From ea000116186786744e29ac813f61b9a402bfed35 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 17 Mar 2026 17:30:51 -0300 Subject: [PATCH 01/35] chore: add magicast dependency for AST-based code manipulation Used by the init command's framework scaffolders to safely add imports and modify config files without breaking existing code. --- packages/cli-core/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index aaaa7718b..bfc206a65 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -21,6 +21,7 @@ "@napi-rs/keyring": "^1.2.0", "commander": "^14.0.3", "env-paths": "^4.0.0", + "magicast": "^0.5.2", "yaml": "^2.8.2" } } From 74f88b3aa99abce98beb6771f81e83455bd03033 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 17 Mar 2026 17:30:58 -0300 Subject: [PATCH 02/35] refactor: fix framework SDK names and detection priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix @clerk/clerk-react → @clerk/react - Fix @clerk/tanstack-start → @clerk/tanstack-react-start - Remove standalone vite detection (covered by react) - Reorder priority: scaffoldable frameworks first, then expo, react, express, fastify - Export readDeps for use by init context module --- packages/cli-core/src/lib/framework.test.ts | 60 +++++++++------------ packages/cli-core/src/lib/framework.ts | 18 ++++--- 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/packages/cli-core/src/lib/framework.test.ts b/packages/cli-core/src/lib/framework.test.ts index 45c2eb6ce..6f794a771 100644 --- a/packages/cli-core/src/lib/framework.test.ts +++ b/packages/cli-core/src/lib/framework.test.ts @@ -35,14 +35,6 @@ describe("detectFramework", () => { expect(fw!.envVar).toBe("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"); }); - test("detects Expo", async () => { - await writePkg(tempDir, { expo: "52.0.0", react: "19.0.0" }); - const fw = await detectFramework(tempDir); - expect(fw!.name).toBe("Expo"); - expect(fw!.sdk).toBe("@clerk/expo"); - expect(fw!.envVar).toBe("EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY"); - }); - test("detects Astro", async () => { await writePkg(tempDir, { astro: "5.0.0" }); const fw = await detectFramework(tempDir); @@ -63,7 +55,7 @@ describe("detectFramework", () => { await writePkg(tempDir, { "@tanstack/react-start": "1.0.0", react: "19.0.0" }); const fw = await detectFramework(tempDir); expect(fw!.name).toBe("TanStack Start"); - expect(fw!.sdk).toBe("@clerk/tanstack-start"); + expect(fw!.sdk).toBe("@clerk/tanstack-react-start"); expect(fw!.envVar).toBe("VITE_CLERK_PUBLISHABLE_KEY"); }); @@ -75,22 +67,6 @@ describe("detectFramework", () => { expect(fw!.envVar).toBe("VITE_CLERK_PUBLISHABLE_KEY"); }); - test("detects Fastify", async () => { - await writePkg(tempDir, { fastify: "5.0.0" }); - const fw = await detectFramework(tempDir); - expect(fw!.name).toBe("Fastify"); - expect(fw!.sdk).toBe("@clerk/fastify"); - expect(fw!.envVar).toBe("CLERK_PUBLISHABLE_KEY"); - }); - - test("detects Express", async () => { - await writePkg(tempDir, { express: "5.0.0" }); - const fw = await detectFramework(tempDir); - expect(fw!.name).toBe("Express"); - expect(fw!.sdk).toBe("@clerk/express"); - expect(fw!.envVar).toBe("CLERK_PUBLISHABLE_KEY"); - }); - test("detects Vue standalone", async () => { await writePkg(tempDir, { vue: "3.0.0" }); const fw = await detectFramework(tempDir); @@ -103,16 +79,32 @@ describe("detectFramework", () => { await writePkg(tempDir, { react: "19.0.0" }); const fw = await detectFramework(tempDir); expect(fw!.name).toBe("React"); - expect(fw!.sdk).toBe("@clerk/clerk-react"); + expect(fw!.sdk).toBe("@clerk/react"); expect(fw!.envVar).toBe("VITE_CLERK_PUBLISHABLE_KEY"); }); - test("detects Vite (no framework)", async () => { - await writePkg(tempDir, {}, { vite: "6.0.0" }); + test("detects Expo", async () => { + await writePkg(tempDir, { expo: "52.0.0", react: "18.0.0" }); const fw = await detectFramework(tempDir); - expect(fw!.name).toBe("Vite"); - expect(fw!.sdk).toBe("@clerk/clerk-react"); - expect(fw!.envVar).toBe("VITE_CLERK_PUBLISHABLE_KEY"); + expect(fw!.name).toBe("Expo"); + expect(fw!.sdk).toBe("@clerk/expo"); + expect(fw!.envVar).toBe("EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY"); + }); + + test("detects Express", async () => { + await writePkg(tempDir, { express: "4.0.0" }); + const fw = await detectFramework(tempDir); + expect(fw!.name).toBe("Express"); + expect(fw!.sdk).toBe("@clerk/express"); + expect(fw!.envVar).toBe("CLERK_PUBLISHABLE_KEY"); + }); + + test("detects Fastify", async () => { + await writePkg(tempDir, { fastify: "4.0.0" }); + const fw = await detectFramework(tempDir); + expect(fw!.name).toBe("Fastify"); + expect(fw!.sdk).toBe("@clerk/fastify"); + expect(fw!.envVar).toBe("CLERK_PUBLISHABLE_KEY"); }); // --- Priority / ordering --- @@ -138,7 +130,7 @@ describe("detectFramework", () => { }); test("prefers Expo over React", async () => { - await writePkg(tempDir, { expo: "52.0.0", react: "19.0.0" }); + await writePkg(tempDir, { expo: "52.0.0", react: "18.0.0" }); expect((await detectFramework(tempDir))!.name).toBe("Expo"); }); @@ -180,8 +172,8 @@ describe("detectPublishableKeyName", () => { expect(await detectPublishableKeyName(tempDir)).toBe("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"); }); - test("returns VITE_* for Vite", async () => { - await writePkg(tempDir, {}, { vite: "6.0.0" }); + test("returns VITE_* for React", async () => { + await writePkg(tempDir, { react: "19.0.0" }); expect(await detectPublishableKeyName(tempDir)).toBe("VITE_CLERK_PUBLISHABLE_KEY"); }); diff --git a/packages/cli-core/src/lib/framework.ts b/packages/cli-core/src/lib/framework.ts index 4a7c73d06..206231344 100644 --- a/packages/cli-core/src/lib/framework.ts +++ b/packages/cli-core/src/lib/framework.ts @@ -20,13 +20,12 @@ const FRAMEWORK_MAP: FrameworkInfo[] = [ sdk: "@clerk/nextjs", envVar: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", }, - { dep: "expo", name: "Expo", sdk: "@clerk/expo", envVar: "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY" }, { dep: "astro", name: "Astro", sdk: "@clerk/astro", envVar: "PUBLIC_CLERK_PUBLISHABLE_KEY" }, { dep: "nuxt", name: "Nuxt", sdk: "@clerk/nuxt", envVar: "NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY" }, { dep: "@tanstack/react-start", name: "TanStack Start", - sdk: "@clerk/tanstack-start", + sdk: "@clerk/tanstack-react-start", envVar: "VITE_CLERK_PUBLISHABLE_KEY", }, { @@ -35,16 +34,21 @@ const FRAMEWORK_MAP: FrameworkInfo[] = [ sdk: "@clerk/react-router", envVar: "VITE_CLERK_PUBLISHABLE_KEY", }, - { dep: "fastify", name: "Fastify", sdk: "@clerk/fastify", envVar: "CLERK_PUBLISHABLE_KEY" }, - { dep: "express", name: "Express", sdk: "@clerk/express", envVar: "CLERK_PUBLISHABLE_KEY" }, { dep: "vue", name: "Vue", sdk: "@clerk/vue", envVar: "VITE_CLERK_PUBLISHABLE_KEY" }, - { dep: "react", name: "React", sdk: "@clerk/clerk-react", envVar: "VITE_CLERK_PUBLISHABLE_KEY" }, - { dep: "vite", name: "Vite", sdk: "@clerk/clerk-react", envVar: "VITE_CLERK_PUBLISHABLE_KEY" }, + { + dep: "expo", + name: "Expo", + sdk: "@clerk/expo", + envVar: "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY", + }, + { dep: "react", name: "React", sdk: "@clerk/react", envVar: "VITE_CLERK_PUBLISHABLE_KEY" }, + { dep: "express", name: "Express", sdk: "@clerk/express", envVar: "CLERK_PUBLISHABLE_KEY" }, + { dep: "fastify", name: "Fastify", sdk: "@clerk/fastify", envVar: "CLERK_PUBLISHABLE_KEY" }, ]; const FALLBACK_KEY = "CLERK_PUBLISHABLE_KEY"; -async function readDeps(cwd: string): Promise | null> { +export async function readDeps(cwd: string): Promise | null> { const file = Bun.file(join(cwd, "package.json")); if (!(await file.exists())) return null; From ce67a591602e31edd84423fde17d9aea917b1e3f Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 17 Mar 2026 17:31:04 -0300 Subject: [PATCH 03/35] feat(init): add project context gathering Detects framework, TypeScript, src/ directory convention, package manager, Next.js router variant, middleware filename, layout path, and existing Clerk SDK installation. --- .../src/commands/init/context.test.ts | 261 ++++++++++++++++++ .../cli-core/src/commands/init/context.ts | 167 +++++++++++ 2 files changed, 428 insertions(+) create mode 100644 packages/cli-core/src/commands/init/context.test.ts create mode 100644 packages/cli-core/src/commands/init/context.ts diff --git a/packages/cli-core/src/commands/init/context.test.ts b/packages/cli-core/src/commands/init/context.test.ts new file mode 100644 index 000000000..61cef1c18 --- /dev/null +++ b/packages/cli-core/src/commands/init/context.test.ts @@ -0,0 +1,261 @@ +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 { gatherContext, parseNextMajorVersion } from "./context.ts"; + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-ctx-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +test("returns null when no package.json exists", async () => { + const ctx = await gatherContext(tempDir); + expect(ctx).toBeNull(); +}); + +test("returns null when no framework detected", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { lodash: "4.0.0" } }), + ); + const ctx = await gatherContext(tempDir); + expect(ctx).toBeNull(); +}); + +test("detects Next.js with app-router variant", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await gatherContext(tempDir); + + expect(ctx).not.toBeNull(); + expect(ctx!.framework.dep).toBe("next"); + expect(ctx!.framework.sdk).toBe("@clerk/nextjs"); + expect(ctx!.variant).toBe("app-router"); + expect(ctx!.typescript).toBe(true); + expect(ctx!.srcDir).toBe(false); + expect(ctx!.layoutPath).toBe("app/layout.tsx"); + expect(ctx!.middlewareBasename).toBe("middleware"); +}); + +test("detects Next.js with pages-router variant", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "12.0.0", react: "18.0.0" } }), + ); + await mkdir(join(tempDir, "pages"), { recursive: true }); + await Bun.write(join(tempDir, "pages/_app.tsx"), "export default function App() {}"); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await gatherContext(tempDir); + + expect(ctx).not.toBeNull(); + expect(ctx!.variant).toBe("pages-router"); + expect(ctx!.layoutPath).toBe("pages/_app.tsx"); +}); + +test("detects src/ directory convention", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "src/app"), { recursive: true }); + await Bun.write(join(tempDir, "src/app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await gatherContext(tempDir); + + expect(ctx).not.toBeNull(); + expect(ctx!.srcDir).toBe(true); + expect(ctx!.layoutPath).toBe("src/app/layout.tsx"); +}); + +test("detects JavaScript projects (no tsconfig)", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.jsx"), "{children}"); + + const ctx = await gatherContext(tempDir); + + expect(ctx).not.toBeNull(); + expect(ctx!.typescript).toBe(false); + expect(ctx!.layoutPath).toBe("app/layout.jsx"); +}); + +test("detects existing Clerk SDK in dependencies", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0", "@clerk/nextjs": "6.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + + const ctx = await gatherContext(tempDir); + + expect(ctx).not.toBeNull(); + expect(ctx!.existingClerk).toBe(true); +}); + +test("detects package manager from bun.lockb", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0" } }), + ); + await Bun.write(join(tempDir, "bun.lockb"), ""); + await mkdir(join(tempDir, "app"), { recursive: true }); + + const ctx = await gatherContext(tempDir); + + expect(ctx!.packageManager).toBe("bun"); +}); + +test("detects package manager from bun.lock (text format)", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0" } }), + ); + await Bun.write(join(tempDir, "bun.lock"), ""); + await mkdir(join(tempDir, "app"), { recursive: true }); + + const ctx = await gatherContext(tempDir); + + expect(ctx!.packageManager).toBe("bun"); +}); + +test("detects package manager from yarn.lock", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0" } }), + ); + await Bun.write(join(tempDir, "yarn.lock"), ""); + await mkdir(join(tempDir, "app"), { recursive: true }); + + const ctx = await gatherContext(tempDir); + + expect(ctx!.packageManager).toBe("yarn"); +}); + +test("defaults to npm when no lockfile found", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + + const ctx = await gatherContext(tempDir); + + expect(ctx!.packageManager).toBe("npm"); +}); + +test("defaults to app-router when neither app/ nor pages/ exists", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0" } }), + ); + + const ctx = await gatherContext(tempDir); + + expect(ctx!.variant).toBe("app-router"); + expect(ctx!.layoutPath).toBeNull(); +}); + +test("uses proxy.ts for Next.js 16+", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "16.0.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + + const ctx = await gatherContext(tempDir); + + expect(ctx!.middlewareBasename).toBe("proxy"); +}); + +test("uses middleware.ts for Next.js 15", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.1.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + + const ctx = await gatherContext(tempDir); + + expect(ctx!.middlewareBasename).toBe("middleware"); +}); + +test("uses middleware.ts for Next.js with caret range ≤15", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "^14.2.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + + const ctx = await gatherContext(tempDir); + + expect(ctx!.middlewareBasename).toBe("middleware"); +}); + +test("uses proxy.ts for Next.js with caret range ≥16", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "^16.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + + const ctx = await gatherContext(tempDir); + + expect(ctx!.middlewareBasename).toBe("proxy"); +}); + +test("prefers existing proxy.ts over version detection", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + await Bun.write(join(tempDir, "proxy.ts"), "export default function() {}"); + + const ctx = await gatherContext(tempDir); + + // Even though version is 15 (would normally pick middleware), proxy.ts exists + expect(ctx!.middlewareBasename).toBe("proxy"); +}); + +test("prefers existing middleware.ts over version detection", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "16.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + await Bun.write(join(tempDir, "middleware.ts"), "export default function() {}"); + + const ctx = await gatherContext(tempDir); + + // Even though version is 16 (would normally pick proxy), middleware.ts exists + expect(ctx!.middlewareBasename).toBe("middleware"); +}); + +test("parseNextMajorVersion handles various formats", () => { + expect(parseNextMajorVersion("15.0.0")).toBe(15); + expect(parseNextMajorVersion("^16.1.0")).toBe(16); + expect(parseNextMajorVersion("~14.2.3")).toBe(14); + expect(parseNextMajorVersion(">=16")).toBe(16); + expect(parseNextMajorVersion("latest")).toBeNull(); + expect(parseNextMajorVersion("*")).toBeNull(); + expect(parseNextMajorVersion("canary")).toBeNull(); +}); diff --git a/packages/cli-core/src/commands/init/context.ts b/packages/cli-core/src/commands/init/context.ts new file mode 100644 index 000000000..2390b6263 --- /dev/null +++ b/packages/cli-core/src/commands/init/context.ts @@ -0,0 +1,167 @@ +import { join } from "node:path"; +import { stat } from "node:fs/promises"; +import { detectFramework, readDeps } from "../../lib/framework.js"; +import { findFirstFile } from "./frameworks/helpers.js"; +import type { ProjectContext } from "./frameworks/types.js"; + +export async function fileExists(path: string): Promise { + return Bun.file(path).exists(); +} + +async function dirExists(path: string): Promise { + try { + const s = await stat(path); + return s.isDirectory(); + } catch { + return false; + } +} + +async function detectPackageManager(cwd: string): Promise { + const checks: Array<{ files: string[]; pm: ProjectContext["packageManager"] }> = [ + { files: ["bun.lockb", "bun.lock"], pm: "bun" }, + { files: ["yarn.lock"], pm: "yarn" }, + { files: ["pnpm-lock.yaml"], pm: "pnpm" }, + ]; + + for (const { files, pm } of checks) { + for (const file of files) { + if (await fileExists(join(cwd, file))) return pm; + } + } + return "npm"; +} + +// Re-export for modules that import readDeps from context (e.g., format.ts) +export { readDeps } from "../../lib/framework.js"; + +/** + * Parse the major version from a semver-like string. + * Handles: "15.0.0", "^15.0.0", "~15.0.0", ">=15", etc. + * Returns null for non-numeric versions like "latest", "canary", "*". + */ +export function parseNextMajorVersion(version: string): number | null { + const match = version.match(/(\d+)/); + return match ? parseInt(match[1]!, 10) : null; +} + +/** + * Determine the correct middleware filename for a Next.js project. + * Next.js 16+ uses proxy.ts, ≤15 uses middleware.ts. + * + * Priority: existing file > version-based > default to proxy (latest convention). + */ +async function detectMiddlewareBasename( + cwd: string, + srcDir: boolean, + ext: string, + nextVersion: string | undefined, +): Promise { + const base = srcDir ? "src/" : ""; + + // Existing file takes precedence + if (await fileExists(join(cwd, `${base}proxy.${ext}`))) return "proxy"; + if (await fileExists(join(cwd, `${base}middleware.${ext}`))) return "middleware"; + + // Fall back to version detection + if (!nextVersion) return "proxy"; + + const major = parseNextMajorVersion(nextVersion); + if (major === null) return "proxy"; // Unknown version (e.g., "latest", "*") + + return major >= 16 ? "proxy" : "middleware"; +} + +async function detectLayoutPath( + cwd: string, + dep: string, + variant: ProjectContext["variant"], + srcDir: boolean, + ext: string, +): Promise { + const base = srcDir ? "src/" : ""; + + if (dep === "next") { + if (variant === "pages-router") { + return findFirstFile(cwd, [`${base}pages/_app.${ext}x`, `${base}pages/_app.${ext}`]); + } + return findFirstFile(cwd, [`${base}app/layout.${ext}x`, `${base}app/layout.${ext}`]); + } + + return null; +} + +function detectNextjsVariant( + dep: string, + dirs: { + srcDir: boolean; + srcAppDir: boolean; + srcPagesDir: boolean; + rootAppDir: boolean; + rootPagesDir: boolean; + }, +): ProjectContext["variant"] { + if (dep !== "next") return null; + + const appExists = dirs.srcDir ? dirs.srcAppDir : dirs.rootAppDir; + if (appExists) return "app-router"; + + const pagesExists = dirs.srcDir ? dirs.srcPagesDir : dirs.rootPagesDir; + if (pagesExists) return "pages-router"; + + return "app-router"; // Default for new Next.js projects +} + +export async function gatherContext(cwd: string): Promise { + const framework = await detectFramework(cwd); + if (!framework) return null; + + const typescript = await fileExists(join(cwd, "tsconfig.json")); + const ext = typescript ? "ts" : "js"; + + const srcAppDir = await dirExists(join(cwd, "src/app")); + const srcPagesDir = await dirExists(join(cwd, "src/pages")); + const rootAppDir = await dirExists(join(cwd, "app")); + const rootPagesDir = await dirExists(join(cwd, "pages")); + + // Use src/ convention only when app/pages dirs exist in src/ but NOT in root + const hasSrcStructure = srcAppDir || srcPagesDir; + const hasRootStructure = rootAppDir || rootPagesDir; + const srcDir = hasSrcStructure && !hasRootStructure; + + const variant = detectNextjsVariant(framework.dep, { + srcDir, + srcAppDir, + srcPagesDir, + rootAppDir, + rootPagesDir, + }); + + const packageManager = await detectPackageManager(cwd); + + const deps = await readDeps(cwd); + const existingClerk = deps ? Object.keys(deps).some((d) => d.startsWith("@clerk/")) : false; + + const layoutPath = await detectLayoutPath(cwd, framework.dep, variant, srcDir, ext); + + const envFile = (await fileExists(join(cwd, ".env.local"))) ? ".env.local" : ".env"; + + const middlewareBasename = + framework.dep === "next" + ? await detectMiddlewareBasename(cwd, srcDir, ext, deps?.[framework.dep]) + : ("middleware" as const); + + return { + cwd, + framework, + variant, + typescript, + srcDir, + packageManager, + existingClerk, + deps: deps ?? {}, + layoutPath, + envFile, + middlewareBasename, + }; +} From c60cffd17b46e7bd46f069a683f3a0c45f52535d Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 17 Mar 2026 17:31:12 -0300 Subject: [PATCH 04/35] feat(init): add scaffold system with framework-specific scaffolders Add idempotent scaffolders for Next.js (App + Pages Router), React, React Router, Nuxt, TanStack Start, Astro, and Vue. Each scaffolder generates framework-appropriate boilerplate (middleware, providers, auth pages) while preserving existing user code. Shared helpers handle AST-based import injection (magicast with string fallback), middleware composition, and auth page creation. --- .../src/commands/init/frameworks/astro.ts | 161 ++++++++++++++ .../src/commands/init/frameworks/helpers.ts | 200 +++++++++++++++++ .../init/frameworks/nextjs-app.test.ts | 201 ++++++++++++++++++ .../commands/init/frameworks/nextjs-app.ts | 94 ++++++++ .../commands/init/frameworks/nextjs-pages.ts | 115 ++++++++++ .../src/commands/init/frameworks/nuxt.ts | 92 ++++++++ .../commands/init/frameworks/react-router.ts | 197 +++++++++++++++++ .../src/commands/init/frameworks/react.ts | 75 +++++++ .../init/frameworks/tanstack-start.ts | 152 +++++++++++++ .../src/commands/init/frameworks/types.ts | 36 ++++ .../src/commands/init/frameworks/vue.ts | 80 +++++++ .../cli-core/src/commands/init/scaffold.ts | 43 ++++ 12 files changed, 1446 insertions(+) create mode 100644 packages/cli-core/src/commands/init/frameworks/astro.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/helpers.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/nextjs-app.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/nuxt.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/react-router.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/react.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/tanstack-start.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/types.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/vue.ts create mode 100644 packages/cli-core/src/commands/init/scaffold.ts diff --git a/packages/cli-core/src/commands/init/frameworks/astro.ts b/packages/cli-core/src/commands/init/frameworks/astro.ts new file mode 100644 index 000000000..4d862e209 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/astro.ts @@ -0,0 +1,161 @@ +import { join } from "node:path"; +import { parseModule } from "magicast"; +import { findFirstFile, hasClerkImport, scaffoldAuthPage } from "./helpers.js"; +import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +function middlewareContent(): string { + return `import { clerkMiddleware } from "@clerk/astro/server"; + +export const onRequest = clerkMiddleware(); +`; +} + +function signInPageContent(): string { + return `--- +import { SignIn } from "@clerk/astro/components"; +--- + + +`; +} + +function signUpPageContent(): string { + return `--- +import { SignUp } from "@clerk/astro/components"; +--- + + +`; +} + +function addClerkImport(content: string): string { + try { + const mod = parseModule(content); + mod.imports.$add({ from: "@clerk/astro", imported: "default", local: "clerk" }); + return mod.generate().code; + } catch { + return `import clerk from "@clerk/astro";\n${content}`; + } +} + +function addClerkToIntegrations(content: string): string { + if (content.includes("integrations:")) { + return content.replace(/(integrations:\s*\[)/, "$1clerk(), "); + } + if (content.includes("defineConfig")) { + return content.replace(/(defineConfig\s*\(\s*\{)/, "$1\n integrations: [clerk()],"); + } + return content; +} + +function addClerkIntegration(content: string): string { + return addClerkToIntegrations(addClerkImport(content)); +} + +async function scaffoldConfig(ctx: ProjectContext): Promise { + const configPath = await findFirstFile(ctx.cwd, [ + "astro.config.mjs", + "astro.config.ts", + "astro.config.js", + ]); + if (!configPath) return null; + + const content = await Bun.file(join(ctx.cwd, configPath)).text(); + + if (content.includes("@clerk/astro")) { + return { + path: configPath, + type: "modify", + content, + description: "Add clerk() integration", + skipReason: "Already has @clerk/astro integration", + }; + } + + const newContent = addClerkIntegration(content); + + return { + path: configPath, + type: "modify", + content: newContent, + description: "Add clerk() to integrations and import", + }; +} + +async function scaffoldMiddleware(ctx: ProjectContext): Promise { + const ext = ctx.typescript ? "ts" : "js"; + const path = `src/middleware.${ext}`; + const fullPath = join(ctx.cwd, path); + + const file = Bun.file(fullPath); + if (await file.exists()) { + const content = await file.text(); + if (hasClerkImport(content)) { + return { + path, + type: "modify", + content: "", + description: "Create Clerk middleware", + skipReason: "Already has Clerk middleware", + }; + } + + // Existing non-Clerk middleware — skip to avoid overwriting user code + return { + path, + type: "modify", + content: "", + description: "Create Clerk middleware", + skipReason: "Existing middleware found — add clerkMiddleware() manually", + }; + } + + return { + path, + type: "create", + content: middlewareContent(), + description: "Create Clerk middleware with onRequest export", + }; +} + +export const astro: FrameworkScaffold = { + name: "Astro", + + async scaffold(ctx: ProjectContext): Promise { + const actions: FileAction[] = []; + const postInstructions: string[] = []; + + const configAction = await scaffoldConfig(ctx); + if (configAction) { + actions.push(configAction); + } else { + postInstructions.push( + "Add `import clerk from '@clerk/astro'` and `clerk()` to integrations in astro.config.mjs. See: https://clerk.com/docs/quickstarts/astro", + ); + } + + actions.push(await scaffoldMiddleware(ctx)); + actions.push( + await scaffoldAuthPage( + ctx.cwd, + "src/pages/sign-in.astro", + signInPageContent(), + "sign-in page", + ), + ); + actions.push( + await scaffoldAuthPage( + ctx.cwd, + "src/pages/sign-up.astro", + signUpPageContent(), + "sign-up page", + ), + ); + + postInstructions.push( + "Ensure your Astro config has `output: 'server'` and an SSR adapter (e.g., @astrojs/node)", + ); + + return { actions, postInstructions }; + }, +}; diff --git a/packages/cli-core/src/commands/init/frameworks/helpers.ts b/packages/cli-core/src/commands/init/frameworks/helpers.ts new file mode 100644 index 000000000..c21c4f98a --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/helpers.ts @@ -0,0 +1,200 @@ +import { join } from "node:path"; +import { parseModule } from "magicast"; +import type { FileAction } from "./types.js"; + +/** Check if file content already imports from a @clerk/ package. */ +export function hasClerkImport(content: string): boolean { + return content.includes("@clerk/"); +} + +/** Find the first existing file from a list of candidates relative to cwd. */ +export async function findFirstFile(cwd: string, candidates: string[]): Promise { + for (const candidate of candidates) { + if (await Bun.file(join(cwd, candidate)).exists()) return candidate; + } + return null; +} + +/** + * Add an import to a file using magicast AST, with a string-prepend fallback. + * Returns the modified source code. + */ +export function safeAddImport(content: string, source: string, imported: string): string { + try { + const mod = parseModule(content); + mod.imports.$add({ from: source, imported, local: imported }); + return mod.generate().code; + } catch { + return `import { ${imported} } from "${source}";\n${content}`; + } +} + +/** Next.js clerkMiddleware with route protection and matcher config. */ +export function nextjsMiddlewareContent(): string { + return `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; + +const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]); + +export default clerkMiddleware(async (auth, request) => { + if (!isPublicRoute(request)) { + await auth.protect(); + } +}); + +export const config = { + matcher: [ + "/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + "/(api|trpc)(.*)", + ], +}; +`; +} + +/** Next.js sign-in page component. */ +export function nextjsSignInPageContent(): string { + return `import { SignIn } from "@clerk/nextjs"; + +export default function SignInPage() { + return ; +} +`; +} + +/** Next.js sign-up page component. */ +export function nextjsSignUpPageContent(): string { + return `import { SignUp } from "@clerk/nextjs"; + +export default function SignUpPage() { + return ; +} +`; +} + +/** + * Compose Clerk middleware with existing non-Clerk middleware. + * Renames the existing default export and wraps it inside clerkMiddleware. + */ +export function composeWithExistingMiddleware(existing: string): string { + const clerkImport = `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";\n`; + const routeMatcher = `\nconst isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]);\n`; + + const hasDefaultExport = /export\s+default\s+/.test(existing); + + if (hasDefaultExport) { + let content = existing.replace( + /export\s+default\s+(?:async\s+)?function\s+(\w+)?/, + "async function existingMiddleware", + ); + content = content.replace( + /export\s+default\s+(?:async\s+)?(\([^)]*\)\s*=>)/, + "const existingMiddleware = async $1", + ); + + return ( + clerkImport + + routeMatcher + + "\n" + + content + + `\nexport default clerkMiddleware(async (auth, request) => { + if (!isPublicRoute(request)) { + await auth.protect(); + } + return existingMiddleware(request); +}); + +export const config = { + matcher: [ + "/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + "/(api|trpc)(.*)", + ], +}; +` + ); + } + + return clerkImport + routeMatcher + "\n" + existing + "\n" + nextjsMiddlewareContent(); +} + +/** + * Scaffold Next.js middleware — shared between App Router and Pages Router. + * Checks for existing middleware and returns skip/create/compose action accordingly. + * When existing non-Clerk middleware is found, it composes rather than overwriting. + */ +export async function scaffoldNextjsMiddleware(ctx: { + cwd: string; + srcDir: boolean; + typescript: boolean; + middlewareBasename: "proxy" | "middleware"; +}): Promise { + const base = ctx.srcDir ? "src/" : ""; + const ext = ctx.typescript ? "ts" : "js"; + const path = `${base}${ctx.middlewareBasename}.${ext}`; + const fullPath = join(ctx.cwd, path); + + const file = Bun.file(fullPath); + if (await file.exists()) { + const content = await file.text(); + if (hasClerkImport(content)) { + return { + path, + type: "modify", + content: "", + description: "Create Clerk middleware", + skipReason: "Already has Clerk middleware", + }; + } + + return { + path, + type: "modify", + content: composeWithExistingMiddleware(content), + description: "Add clerkMiddleware to existing middleware", + }; + } + + return { + path, + type: "create", + content: nextjsMiddlewareContent(), + description: "Create Clerk middleware with route protection", + }; +} + +/** Shared post-instruction for Next.js sign-in/sign-up env vars. Used by both App and Pages Router. */ +export const NEXTJS_SIGN_ROUTES_INSTRUCTION = + "Add to your .env.local: NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in, NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up, NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/, NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/"; + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Generic helper for scaffolding an auth page (sign-in or sign-up). + * Handles the common create-or-skip pattern used by every framework scaffolder. + */ +export async function scaffoldAuthPage( + cwd: string, + path: string, + content: string, + label: string, +): Promise { + const capitalizedLabel = capitalize(label); + + if (await Bun.file(join(cwd, path)).exists()) { + return { + path, + type: "create", + content: "", + description: `Create ${label}`, + skipReason: `${capitalizedLabel} already exists`, + }; + } + + const component = label.includes("sign-in") ? "SignIn" : "SignUp"; + return { + path, + type: "create", + content, + description: `Create ${label} with <${component} /> component`, + }; +} diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts new file mode 100644 index 000000000..b01dfd3df --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts @@ -0,0 +1,201 @@ +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 { nextjsApp } from "./nextjs-app.ts"; +import type { ProjectContext } from "./types.ts"; + +let tempDir: string; + +function makeCtx(overrides?: Partial): ProjectContext { + return { + cwd: tempDir, + framework: { + dep: "next", + name: "Next.js", + sdk: "@clerk/nextjs", + envVar: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + }, + variant: "app-router", + typescript: true, + srcDir: false, + packageManager: "npm", + existingClerk: false, + deps: {}, + layoutPath: "app/layout.tsx", + envFile: ".env.local", + middlewareBasename: "middleware", + ...overrides, + }; +} + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-nextjs-app-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +test("scaffolds all 4 files for a fresh Next.js App Router project", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "app/layout.tsx"), + `export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +`, + ); + + const plan = await nextjsApp.scaffold(makeCtx()); + + expect(plan.actions).toHaveLength(4); + + // Middleware + expect(plan.actions[0]!.path).toBe("middleware.ts"); + expect(plan.actions[0]!.type).toBe("create"); + expect(plan.actions[0]!.content).toContain("clerkMiddleware"); + expect(plan.actions[0]!.content).toContain("createRouteMatcher"); + expect(plan.actions[0]!.skipReason).toBeUndefined(); + + // Layout + expect(plan.actions[1]!.path).toBe("app/layout.tsx"); + expect(plan.actions[1]!.type).toBe("modify"); + expect(plan.actions[1]!.content).toContain("ClerkProvider"); + expect(plan.actions[1]!.content).toContain("@clerk/nextjs"); + + // Sign-in + expect(plan.actions[2]!.path).toBe("app/sign-in/[[...sign-in]]/page.tsx"); + expect(plan.actions[2]!.type).toBe("create"); + expect(plan.actions[2]!.content).toContain(""); + + // Sign-up + expect(plan.actions[3]!.path).toBe("app/sign-up/[[...sign-up]]/page.tsx"); + expect(plan.actions[3]!.type).toBe("create"); + expect(plan.actions[3]!.content).toContain(""); +}); + +test("skips middleware when already has Clerk", async () => { + await Bun.write( + join(tempDir, "middleware.ts"), + `import { clerkMiddleware } from "@clerk/nextjs/server";\nexport default clerkMiddleware();`, + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx()); + + expect(plan.actions[0]!.skipReason).toBe("Already has Clerk middleware"); +}); + +test("skips layout when already has ClerkProvider", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "app/layout.tsx"), + `import { ClerkProvider } from "@clerk/nextjs";\nexport default function L({ children }) { return {children}; }`, + ); + + const plan = await nextjsApp.scaffold(makeCtx()); + + expect(plan.actions[1]!.skipReason).toBe("Already has ClerkProvider"); +}); + +test("skips sign-in page when it already exists", async () => { + await mkdir(join(tempDir, "app/sign-in/[[...sign-in]]"), { recursive: true }); + await Bun.write( + join(tempDir, "app/sign-in/[[...sign-in]]/page.tsx"), + "export default function() {}", + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx()); + + expect(plan.actions[2]!.skipReason).toBe("Sign-in page already exists"); +}); + +test("uses src/ paths when srcDir is true", async () => { + await mkdir(join(tempDir, "src/app"), { recursive: true }); + await Bun.write(join(tempDir, "src/app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold( + makeCtx({ srcDir: true, layoutPath: "src/app/layout.tsx" }), + ); + + expect(plan.actions[0]!.path).toBe("src/middleware.ts"); + expect(plan.actions[2]!.path).toBe("src/app/sign-in/[[...sign-in]]/page.tsx"); + expect(plan.actions[3]!.path).toBe("src/app/sign-up/[[...sign-up]]/page.tsx"); +}); + +test("uses .jsx extension when typescript is false", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.jsx"), "{children}"); + + const plan = await nextjsApp.scaffold( + makeCtx({ typescript: false, layoutPath: "app/layout.jsx" }), + ); + + expect(plan.actions[0]!.path).toBe("middleware.js"); + expect(plan.actions[2]!.path).toBe("app/sign-in/[[...sign-in]]/page.jsx"); +}); + +test("adds post-instructions for sign-in/sign-up URLs", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx()); + + expect(plan.postInstructions.length).toBeGreaterThan(0); + expect(plan.postInstructions.some((i) => i.includes("NEXT_PUBLIC_CLERK_SIGN_IN_URL"))).toBe(true); +}); + +test("adds post-instruction when no layout found", async () => { + const plan = await nextjsApp.scaffold(makeCtx({ layoutPath: null })); + + expect(plan.postInstructions.some((i) => i.includes("ClerkProvider"))).toBe(true); +}); + +test("composes with existing non-Clerk middleware", async () => { + await Bun.write( + join(tempDir, "middleware.ts"), + `import { NextResponse } from "next/server"; +export default function middleware(request) { + return NextResponse.next(); +} +`, + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx()); + + expect(plan.actions[0]!.type).toBe("modify"); + expect(plan.actions[0]!.content).toContain("clerkMiddleware"); + expect(plan.actions[0]!.content).toContain("existingMiddleware"); + expect(plan.actions[0]!.skipReason).toBeUndefined(); +}); + +test("uses proxy.ts when middlewareBasename is proxy", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx({ middlewareBasename: "proxy" })); + + expect(plan.actions[0]!.path).toBe("proxy.ts"); + expect(plan.actions[0]!.content).toContain("clerkMiddleware"); +}); + +test("uses src/proxy.ts when srcDir and middlewareBasename is proxy", async () => { + await mkdir(join(tempDir, "src/app"), { recursive: true }); + await Bun.write(join(tempDir, "src/app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold( + makeCtx({ srcDir: true, layoutPath: "src/app/layout.tsx", middlewareBasename: "proxy" }), + ); + + expect(plan.actions[0]!.path).toBe("src/proxy.ts"); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts new file mode 100644 index 000000000..c85b21e22 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts @@ -0,0 +1,94 @@ +import { join } from "node:path"; +import { + NEXTJS_SIGN_ROUTES_INSTRUCTION, + nextjsSignInPageContent, + nextjsSignUpPageContent, + safeAddImport, + scaffoldAuthPage, + scaffoldNextjsMiddleware, +} from "./helpers.js"; +import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +async function scaffoldLayout(ctx: ProjectContext): Promise { + if (!ctx.layoutPath) return null; + + const fullPath = join(ctx.cwd, ctx.layoutPath); + const file = Bun.file(fullPath); + if (!(await file.exists())) return null; + + const content = await file.text(); + + if (content.includes("ClerkProvider")) { + return { + path: ctx.layoutPath, + type: "modify", + content, + description: "Add ClerkProvider to layout", + skipReason: "Already has ClerkProvider", + }; + } + + let newContent = safeAddImport(content, "@clerk/nextjs", "ClerkProvider"); + + if (newContent.includes("]*>)(\s*)/, "$1$2\n"); + newContent = newContent.replace(/(\s*)(<\/body>)/, "\n$1$2"); + } else { + return { + path: ctx.layoutPath, + type: "modify", + content: newContent, + description: "Add ClerkProvider import (manual wrapping needed)", + }; + } + + return { + path: ctx.layoutPath, + type: "modify", + content: newContent, + description: "Add ClerkProvider import and wrap body contents", + }; +} + +function signInPath(ctx: ProjectContext): string { + const base = ctx.srcDir ? "src/" : ""; + const ext = ctx.typescript ? "tsx" : "jsx"; + return `${base}app/sign-in/[[...sign-in]]/page.${ext}`; +} + +function signUpPath(ctx: ProjectContext): string { + const base = ctx.srcDir ? "src/" : ""; + const ext = ctx.typescript ? "tsx" : "jsx"; + return `${base}app/sign-up/[[...sign-up]]/page.${ext}`; +} + +export const nextjsApp: FrameworkScaffold = { + name: "Next.js (App Router)", + + async scaffold(ctx: ProjectContext): Promise { + const actions: FileAction[] = []; + const postInstructions: string[] = []; + + actions.push(await scaffoldNextjsMiddleware(ctx)); + + const layoutAction = await scaffoldLayout(ctx); + if (layoutAction) { + actions.push(layoutAction); + } else { + postInstructions.push( + "Wrap your root layout with from @clerk/nextjs. See: https://clerk.com/docs/quickstarts/nextjs", + ); + } + + actions.push( + await scaffoldAuthPage(ctx.cwd, signInPath(ctx), nextjsSignInPageContent(), "sign-in page"), + ); + actions.push( + await scaffoldAuthPage(ctx.cwd, signUpPath(ctx), nextjsSignUpPageContent(), "sign-up page"), + ); + + postInstructions.push(NEXTJS_SIGN_ROUTES_INSTRUCTION); + + return { actions, postInstructions }; + }, +}; diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts new file mode 100644 index 000000000..7cdbd6484 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts @@ -0,0 +1,115 @@ +import { join } from "node:path"; +import { + NEXTJS_SIGN_ROUTES_INSTRUCTION, + nextjsSignInPageContent, + nextjsSignUpPageContent, + safeAddImport, + scaffoldAuthPage, + scaffoldNextjsMiddleware, +} from "./helpers.js"; +import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +function appWrapperContent(typescript: boolean): string { + if (typescript) { + return `import { ClerkProvider } from "@clerk/nextjs"; +import type { AppProps } from "next/app"; + +export default function MyApp({ Component, pageProps }: AppProps) { + return ( + + + + ); +} +`; + } + + return `import { ClerkProvider } from "@clerk/nextjs"; + +export default function MyApp({ Component, pageProps }) { + return ( + + + + ); +} +`; +} + +async function scaffoldApp(ctx: ProjectContext): Promise { + const base = ctx.srcDir ? "src/" : ""; + const ext = ctx.typescript ? "tsx" : "jsx"; + const path = `${base}pages/_app.${ext}`; + const fullPath = join(ctx.cwd, path); + + const file = Bun.file(fullPath); + if (await file.exists()) { + const content = await file.text(); + if (content.includes("ClerkProvider")) { + return { + path, + type: "modify", + content, + description: "Wrap _app with ClerkProvider", + skipReason: "Already has ClerkProvider", + }; + } + + let newContent = safeAddImport(content, "@clerk/nextjs", "ClerkProvider"); + + if (newContent.includes(")/, + "\n $1\n ", + ); + } + + return { + path, + type: "modify", + content: newContent, + description: "Add ClerkProvider import and wrap Component", + }; + } + + return { + path, + type: "create", + content: appWrapperContent(ctx.typescript), + description: "Create _app with ClerkProvider wrapper", + }; +} + +function signInPath(ctx: ProjectContext): string { + const base = ctx.srcDir ? "src/" : ""; + const ext = ctx.typescript ? "tsx" : "jsx"; + return `${base}pages/sign-in/[[...sign-in]].${ext}`; +} + +function signUpPath(ctx: ProjectContext): string { + const base = ctx.srcDir ? "src/" : ""; + const ext = ctx.typescript ? "tsx" : "jsx"; + return `${base}pages/sign-up/[[...sign-up]].${ext}`; +} + +export const nextjsPages: FrameworkScaffold = { + name: "Next.js (Pages Router)", + + async scaffold(ctx: ProjectContext): Promise { + const actions: FileAction[] = []; + const postInstructions: string[] = []; + + actions.push(await scaffoldNextjsMiddleware(ctx)); + actions.push(await scaffoldApp(ctx)); + actions.push( + await scaffoldAuthPage(ctx.cwd, signInPath(ctx), nextjsSignInPageContent(), "sign-in page"), + ); + actions.push( + await scaffoldAuthPage(ctx.cwd, signUpPath(ctx), nextjsSignUpPageContent(), "sign-up page"), + ); + + postInstructions.push(NEXTJS_SIGN_ROUTES_INSTRUCTION); + + return { actions, postInstructions }; + }, +}; diff --git a/packages/cli-core/src/commands/init/frameworks/nuxt.ts b/packages/cli-core/src/commands/init/frameworks/nuxt.ts new file mode 100644 index 000000000..82d085649 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/nuxt.ts @@ -0,0 +1,92 @@ +import { join } from "node:path"; +import { parseModule } from "magicast"; +import { findFirstFile, scaffoldAuthPage } from "./helpers.js"; +import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +function signInPageContent(): string { + return ` +`; +} + +function signUpPageContent(): string { + return ` +`; +} + +function addNuxtModule(content: string): string { + try { + const mod = parseModule(content); + const defaultExport = mod.exports.default; + if (!defaultExport || typeof defaultExport !== "object") return content; + + if (!defaultExport.modules) defaultExport.modules = []; + if (Array.isArray(defaultExport.modules)) defaultExport.modules.push("@clerk/nuxt"); + return mod.generate().code; + } catch { + if (content.includes("modules:")) { + return content.replace(/(modules:\s*\[)/, "$1\n '@clerk/nuxt',"); + } + return content.replace(/(defineNuxtConfig\s*\(\s*\{)/, "$1\n modules: ['@clerk/nuxt'],"); + } +} + +async function scaffoldConfig(ctx: ProjectContext): Promise { + const configPath = await findFirstFile(ctx.cwd, ["nuxt.config.ts", "nuxt.config.js"]); + if (!configPath) return null; + + const content = await Bun.file(join(ctx.cwd, configPath)).text(); + + if (content.includes("@clerk/nuxt")) { + return { + path: configPath, + type: "modify", + content, + description: "Add @clerk/nuxt to modules", + skipReason: "Already has @clerk/nuxt module", + }; + } + + const newContent = addNuxtModule(content); + + return { + path: configPath, + type: "modify", + content: newContent, + description: "Add @clerk/nuxt to modules array", + }; +} + +export const nuxt: FrameworkScaffold = { + name: "Nuxt", + + async scaffold(ctx: ProjectContext): Promise { + const actions: FileAction[] = []; + const postInstructions: string[] = []; + + const configAction = await scaffoldConfig(ctx); + if (configAction) { + actions.push(configAction); + } else { + postInstructions.push( + "Add '@clerk/nuxt' to the modules array in your nuxt.config.ts. See: https://clerk.com/docs/quickstarts/nuxt", + ); + } + + actions.push( + await scaffoldAuthPage(ctx.cwd, "pages/sign-in.vue", signInPageContent(), "sign-in page"), + ); + actions.push( + await scaffoldAuthPage(ctx.cwd, "pages/sign-up.vue", signUpPageContent(), "sign-up page"), + ); + + postInstructions.push( + 'Use and components in your app.vue for conditional rendering (auto-imported)', + ); + + return { actions, postInstructions }; + }, +}; diff --git a/packages/cli-core/src/commands/init/frameworks/react-router.ts b/packages/cli-core/src/commands/init/frameworks/react-router.ts new file mode 100644 index 000000000..a90451f09 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/react-router.ts @@ -0,0 +1,197 @@ +import { join } from "node:path"; +import { parseModule } from "magicast"; +import { findFirstFile, safeAddImport, scaffoldAuthPage } from "./helpers.js"; +import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +function signInRouteContent(): string { + return `import { SignIn } from "@clerk/react-router"; + +export default function SignInPage() { + return ; +} +`; +} + +function signUpRouteContent(): string { + return `import { SignUp } from "@clerk/react-router"; + +export default function SignUpPage() { + return ; +} +`; +} + +function addServerImports(source: string): string { + if (source.includes("@clerk/react-router/server")) return source; + + let result = safeAddImport(source, "@clerk/react-router/server", "clerkMiddleware"); + result = safeAddImport(result, "@clerk/react-router/server", "rootAuthLoader"); + return result; +} + +function insertAfterLastImport(source: string, snippet: string): string { + const lastImportIdx = source.lastIndexOf("import "); + const lineEnd = source.indexOf("\n", lastImportIdx); + if (lineEnd === -1) return source; + return source.slice(0, lineEnd + 1) + snippet + source.slice(lineEnd + 1); +} + +function addMiddlewareExport(source: string, typescript: boolean): string { + if (source.includes("export const middleware")) return source; + const typeAnnotation = typescript ? ": Route.MiddlewareFunction[]" : ""; + return insertAfterLastImport( + source, + `\nexport const middleware${typeAnnotation} = [clerkMiddleware()];\n`, + ); +} + +function addLoaderExport(source: string, typescript: boolean): string { + if (source.includes("rootAuthLoader")) return source; + + const middlewareIdx = source.indexOf("export const middleware"); + if (middlewareIdx === -1) return source; + + const lineEnd = source.indexOf("\n", middlewareIdx); + if (lineEnd === -1) return source; + + const argsParam = typescript ? "(args: Route.LoaderArgs)" : "(args)"; + return ( + source.slice(0, lineEnd + 1) + + `\nexport const loader = ${argsParam} => rootAuthLoader(args);\n` + + source.slice(lineEnd + 1) + ); +} + +function wrapOutletWithProvider(source: string): string { + if (!source.includes(")/, + "\n $1\n ", + ); +} + +async function scaffoldRoot(ctx: ProjectContext): Promise { + const rootPath = await findFirstFile(ctx.cwd, ["app/root.tsx", "app/root.jsx"]); + if (!rootPath) return null; + + const content = await Bun.file(join(ctx.cwd, rootPath)).text(); + + if (content.includes("ClerkProvider")) { + return { + path: rootPath, + type: "modify", + content, + description: "Add ClerkProvider to root", + skipReason: "Already has ClerkProvider", + }; + } + + let result = addServerImports(content); + result = safeAddImport(result, "@clerk/react-router", "ClerkProvider"); + result = addMiddlewareExport(result, ctx.typescript); + result = addLoaderExport(result, ctx.typescript); + result = wrapOutletWithProvider(result); + + return { + path: rootPath, + type: "modify", + content: result, + description: "Add ClerkProvider, clerkMiddleware, and rootAuthLoader", + }; +} + +function enableV8Middleware(content: string): string { + try { + const mod = parseModule(content); + const defaultExport = mod.exports.default; + if (!defaultExport || typeof defaultExport !== "object") return content; + + if (!defaultExport.future) defaultExport.future = {}; + defaultExport.future.v8_middleware = true; + return mod.generate().code; + } catch { + if (content.includes("future:")) { + return content.replace(/(future:\s*\{)/, "$1\n v8_middleware: true,"); + } + return content.replace( + /(}\s*satisfies\s*Config)/, + " future: {\n v8_middleware: true,\n },\n$1", + ); + } +} + +async function scaffoldConfig(ctx: ProjectContext): Promise { + const configPath = await findFirstFile(ctx.cwd, [ + "react-router.config.ts", + "react-router.config.js", + ]); + if (!configPath) return null; + + const content = await Bun.file(join(ctx.cwd, configPath)).text(); + + if (content.includes("v8_middleware")) { + return { + path: configPath, + type: "modify", + content, + description: "Enable v8_middleware future flag", + skipReason: "Already has v8_middleware flag", + }; + } + + const newContent = enableV8Middleware(content); + + return { + path: configPath, + type: "modify", + content: newContent, + description: "Enable v8_middleware future flag for Clerk middleware", + }; +} + +export const reactRouter: FrameworkScaffold = { + name: "React Router", + + async scaffold(ctx: ProjectContext): Promise { + const actions: FileAction[] = []; + const postInstructions: string[] = []; + + const configAction = await scaffoldConfig(ctx); + if (configAction) { + actions.push(configAction); + } + + const rootAction = await scaffoldRoot(ctx); + if (rootAction) { + actions.push(rootAction); + } else { + postInstructions.push( + "Add ClerkProvider, clerkMiddleware(), and rootAuthLoader() to your app/root.tsx. See: https://clerk.com/docs/quickstarts/react-router", + ); + } + + const ext = ctx.typescript ? "tsx" : "jsx"; + actions.push( + await scaffoldAuthPage( + ctx.cwd, + `app/routes/sign-in.${ext}`, + signInRouteContent(), + "sign-in route", + ), + ); + actions.push( + await scaffoldAuthPage( + ctx.cwd, + `app/routes/sign-up.${ext}`, + signUpRouteContent(), + "sign-up route", + ), + ); + + postInstructions.push( + "Add sign-in and sign-up routes to app/routes.ts: route('sign-in/*', 'routes/sign-in.tsx') and route('sign-up/*', 'routes/sign-up.tsx')", + ); + + return { actions, postInstructions }; + }, +}; diff --git a/packages/cli-core/src/commands/init/frameworks/react.ts b/packages/cli-core/src/commands/init/frameworks/react.ts new file mode 100644 index 000000000..f320382f2 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/react.ts @@ -0,0 +1,75 @@ +import { join } from "node:path"; +import { findFirstFile, safeAddImport } from "./helpers.js"; +import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +async function findEntryFile(ctx: ProjectContext): Promise { + const base = ctx.srcDir ? "src/" : ""; + const ext = ctx.typescript ? "tsx" : "jsx"; + return findFirstFile(ctx.cwd, [ + `${base}main.${ext}`, + `${base}main.${ctx.typescript ? "ts" : "js"}`, + ]); +} + +async function scaffoldEntry(ctx: ProjectContext): Promise { + const entryPath = await findEntryFile(ctx); + if (!entryPath) return null; + + const content = await Bun.file(join(ctx.cwd, entryPath)).text(); + + if (content.includes("ClerkProvider")) { + return { + path: entryPath, + type: "modify", + content, + description: "Add ClerkProvider to entry", + skipReason: "Already has ClerkProvider", + }; + } + + let newContent = safeAddImport(content, "@clerk/react", "ClerkProvider"); + + if (newContent.includes("")) { + newContent = newContent.replace( + /()(\s*)/, + '$1$2\n', + ); + newContent = newContent.replace(/(\s*)(<\/StrictMode>)/, "\n$1$2"); + } else if (newContent.includes(")/, + '\n $1\n ', + ); + } + + return { + path: entryPath, + type: "modify", + content: newContent, + description: "Add ClerkProvider import and wrap app root", + }; +} + +export const reactVite: FrameworkScaffold = { + name: "React (Vite)", + + async scaffold(ctx: ProjectContext): Promise { + const actions: FileAction[] = []; + const postInstructions: string[] = []; + + const entryAction = await scaffoldEntry(ctx); + if (entryAction) { + actions.push(entryAction); + } else { + postInstructions.push( + `Wrap your app root with from @clerk/react in your entry file (e.g., main.tsx). See: https://clerk.com/docs/quickstarts/react`, + ); + } + + postInstructions.push( + `Ensure ${ctx.framework.envVar} is set in your ${ctx.envFile} (pulled via \`clerk env pull\`)`, + ); + + return { actions, postInstructions }; + }, +}; diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts new file mode 100644 index 000000000..e2509f5f2 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -0,0 +1,152 @@ +import { join } from "node:path"; +import { hasClerkImport, safeAddImport, findFirstFile, scaffoldAuthPage } from "./helpers.js"; +import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +function signInRouteContent(): string { + return `import { SignIn } from "@clerk/tanstack-react-start"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/sign-in/$")({ + component: Page, +}); + +function Page() { + return ; +} +`; +} + +function signUpRouteContent(): string { + return `import { SignUp } from "@clerk/tanstack-react-start"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/sign-up/$")({ + component: Page, +}); + +function Page() { + return ; +} +`; +} + +async function scaffoldStartServer(ctx: ProjectContext): Promise { + const serverPath = await findFirstFile(ctx.cwd, [ + "src/start.ts", + "src/start.tsx", + "app/start.ts", + ]); + if (!serverPath) return null; + + const content = await Bun.file(join(ctx.cwd, serverPath)).text(); + + if (hasClerkImport(content)) { + return { + path: serverPath, + type: "modify", + content, + description: "Add clerkMiddleware to start server", + skipReason: "Already has Clerk middleware", + }; + } + + let newContent = safeAddImport(content, "@clerk/tanstack-react-start/server", "clerkMiddleware"); + + // Insert requestMiddleware into createStart config + if (newContent.includes("createStart")) { + newContent = newContent.replace( + /(createStart\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?return\s*\{)/, + "$1\n requestMiddleware: [clerkMiddleware()],", + ); + } + + return { + path: serverPath, + type: "modify", + content: newContent, + description: "Add clerkMiddleware to request middleware", + }; +} + +async function scaffoldRoot(ctx: ProjectContext): Promise { + const rootPath = await findFirstFile(ctx.cwd, [ + "src/routes/__root.tsx", + "src/routes/__root.jsx", + "app/routes/__root.tsx", + ]); + if (!rootPath) return null; + + const content = await Bun.file(join(ctx.cwd, rootPath)).text(); + + if (content.includes("ClerkProvider")) { + return { + path: rootPath, + type: "modify", + content, + description: "Add ClerkProvider to root route", + skipReason: "Already has ClerkProvider", + }; + } + + let newContent = safeAddImport(content, "@clerk/tanstack-react-start", "ClerkProvider"); + + // Wrap children or body content with + if (newContent.includes("]*>)(\s*)/, "$1$2\n"); + newContent = newContent.replace(/(\s*)(<\/body>)/, "\n$1$2"); + } + + return { + path: rootPath, + type: "modify", + content: newContent, + description: "Add ClerkProvider import and wrap body contents", + }; +} + +export const tanstackStart: FrameworkScaffold = { + name: "TanStack Start", + + async scaffold(ctx: ProjectContext): Promise { + const actions: FileAction[] = []; + const postInstructions: string[] = []; + + const serverAction = await scaffoldStartServer(ctx); + if (serverAction) { + actions.push(serverAction); + } else { + postInstructions.push( + "Add clerkMiddleware() to your start server's requestMiddleware. See: https://clerk.com/docs/quickstarts/tanstack-start", + ); + } + + const rootAction = await scaffoldRoot(ctx); + if (rootAction) { + actions.push(rootAction); + } else { + postInstructions.push( + "Wrap your root route with from @clerk/tanstack-react-start", + ); + } + + const ext = ctx.typescript ? "tsx" : "jsx"; + actions.push( + await scaffoldAuthPage( + ctx.cwd, + `src/routes/sign-in.$.${ext}`, + signInRouteContent(), + "sign-in route", + ), + ); + actions.push( + await scaffoldAuthPage( + ctx.cwd, + `src/routes/sign-up.$.${ext}`, + signUpRouteContent(), + "sign-up route", + ), + ); + + return { actions, postInstructions }; + }, +}; diff --git a/packages/cli-core/src/commands/init/frameworks/types.ts b/packages/cli-core/src/commands/init/frameworks/types.ts new file mode 100644 index 000000000..61b49f297 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/types.ts @@ -0,0 +1,36 @@ +import type { FrameworkInfo } from "../../../lib/framework.js"; + +export interface ProjectContext { + cwd: string; + framework: FrameworkInfo; + variant: "app-router" | "pages-router" | null; + typescript: boolean; + srcDir: boolean; + packageManager: "bun" | "yarn" | "pnpm" | "npm"; + existingClerk: boolean; + deps: Record; + layoutPath: string | null; + envFile: string; + /** Next.js middleware basename: "proxy" for Next.js 16+, "middleware" for ≤15 */ + middlewareBasename: "proxy" | "middleware"; +} + +export interface FileAction { + /** Relative path from cwd */ + path: string; + type: "create" | "modify"; + content: string; + description: string; + /** If set, this action is skipped and the reason is shown in the preview */ + skipReason?: string; +} + +export interface ScaffoldPlan { + actions: FileAction[]; + postInstructions: string[]; +} + +export interface FrameworkScaffold { + name: string; + scaffold(ctx: ProjectContext): Promise; +} diff --git a/packages/cli-core/src/commands/init/frameworks/vue.ts b/packages/cli-core/src/commands/init/frameworks/vue.ts new file mode 100644 index 000000000..b2e6c9d7f --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/vue.ts @@ -0,0 +1,80 @@ +import { join } from "node:path"; +import { findFirstFile, safeAddImport } from "./helpers.js"; +import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +async function findEntryFile(ctx: ProjectContext): Promise { + const base = ctx.srcDir ? "src/" : ""; + return findFirstFile(ctx.cwd, [`${base}main.ts`, `${base}main.js`]); +} + +function addClerkPluginSetup(source: string): string { + const keyBlock = `\nconst PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;\n\nif (!PUBLISHABLE_KEY) {\n throw new Error("Add your Clerk Publishable Key to the .env file");\n}\n`; + + // Insert app.use(clerkPlugin, ...) before app.mount() + let result = source.replace( + /((\w+)\.mount\s*\()/, + `$2.use(clerkPlugin, { publishableKey: PUBLISHABLE_KEY });\n$1`, + ); + + // Insert key block after last import + const lastImportIdx = result.lastIndexOf("import "); + const lineEnd = result.indexOf("\n", lastImportIdx); + if (lineEnd === -1) return result; + + return result.slice(0, lineEnd + 1) + keyBlock + result.slice(lineEnd + 1); +} + +async function scaffoldEntry(ctx: ProjectContext): Promise { + const entryPath = await findEntryFile(ctx); + if (!entryPath) return null; + + const content = await Bun.file(join(ctx.cwd, entryPath)).text(); + + if (content.includes("clerkPlugin") || content.includes("@clerk/vue")) { + return { + path: entryPath, + type: "modify", + content, + description: "Add clerkPlugin to Vue app", + skipReason: "Already has Clerk plugin", + }; + } + + let newContent = safeAddImport(content, "@clerk/vue", "clerkPlugin"); + + // Add the publishable key constant and app.use() call before app.mount() + if (newContent.includes(".mount(")) { + newContent = addClerkPluginSetup(newContent); + } + + return { + path: entryPath, + type: "modify", + content: newContent, + description: "Add clerkPlugin with publishableKey to Vue app", + }; +} + +export const vue: FrameworkScaffold = { + name: "Vue", + + async scaffold(ctx: ProjectContext): Promise { + const actions: FileAction[] = []; + const postInstructions: string[] = []; + + const entryAction = await scaffoldEntry(ctx); + if (entryAction) { + actions.push(entryAction); + } else { + postInstructions.push( + "Add `import { clerkPlugin } from '@clerk/vue'` and `app.use(clerkPlugin, { publishableKey: PUBLISHABLE_KEY })` to your main.ts. See: https://clerk.com/docs/quickstarts/vue", + ); + } + + postInstructions.push( + "Use , , , from @clerk/vue in your components", + ); + + return { actions, postInstructions }; + }, +}; diff --git a/packages/cli-core/src/commands/init/scaffold.ts b/packages/cli-core/src/commands/init/scaffold.ts new file mode 100644 index 000000000..88b43cbf6 --- /dev/null +++ b/packages/cli-core/src/commands/init/scaffold.ts @@ -0,0 +1,43 @@ +import { nextjsApp } from "./frameworks/nextjs-app.js"; +import { nextjsPages } from "./frameworks/nextjs-pages.js"; +import { reactVite } from "./frameworks/react.js"; +import { reactRouter } from "./frameworks/react-router.js"; +import { nuxt } from "./frameworks/nuxt.js"; +import { tanstackStart } from "./frameworks/tanstack-start.js"; +import { astro } from "./frameworks/astro.js"; +import { vue } from "./frameworks/vue.js"; +import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./frameworks/types.js"; + +const SCAFFOLDS: Record = { + "next:app-router": nextjsApp, + "next:pages-router": nextjsPages, + react: reactVite, + "react-router": reactRouter, + nuxt: nuxt, + "@tanstack/react-start": tanstackStart, + astro: astro, + vue: vue, +}; + +export function getScaffoldKey(ctx: ProjectContext): string { + if (ctx.framework.dep === "next") { + return `next:${ctx.variant ?? "app-router"}`; + } + return ctx.framework.dep; +} + +export async function scaffold(ctx: ProjectContext): Promise { + const key = getScaffoldKey(ctx); + const scaffolder = SCAFFOLDS[key]; + + if (!scaffolder) { + return { + actions: [], + postInstructions: [ + `Scaffolding is not yet supported for ${ctx.framework.name}. Visit https://clerk.com/docs/quickstarts for setup instructions.`, + ], + }; + } + + return scaffolder.scaffold(ctx); +} From 1eedb835e119b2c6794f526b78dc305d74728a5c Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 17 Mar 2026 17:31:20 -0300 Subject: [PATCH 05/35] feat(init): add scan, format, preview, and agent prompt modules - scan: detect competing auth libraries pre-scaffold and scan for hardcoded keys/leftover imports post-scaffold - format: run Prettier/Biome on generated files - preview: show planned file changes and confirm before writing - prompts: framework-specific agent mode prompts with exact code snippets and file paths --- packages/cli-core/src/commands/init/format.ts | 31 +++ .../cli-core/src/commands/init/preview.ts | 27 ++ .../cli-core/src/commands/init/prompts.ts | 160 ++++++++++++ .../cli-core/src/commands/init/scan.test.ts | 247 ++++++++++++++++++ packages/cli-core/src/commands/init/scan.ts | 199 ++++++++++++++ 5 files changed, 664 insertions(+) create mode 100644 packages/cli-core/src/commands/init/format.ts create mode 100644 packages/cli-core/src/commands/init/preview.ts create mode 100644 packages/cli-core/src/commands/init/prompts.ts create mode 100644 packages/cli-core/src/commands/init/scan.test.ts create mode 100644 packages/cli-core/src/commands/init/scan.ts diff --git a/packages/cli-core/src/commands/init/format.ts b/packages/cli-core/src/commands/init/format.ts new file mode 100644 index 000000000..762385db1 --- /dev/null +++ b/packages/cli-core/src/commands/init/format.ts @@ -0,0 +1,31 @@ +import { readDeps } from "./context.js"; + +export async function runFormatters(cwd: string, files: string[]): Promise { + if (files.length === 0) return; + + const deps = await readDeps(cwd); + if (!deps) return; + + const hasPrettier = "prettier" in deps; + const hasBiome = "@biomejs/biome" in deps; + + if (!hasPrettier && !hasBiome) return; + + if (hasPrettier) { + const proc = Bun.spawn(["npx", "prettier", "--ignore-unknown", "--write", ...files], { + cwd, + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; + } + + if (hasBiome) { + const proc = Bun.spawn(["npx", "@biomejs/biome", "format", "--write", ...files], { + cwd, + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; + } +} diff --git a/packages/cli-core/src/commands/init/preview.ts b/packages/cli-core/src/commands/init/preview.ts new file mode 100644 index 000000000..2908032c5 --- /dev/null +++ b/packages/cli-core/src/commands/init/preview.ts @@ -0,0 +1,27 @@ +import { confirm } from "@inquirer/prompts"; +import { cyan, dim, green, yellow } from "../../lib/color.js"; +import type { ScaffoldPlan } from "./frameworks/types.js"; + +export async function previewAndConfirm(plan: ScaffoldPlan): Promise { + console.log("\nclerk init will make the following changes:\n"); + + for (const action of plan.actions) { + if (action.skipReason) { + console.log(` ${dim("SKIP")} ${dim(action.path)} — ${dim(action.skipReason)}`); + } else if (action.type === "create") { + console.log(` ${green("CREATE")} ${cyan(action.path)}`); + } else { + console.log(` ${yellow("MODIFY")} ${cyan(action.path)} — ${action.description}`); + } + } + + if (plan.postInstructions.length > 0) { + console.log(dim("\n After scaffolding, you'll need to:")); + for (const instr of plan.postInstructions) { + console.log(dim(` • ${instr}`)); + } + } + + console.log(); + return confirm({ message: "Proceed?" }); +} diff --git a/packages/cli-core/src/commands/init/prompts.ts b/packages/cli-core/src/commands/init/prompts.ts new file mode 100644 index 000000000..401fc64ad --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts.ts @@ -0,0 +1,160 @@ +import type { ProjectContext } from "./frameworks/types.js"; + +type PromptBuilder = (ctx: ProjectContext, base: string, ext: string, jsx: string) => string; + +function nextjsAppRouterPrompt( + ctx: ProjectContext, + base: string, + ext: string, + jsx: string, +): string { + return `Integrate Clerk auth into this Next.js (App Router) project. + +1. Install: ${ctx.framework.sdk} +2. Create ${base}${ctx.middlewareBasename}.${ext}: + \`\`\`typescript + import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; + const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]); + export default clerkMiddleware(async (auth, request) => { + if (!isPublicRoute(request)) { await auth.protect(); } + }); + export const config = { matcher: ["/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)"] }; + \`\`\` +3. Modify ${ctx.layoutPath ?? `${base}app/layout.${jsx}`}: Add \`import { ClerkProvider } from "@clerk/nextjs"\` and wrap the body children with \`...\`. +4. Create ${base}app/sign-in/[[...sign-in]]/page.${jsx}: \`import { SignIn } from "@clerk/nextjs"; export default function SignInPage() { return ; }\` +5. Create ${base}app/sign-up/[[...sign-up]]/page.${jsx}: \`import { SignUp } from "@clerk/nextjs"; export default function SignUpPage() { return ; }\` +6. Add to .env.local: NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +7. Env vars (${ctx.framework.envVar} and CLERK_SECRET_KEY) are in .env.local via \`clerk env pull\`. + +Docs: https://clerk.com/docs/quickstarts/nextjs`; +} + +function nextjsPagesRouterPrompt( + ctx: ProjectContext, + base: string, + ext: string, + jsx: string, +): string { + return `Integrate Clerk auth into this Next.js (Pages Router) project. + +1. Install: ${ctx.framework.sdk} +2. Create ${base}${ctx.middlewareBasename}.${ext}: Same as App Router (clerkMiddleware with createRouteMatcher). +3. Create or modify ${base}pages/_app.${jsx}: Wrap \`\` with \`...\`. +4. Create ${base}pages/sign-in/[[...sign-in]].${jsx}: \`import { SignIn } from "@clerk/nextjs"; export default function SignInPage() { return ; }\` +5. Create ${base}pages/sign-up/[[...sign-up]].${jsx}: \`import { SignUp } from "@clerk/nextjs"; export default function SignUpPage() { return ; }\` +6. Add to .env.local: NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up + +Docs: https://clerk.com/docs/quickstarts/nextjs`; +} + +const AGENT_PROMPTS: Record = { + react: (ctx, base, _ext, jsx) => `Integrate Clerk auth into this React project. + +1. Install: ${ctx.framework.sdk} +2. Modify ${base}main.${jsx} (or entry file): Add \`import { ClerkProvider } from "@clerk/react"\` and wrap the app root with \`......\`. +3. Use \`\`, \`\`, \`\` from @clerk/react in your components. +4. Use \`\` and \`\` from @clerk/react for conditional rendering. +5. Env var (${ctx.framework.envVar}) is in ${ctx.envFile} via \`clerk env pull\`. + +Docs: https://clerk.com/docs/quickstarts/react`, + + "react-router": (ctx, _base, _ext, jsx) => `Integrate Clerk auth into this React Router project. + +1. Install: ${ctx.framework.sdk} +2. Enable middleware in react-router.config.ts: Add \`future: { v8_middleware: true }\` to the config. +3. Modify app/root.tsx: + - Add \`import { clerkMiddleware, rootAuthLoader } from "@clerk/react-router/server"\` + - Add \`import { ClerkProvider } from "@clerk/react-router"\` + - Export \`const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]\` + - Export \`const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)\` + - Wrap content with \`...\` +4. Create app/routes/sign-in.${jsx}: \`import { SignIn } from "@clerk/react-router"; export default function SignInPage() { return ; }\` +5. Create app/routes/sign-up.${jsx}: \`import { SignUp } from "@clerk/react-router"; export default function SignUpPage() { return ; }\` +6. Add routes to app/routes.ts: \`route('sign-in/*', 'routes/sign-in.tsx')\` and \`route('sign-up/*', 'routes/sign-up.tsx')\` +7. Env vars (${ctx.framework.envVar} and CLERK_SECRET_KEY) are in ${ctx.envFile} via \`clerk env pull\`. + +Docs: https://clerk.com/docs/quickstarts/react-router`, + + nuxt: (ctx) => `Integrate Clerk auth into this Nuxt project. + +1. Install: ${ctx.framework.sdk} +2. Modify nuxt.config.ts: Add \`'@clerk/nuxt'\` to the \`modules\` array. Middleware is auto-configured. +3. Create pages/sign-in.vue: \`\` (components are auto-imported). +4. Create pages/sign-up.vue: \`\`. +5. Use \`\` and \`\` in your templates for conditional rendering. +6. Env vars (${ctx.framework.envVar} and NUXT_CLERK_SECRET_KEY) are in ${ctx.envFile} via \`clerk env pull\`. + +Docs: https://clerk.com/docs/quickstarts/nuxt`, + + "@tanstack/react-start": (ctx, _base, _ext, jsx) => + `Integrate Clerk auth into this TanStack Start project. + +1. Install: ${ctx.framework.sdk} +2. Modify src/start.ts: Add \`import { clerkMiddleware } from "@clerk/tanstack-react-start/server"\` and add \`requestMiddleware: [clerkMiddleware()]\` to createStart config. +3. Modify src/routes/__root.tsx: Add \`import { ClerkProvider } from "@clerk/tanstack-react-start"\` and wrap body contents with \`\`. +4. Create src/routes/sign-in.$.${jsx}: Use \`createFileRoute("/sign-in/$")\` with \`\` from @clerk/tanstack-react-start. +5. Create src/routes/sign-up.$.${jsx}: Use \`createFileRoute("/sign-up/$")\` with \`\`. +6. Env vars (${ctx.framework.envVar} and CLERK_SECRET_KEY) are in ${ctx.envFile} via \`clerk env pull\`. + +Docs: https://clerk.com/docs/quickstarts/tanstack-start`, + + astro: (ctx) => `Integrate Clerk auth into this Astro project. + +1. Install: ${ctx.framework.sdk} +2. Modify astro.config.mjs: Add \`import clerk from "@clerk/astro"\` and \`clerk()\` to integrations. Ensure \`output: "server"\` and an SSR adapter. +3. Create src/middleware.ts: \`import { clerkMiddleware } from "@clerk/astro/server"; export const onRequest = clerkMiddleware();\` +4. Create src/pages/sign-in.astro with \`\` from @clerk/astro/components. +5. Create src/pages/sign-up.astro with \`\` from @clerk/astro/components. +6. Use \`\` and \`\` from @clerk/astro/components in layouts. +7. Env vars (${ctx.framework.envVar} and CLERK_SECRET_KEY) are in ${ctx.envFile} via \`clerk env pull\`. + +Docs: https://clerk.com/docs/quickstarts/astro`, + + vue: (ctx, base) => `Integrate Clerk auth into this Vue project. + +1. Install: ${ctx.framework.sdk} +2. Modify ${base}main.ts: Add \`import { clerkPlugin } from "@clerk/vue"\` and \`app.use(clerkPlugin, { publishableKey: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY })\`. +3. Use \`\`, \`\`, \`\`, \`\` from @clerk/vue in components. +4. Env var (${ctx.framework.envVar}) is in ${ctx.envFile} via \`clerk env pull\`. + +Docs: https://clerk.com/docs/quickstarts/vue`, +}; + +export const GENERIC_AGENT_PROMPT = `You are integrating Clerk authentication into an existing project. Follow these steps: + +1. Authenticate with Clerk: Run \`clerk auth login\` to log in via the browser. +2. Link a Clerk application: Run \`clerk link\` to associate this directory with a Clerk application. +3. Install the Clerk SDK appropriate for the project's framework (see https://clerk.com/docs/quickstarts). +4. Pull environment variables with \`clerk env pull\`. +5. Set up the Clerk provider at the root of the application. +6. Add sign-in and sign-up routes/components. +7. Protect routes that require authentication. + +Refer to the Clerk docs at https://clerk.com/docs for framework-specific details.`; + +export function buildAgentPrompt(ctx: ProjectContext): string { + const base = ctx.srcDir ? "src/" : ""; + const ext = ctx.typescript ? "ts" : "js"; + const jsx = ctx.typescript ? "tsx" : "jsx"; + + if (ctx.framework.dep === "next") { + if (ctx.variant === "pages-router") { + return nextjsPagesRouterPrompt(ctx, base, ext, jsx); + } + return nextjsAppRouterPrompt(ctx, base, ext, jsx); + } + + const builder = AGENT_PROMPTS[ctx.framework.dep]; + if (builder) { + return builder(ctx, base, ext, jsx); + } + + return `Integrate Clerk auth into this ${ctx.framework.name} project. + +1. Install: ${ctx.framework.sdk} +2. Set up the Clerk provider/middleware for ${ctx.framework.name}. +3. Create sign-in and sign-up routes/components. +4. Env vars (${ctx.framework.envVar} and CLERK_SECRET_KEY) are in ${ctx.envFile} via \`clerk env pull\`. + +Docs: https://clerk.com/docs`; +} diff --git a/packages/cli-core/src/commands/init/scan.test.ts b/packages/cli-core/src/commands/init/scan.test.ts new file mode 100644 index 000000000..0c75a6a16 --- /dev/null +++ b/packages/cli-core/src/commands/init/scan.test.ts @@ -0,0 +1,247 @@ +import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { detectAuthLibraries, scanForIssues } from "./scan.ts"; + +// --------------------------------------------------------------------------- +// detectAuthLibraries +// --------------------------------------------------------------------------- + +describe("detectAuthLibraries", () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + test("detects NextAuth", () => { + detectAuthLibraries({ "next-auth": "5.0.0", next: "15.0.0" }); + const output = consoleSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + expect(output).toContain("NextAuth"); + expect(output).toContain("clerk.com/docs/migrations/nextauth"); + }); + + test("detects Auth0 via @auth0/nextjs-auth0", () => { + detectAuthLibraries({ "@auth0/nextjs-auth0": "3.0.0" }); + const output = consoleSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + expect(output).toContain("Auth0"); + }); + + test("detects Auth0 via auth0 package", () => { + detectAuthLibraries({ auth0: "4.0.0" }); + const output = consoleSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + expect(output).toContain("Auth0"); + }); + + test("detects Supabase Auth via @supabase/ssr", () => { + detectAuthLibraries({ "@supabase/ssr": "0.5.0" }); + const output = consoleSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + expect(output).toContain("Supabase Auth"); + }); + + test("detects Firebase", () => { + detectAuthLibraries({ firebase: "11.0.0" }); + const output = consoleSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + expect(output).toContain("Firebase"); + }); + + test("detects Passport.js", () => { + detectAuthLibraries({ passport: "0.7.0" }); + const output = consoleSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + expect(output).toContain("Passport.js"); + }); + + test("detects Better Auth", () => { + detectAuthLibraries({ "better-auth": "1.0.0" }); + const output = consoleSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + expect(output).toContain("Better Auth"); + }); + + test("detects Kinde", () => { + detectAuthLibraries({ "@kinde-oss/kinde-auth-nextjs": "2.0.0" }); + const output = consoleSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + expect(output).toContain("Kinde"); + }); + + test("detects multiple auth libraries", () => { + detectAuthLibraries({ "next-auth": "5.0.0", firebase: "11.0.0" }); + const output = consoleSpy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); + expect(output).toContain("NextAuth"); + expect(output).toContain("Firebase"); + }); + + test("does not warn when no auth library found", () => { + detectAuthLibraries({ react: "19.0.0", next: "15.0.0" }); + expect(consoleSpy).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// scanForIssues +// --------------------------------------------------------------------------- + +describe("scanForIssues", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-scan-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("detects hardcoded publishable key", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/config.ts"), + 'const key = "pk_test_abc123";\nCLERK_PUBLISHABLE_KEY = pk_live_xyz;', + ); + + const findings = await scanForIssues(tempDir, "next"); + expect(findings.length).toBeGreaterThan(0); + expect(findings[0]!.message).toContain("publishable key"); + }); + + test("detects hardcoded secret key", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write(join(tempDir, "src/env.ts"), "CLERK_SECRET_KEY = sk_test_abc123;"); + + const findings = await scanForIssues(tempDir, "next"); + expect(findings.length).toBeGreaterThan(0); + expect(findings[0]!.message).toContain("secret key"); + }); + + test("detects NextAuth import for next framework", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/auth.ts"), + 'import { getServerSession } from "next-auth";\n', + ); + + const findings = await scanForIssues(tempDir, "next"); + expect(findings.some((f) => f.message.includes("NextAuth import"))).toBe(true); + }); + + test("skips NextAuth scan for non-next frameworks", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/auth.ts"), + 'import { getServerSession } from "next-auth";\n', + ); + + const findings = await scanForIssues(tempDir, "react"); + expect(findings.some((f) => f.message.includes("NextAuth import"))).toBe(false); + }); + + test("detects getServerSession call", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/api.ts"), + "const session = await getServerSession(authOptions);\n", + ); + + const findings = await scanForIssues(tempDir, "next"); + expect(findings.some((f) => f.message.includes("getServerSession"))).toBe(true); + }); + + test("detects Firebase Auth import", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/auth.ts"), + 'import { signInWithPopup } from "firebase/auth";\n', + ); + + const findings = await scanForIssues(tempDir, "react"); + expect(findings.some((f) => f.message.includes("Firebase Auth"))).toBe(true); + }); + + test("detects Better Auth import", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write(join(tempDir, "src/auth.ts"), 'import { auth } from "better-auth";\n'); + + const findings = await scanForIssues(tempDir, "react"); + expect(findings.some((f) => f.message.includes("Better Auth"))).toBe(true); + }); + + test("detects Passport import", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write(join(tempDir, "src/auth.ts"), 'import passport from "passport";\n'); + + const findings = await scanForIssues(tempDir, "next"); + expect(findings.some((f) => f.message.includes("Passport"))).toBe(true); + }); + + test("returns correct line number", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/auth.ts"), + 'const a = 1;\nconst b = 2;\nimport { auth } from "better-auth";\n', + ); + + const findings = await scanForIssues(tempDir, "react"); + const finding = findings.find((f) => f.message.includes("Better Auth")); + expect(finding).toBeDefined(); + expect(finding!.line).toBe(3); + }); + + test("returns empty array when no issues found", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/app.ts"), + 'import { ClerkProvider } from "@clerk/nextjs";\n', + ); + + const findings = await scanForIssues(tempDir, "next"); + expect(findings).toEqual([]); + }); + + test("caps findings at 10", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + // Create 12 files with hardcoded keys + for (let i = 0; i < 12; i++) { + await Bun.write(join(tempDir, `src/file${i}.ts`), `CLERK_SECRET_KEY = sk_test_${i};`); + } + + const findings = await scanForIssues(tempDir, "next"); + expect(findings.length).toBeLessThanOrEqual(10); + }); + + test("ignores node_modules", async () => { + await mkdir(join(tempDir, "node_modules/some-pkg"), { recursive: true }); + await Bun.write( + join(tempDir, "node_modules/some-pkg/index.js"), + 'import { auth } from "better-auth";\n', + ); + + const findings = await scanForIssues(tempDir, "react"); + expect(findings).toEqual([]); + }); + + test("ignores nested node_modules in monorepo", async () => { + await mkdir(join(tempDir, "packages/app/node_modules/dep"), { recursive: true }); + await Bun.write( + join(tempDir, "packages/app/node_modules/dep/index.js"), + 'import { auth } from "better-auth";\n', + ); + + const findings = await scanForIssues(tempDir, "react"); + expect(findings).toEqual([]); + }); + + test("ignores .next directory", async () => { + await mkdir(join(tempDir, ".next/server"), { recursive: true }); + await Bun.write( + join(tempDir, ".next/server/chunks.js"), + 'import { auth } from "better-auth";\n', + ); + + const findings = await scanForIssues(tempDir, "react"); + expect(findings).toEqual([]); + }); +}); diff --git a/packages/cli-core/src/commands/init/scan.ts b/packages/cli-core/src/commands/init/scan.ts new file mode 100644 index 000000000..3ea8fcd5f --- /dev/null +++ b/packages/cli-core/src/commands/init/scan.ts @@ -0,0 +1,199 @@ +import { join } from "node:path"; +import { yellow, dim, cyan } from "../../lib/color.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface AuthLibraryScan { + packages: string[]; + name: string; + docsUrl: string; +} + +export interface CodeScan { + pattern: string; + flags?: string; + message: string; + docsUrl?: string; + frameworks?: string[]; +} + +export interface ScanFinding { + file: string; + line: number; + message: string; + docsUrl?: string; +} + +// --------------------------------------------------------------------------- +// Pre-scaffold: auth library detection +// --------------------------------------------------------------------------- + +const AUTH_LIBRARY_SCANS: AuthLibraryScan[] = [ + { + packages: ["next-auth"], + name: "NextAuth / Auth.js", + docsUrl: "https://clerk.com/docs/migrations/nextauth", + }, + { + packages: ["@auth0/nextjs-auth0", "auth0"], + name: "Auth0", + docsUrl: "https://clerk.com/docs/migrations/auth0", + }, + { + packages: ["@supabase/ssr", "@supabase/auth-helpers-nextjs"], + name: "Supabase Auth", + docsUrl: "https://clerk.com/docs/migrations/supabase", + }, + { + packages: ["firebase"], + name: "Firebase", + docsUrl: "https://clerk.com/docs/migrations/firebase", + }, + { + packages: ["passport"], + name: "Passport.js", + docsUrl: "https://clerk.com/docs/migrations/overview", + }, + { + packages: ["better-auth"], + name: "Better Auth", + docsUrl: "https://clerk.com/docs/migrations/overview", + }, + { + packages: ["@kinde-oss/kinde-auth-nextjs"], + name: "Kinde", + docsUrl: "https://clerk.com/docs/migrations/overview", + }, +]; + +export function detectAuthLibraries(deps: Record): void { + for (const scan of AUTH_LIBRARY_SCANS) { + const found = scan.packages.some((pkg) => pkg in deps); + if (!found) continue; + + console.log(yellow(`\n⚠ Detected ${scan.name} in your project.`)); + console.log(dim(` Migration guide: ${scan.docsUrl}`)); + } +} + +// --------------------------------------------------------------------------- +// Post-scaffold: code scans +// --------------------------------------------------------------------------- + +const CODE_SCANS: CodeScan[] = [ + { + pattern: "(?:NEXT_PUBLIC_)?CLERK_PUBLISHABLE_KEY\\s*=\\s*pk_", + message: "Hardcoded publishable key", + docsUrl: "https://clerk.com/docs/deployments/clerk-environment-variables", + }, + { + pattern: "CLERK_SECRET_KEY\\s*=\\s*sk_", + message: "Hardcoded secret key", + docsUrl: "https://clerk.com/docs/deployments/clerk-environment-variables", + }, + { + pattern: "import\\s.*from\\s+['\"]next-auth", + message: "NextAuth import still in use", + frameworks: ["next"], + }, + { + pattern: "import\\s.*from\\s+['\"]@auth0/", + message: "Auth0 import still in use", + }, + { + pattern: "import\\s.*from\\s+['\"]@supabase/(ssr|auth-helpers)", + message: "Supabase Auth import still in use", + }, + { + pattern: "import\\s.*from\\s+['\"]firebase/auth", + message: "Firebase Auth import still in use", + }, + { + pattern: "import\\s.*from\\s+['\"]better-auth", + message: "Better Auth import still in use", + }, + { + pattern: "import\\s.*from\\s+['\"]@kinde-oss/", + message: "Kinde import still in use", + }, + { + pattern: "import\\s.*from\\s+['\"]passport['\"]", + message: "Passport import still in use", + }, + { + pattern: "getServerSession\\s*\\(", + message: "NextAuth getServerSession() call still in use", + frameworks: ["next"], + }, +]; + +const IGNORE_DIRS = ["node_modules", ".next", "dist", ".git", "build", ".output", ".nuxt"]; + +const MAX_FINDINGS = 10; + +function findLineNumber(content: string, matchIndex: number): number { + return content.slice(0, matchIndex).split("\n").length; +} + +function matchesFramework(scan: CodeScan, frameworkDep: string): boolean { + if (!scan.frameworks) return true; + return scan.frameworks.includes(frameworkDep); +} + +function isIgnored(relPath: string): boolean { + return relPath.split("/").some((seg) => IGNORE_DIRS.includes(seg)); +} + +function scanFileContent(content: string, relPath: string, frameworkDep: string): ScanFinding[] { + const results: ScanFinding[] = []; + + for (const scan of CODE_SCANS) { + if (!matchesFramework(scan, frameworkDep)) continue; + + const regex = new RegExp(scan.pattern, scan.flags ?? "m"); + const match = regex.exec(content); + if (!match) continue; + + results.push({ + file: relPath, + line: findLineNumber(content, match.index), + message: scan.message, + docsUrl: scan.docsUrl, + }); + } + + return results; +} + +export async function scanForIssues(cwd: string, frameworkDep: string): Promise { + const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx}"); + const findings: ScanFinding[] = []; + + for await (const relPath of glob.scan({ cwd })) { + if (isIgnored(relPath)) continue; + if (findings.length >= MAX_FINDINGS) break; + + const content = await Bun.file(join(cwd, relPath)).text(); + const fileFindings = scanFileContent(content, relPath, frameworkDep); + + for (const finding of fileFindings) { + if (findings.length >= MAX_FINDINGS) break; + findings.push(finding); + } + } + + return findings; +} + +export function printFindings(findings: ScanFinding[]): void { + if (findings.length === 0) return; + + console.log(dim("\n Recommendations:")); + for (const f of findings) { + const location = `${cyan(f.file)}:${f.line}`; + console.log(` ${yellow("⚠")} ${location} ${dim("—")} ${f.message}`); + if (f.docsUrl) console.log(` ${dim(f.docsUrl)}`); + } +} From 9becf919828d0218e90ed0ad62888f5af389b906 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 17 Mar 2026 17:31:28 -0300 Subject: [PATCH 06/35] feat(init): rewrite init command with scaffold and scan flow Replace the minimal init (login + link + install + env pull) with a full orchestrator that also scaffolds framework boilerplate and scans for issues. Skips login/link when already authenticated and linked. --- packages/cli-core/src/commands/init/index.ts | 285 +++++++++++++------ 1 file changed, 202 insertions(+), 83 deletions(-) diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 5e8cb7ef1..713eb46b1 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -1,110 +1,229 @@ -import { join } from "node:path"; -import { login } from "../auth/login.ts"; -import { link } from "../link/index.ts"; -import { pull } from "../env/pull.ts"; -import { detectFramework } from "../../lib/framework.ts"; -import { isAgent } from "../../mode.ts"; -import { dim, cyan } from "../../lib/color.ts"; - -const AGENT_PROMPT = `You are integrating Clerk authentication into an existing project. Follow these steps: - -1. Authenticate with Clerk: Run \`clerk auth login\` to log in via the browser. - -2. Link a Clerk application: Run \`clerk link\` to associate this directory with a Clerk application. - -3. Install the Clerk SDK appropriate for the project's framework: - - Next.js: \`@clerk/nextjs\` - - React: \`@clerk/clerk-react\` - - Express: \`@clerk/express\` - - Fastify: \`@clerk/fastify\` - - Astro: \`@clerk/astro\` - - Tanstack Start: \`@clerk/tanstack-start\` - - React Router: \`@clerk/react-router\` - - Nuxt: \`@clerk/nuxt\` - - Vue: \`@clerk/vue\` - -4. Add the environment variable NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY (or the equivalent for your framework) and CLERK_SECRET_KEY to the project's .env.local file. You can retrieve these with \`clerk env pull\`. - -5. Set up the Clerk provider at the root of the application: - - For Next.js: Wrap the app with \`\` in the root layout. - - For React: Wrap the app with \`\`. - - For Express/Fastify: Use the \`clerkMiddleware()\` middleware. - -6. Add sign-in and sign-up routes/components: - - Use \`\` and \`\` for trigger buttons. - - Use \`\` and \`\` for full-page components. - - Use \`\` to show the signed-in user's avatar and menu. - -7. Protect routes that require authentication: - - Next.js: Use \`clerkMiddleware()\` in \`middleware.ts\` and configure with \`createRouteMatcher\`. - - React: Use \`\` and \`\` components to conditionally render. - - Express/Fastify: Use \`requireAuth()\` middleware on protected routes. - -8. Access the current user: - - Client-side: \`useUser()\` hook returns the current user object. - - Server-side (Next.js): \`auth()\` or \`currentUser()\` from \`@clerk/nextjs/server\`. - - Express/Fastify: \`req.auth\` after applying \`clerkMiddleware()\`. - -Refer to the Clerk docs at https://clerk.com/docs for framework-specific details.`; - -async function detectPackageManager(cwd: string): Promise<{ cmd: string; add: string }> { - const checks: Array<{ files: string[]; cmd: string; add: string }> = [ - { files: ["bun.lockb", "bun.lock"], cmd: "bun", add: "bun add" }, - { files: ["yarn.lock"], cmd: "yarn", add: "yarn add" }, - { files: ["pnpm-lock.yaml"], cmd: "pnpm", add: "pnpm add" }, - ]; - - for (const { files, cmd, add } of checks) { - for (const file of files) { - if (await Bun.file(join(cwd, file)).exists()) { - return { cmd, add }; - } - } - } - - return { cmd: "npm", add: "npm install" }; +import { join, dirname } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { login } from "../auth/login.js"; +import { link } from "../link/index.js"; +import { pull } from "../env/pull.js"; +import { isAgent } from "../../mode.js"; +import { dim, cyan, green, yellow, bold } from "../../lib/color.js"; +import { throwUserAbort } from "../../lib/errors.js"; +import { getToken } from "../../lib/credential-store.js"; +import { resolveProfile } from "../../lib/config.js"; +import { fetchUserInfo } from "../../lib/token-exchange.js"; +import { gatherContext } from "./context.js"; +import { scaffold } from "./scaffold.js"; +import { previewAndConfirm } from "./preview.js"; +import { runFormatters } from "./format.js"; +import { detectAuthLibraries, scanForIssues, printFindings } from "./scan.js"; +import { buildAgentPrompt, GENERIC_AGENT_PROMPT } from "./prompts.js"; +import type { ProjectContext, ScaffoldPlan } from "./frameworks/types.js"; +import type { ScanFinding } from "./scan.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function pmAddCommand(pm: ProjectContext["packageManager"]): string { + const commands: Record = { + bun: "bun add", + yarn: "yarn add", + pnpm: "pnpm add", + npm: "npm install", + }; + return commands[pm]; } -async function installSdk(cwd: string, sdk: string, frameworkName: string): Promise { - const pm = await detectPackageManager(cwd); - console.log(`Installing ${cyan(sdk)} for ${frameworkName}...`); +async function installSdk(ctx: ProjectContext): Promise { + const addCmd = pmAddCommand(ctx.packageManager); + console.log(`Installing ${cyan(ctx.framework.sdk)} for ${ctx.framework.name}...`); - const proc = Bun.spawn(pm.add.split(" ").concat(sdk), { - cwd, + const proc = Bun.spawn(addCmd.split(" ").concat(ctx.framework.sdk), { + cwd: ctx.cwd, stdout: "inherit", stderr: "inherit", }); const exitCode = await proc.exited; if (exitCode !== 0) { - console.error(`Failed to install ${sdk}. You can install it manually: ${pm.add} ${sdk}`); + console.log( + yellow( + `Failed to install ${ctx.framework.sdk}. You can install it manually: ${addCmd} ${ctx.framework.sdk}`, + ), + ); + } +} + +async function writePlan(cwd: string, plan: ScaffoldPlan): Promise { + const written: string[] = []; + + for (const action of plan.actions) { + if (action.skipReason) continue; + + const fullPath = join(cwd, action.path); + + if (action.type === "create") { + await mkdir(dirname(fullPath), { recursive: true }); + } + + await Bun.write(fullPath, action.content); + written.push(action.path); } + + return written; } +async function checkGitDirty(cwd: string): Promise { + try { + const proc = Bun.spawn(["git", "status", "--porcelain"], { + cwd, + stdout: "pipe", + stderr: "ignore", + }); + const output = await new Response(proc.stdout).text(); + await proc.exited; + return output.trim().length > 0; + } catch { + return false; + } +} + +function printOutro(plan: ScaffoldPlan, findings: ScanFinding[]): void { + const created = plan.actions.filter((a) => a.type === "create" && !a.skipReason); + const modified = plan.actions.filter((a) => a.type === "modify" && !a.skipReason); + const skipped = plan.actions.filter((a) => a.skipReason); + + console.log(bold(green("\n✓ Clerk has been set up in your project!\n"))); + + for (const a of created) { + console.log(` ${green("+")} ${a.path}`); + } + for (const a of modified) { + console.log(` ${yellow("~")} ${a.path}`); + } + for (const a of skipped) { + console.log(` ${dim("-")} ${dim(a.path)} ${dim(`(${a.skipReason})`)}`); + } + + if (plan.postInstructions.length > 0) { + console.log(dim("\nNext steps:")); + for (const instr of plan.postInstructions) { + console.log(dim(` • ${instr}`)); + } + } + + printFindings(findings); + + console.log(); +} + +/** + * Try to get the currently authenticated user's email without triggering login. + * Returns null if not authenticated or token is expired. + */ +async function getAuthenticatedEmail(): Promise { + try { + const token = await getToken(); + if (!token) return null; + const userInfo = await fetchUserInfo(token); + return userInfo.email; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + export async function init() { + const cwd = process.cwd(); + const ctx = await gatherContext(cwd); + if (isAgent()) { - console.log(AGENT_PROMPT); + console.log(ctx ? buildAgentPrompt(ctx) : GENERIC_AGENT_PROMPT); return; } - // Step 1: Authenticate the user - await login(); + await authenticateAndLink(cwd); + await detectAndInstall(cwd, ctx); +} - // Step 2: Link to a Clerk application - await link({ skipIfLinked: true }); +async function authenticateAndLink(cwd: string): Promise { + // Check if fully ready (authenticated + linked) + const email = await getAuthenticatedEmail(); + const profile = await resolveProfile(cwd); - const cwd = process.cwd(); + if (email && profile) { + console.log(dim(`Logged in as ${email} · Linked to ${profile.profile.appId}`)); + return; + } - // Step 3: Detect framework and install SDK - const fw = await detectFramework(cwd); - if (fw) { - await installSdk(cwd, fw.sdk, fw.name); - } else { + // Authenticated but not linked — skip login, just link + if (email) { + console.log(dim(`Logged in as ${email}`)); + await link({ skipIfLinked: true }); + return; + } + + // Not authenticated — full flow + await login(); + await link({ skipIfLinked: true }); +} + +async function detectAndInstall(cwd: string, ctx: ProjectContext | null): Promise { + if (!ctx) { console.log( `Could not detect a framework. Install the appropriate Clerk SDK manually: ${dim("https://clerk.com/docs")}`, ); + return; + } + + const variantLabel = ctx.variant ? ` (${ctx.variant})` : ""; + console.log(`\nDetected ${bold(ctx.framework.name)}${variantLabel}`); + + // Pre-scaffold: detect existing auth libraries + detectAuthLibraries(ctx.deps); + + console.log(); + + if (ctx.existingClerk) { + console.log(dim(`${ctx.framework.sdk} is already installed.`)); + } else { + await installSdk(ctx); } - // Step 4: Pull environment variables await pull({}); + await scaffoldAndWrite(cwd, ctx); +} + +async function scaffoldAndWrite(cwd: string, ctx: ProjectContext): Promise { + const plan = await scaffold(ctx); + const hasChanges = plan.actions.some((a) => !a.skipReason); + + if (!hasChanges && plan.postInstructions.length === 0) { + console.log(green("\nClerk is already set up in this project.")); + return; + } + + if (!hasChanges) { + console.log(dim("\nNo files to scaffold, but:")); + for (const instr of plan.postInstructions) { + console.log(dim(` • ${instr}`)); + } + return; + } + + if (await checkGitDirty(cwd)) { + console.log(yellow("Warning: You have uncommitted changes.")); + console.log(dim("Consider committing first so you can review what clerk init creates.\n")); + } + + const proceed = await previewAndConfirm(plan); + if (!proceed) throwUserAbort(); + + const writtenFiles = await writePlan(cwd, plan); + await runFormatters(cwd, writtenFiles); + + // Post-scaffold: scan for issues + const findings = await scanForIssues(cwd, ctx.framework.dep); + printOutro(plan, findings); } From 1b4a8ac841c559477ba9581b284c36f9d80e6b2d Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 17 Mar 2026 17:31:34 -0300 Subject: [PATCH 07/35] docs(init): update README with scaffolding docs and framework table - Add Agent Mode section - Add env var column to framework detection table - Add Expo, Express, Fastify to detection table - Document scaffolding actions for all 8 supported frameworks - Note that Expo/Express/Fastify are detected but not scaffolded --- packages/cli-core/src/commands/init/README.md | 133 +++++++++++++++--- 1 file changed, 111 insertions(+), 22 deletions(-) diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 26595b289..650a784a7 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -1,6 +1,6 @@ # Init Command -Initializes Clerk in a project by authenticating the user, linking a Clerk application, installing the SDK, and writing environment variables. +Initializes Clerk in a project by authenticating the user, linking a Clerk application, installing the SDK, pulling environment variables, and scaffolding framework-specific boilerplate. ## Usage @@ -8,32 +8,121 @@ Initializes Clerk in a project by authenticating the user, linking a Clerk appli clerk init ``` +## Agent Mode + +When running in agent mode (`--mode agent` or non-TTY), outputs a framework-specific prompt with exact file paths and code snippets, then exits without modifying the project. + ## Flow -1. Authenticates the user via `clerk auth login` (see [auth/README.md](../auth/README.md) for APIs) -2. Links the project to a Clerk application via `clerk link` (see [link/README.md](../link/README.md) for APIs) -3. Detects the project's framework from `package.json` and installs the appropriate Clerk SDK (e.g. `@clerk/nextjs` for Next.js) -4. Pulls development instance API keys via `clerk env pull` and writes them to `.env.local` +1. Gathers project context (framework, router variant, TypeScript, `src/` directory, package manager) +2. **Agent mode**: outputs a framework-specific prompt, then exits +3. **Human mode**: authenticates via `clerk auth login` (skipped if already authenticated) +4. Links the project via `clerk link` (skipped if already linked) +5. Displays detected framework and variant +6. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance +7. Installs the appropriate Clerk SDK (skips if already present) +8. Pulls development instance API keys via `clerk env pull` +9. Generates a scaffold plan for the detected framework +10. Warns if the git working tree has uncommitted changes +11. Previews planned file changes and asks for confirmation +12. Writes scaffold files to disk +13. Runs project formatters (Prettier/Biome) on generated files +14. Scans for issues: hardcoded keys, leftover auth-library imports, stale API calls +15. Prints a summary of created, modified, and skipped files with recommendations ## Framework Detection -The command detects the project's framework by checking `package.json` dependencies: - -| Dependency | Framework | Clerk SDK | -| ----------------------- | -------------- | ----------------------- | -| `next` | Next.js | `@clerk/nextjs` | -| `expo` | Expo | `@clerk/expo` | -| `astro` | Astro | `@clerk/astro` | -| `nuxt` | Nuxt | `@clerk/nuxt` | -| `@tanstack/react-start` | TanStack Start | `@clerk/tanstack-start` | -| `react-router` | React Router | `@clerk/react-router` | -| `fastify` | Fastify | `@clerk/fastify` | -| `express` | Express | `@clerk/express` | -| `vue` | Vue | `@clerk/vue` | -| `react` | React | `@clerk/clerk-react` | -| `vite` | Vite | `@clerk/clerk-react` | - -The package manager is detected from lock files (`bun.lockb` → bun, `yarn.lock` → yarn, `pnpm-lock.yaml` → pnpm, else npm). +Detects the project's framework from `package.json` dependencies (checked top-to-bottom, first match wins): + +| Dependency | Framework | Clerk SDK | Publishable Key Env Var | +| ----------------------- | -------------- | ----------------------------- | ----------------------------------- | +| `next` | Next.js | `@clerk/nextjs` | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | +| `astro` | Astro | `@clerk/astro` | `PUBLIC_CLERK_PUBLISHABLE_KEY` | +| `nuxt` | Nuxt | `@clerk/nuxt` | `NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | +| `@tanstack/react-start` | TanStack Start | `@clerk/tanstack-react-start` | `VITE_CLERK_PUBLISHABLE_KEY` | +| `react-router` | React Router | `@clerk/react-router` | `VITE_CLERK_PUBLISHABLE_KEY` | +| `vue` | Vue | `@clerk/vue` | `VITE_CLERK_PUBLISHABLE_KEY` | +| `expo` | Expo | `@clerk/expo` | `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` | +| `react` | React | `@clerk/react` | `VITE_CLERK_PUBLISHABLE_KEY` | +| `express` | Express | `@clerk/express` | `CLERK_PUBLISHABLE_KEY` | +| `fastify` | Fastify | `@clerk/fastify` | `CLERK_PUBLISHABLE_KEY` | + +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 8 frameworks above. 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. + +All scaffolding is idempotent — files are skipped if they already contain Clerk setup. + +### Next.js (App Router) + +| Action | File | Description | +| ------ | ------------------------------------- | ----------------------------------------------------- | +| CREATE | `proxy.ts` or `middleware.ts` | `clerkMiddleware` with route protection | +| MODIFY | `app/layout.tsx` | Add `ClerkProvider` import and wrap `` children | +| CREATE | `app/sign-in/[[...sign-in]]/page.tsx` | Sign-in page with `` component | +| CREATE | `app/sign-up/[[...sign-up]]/page.tsx` | Sign-up page with `` component | + +The middleware filename is version-aware: `proxy.ts` for Next.js 16+, `middleware.ts` for ≤15. Existing middleware files are preserved and composed with `clerkMiddleware`. + +### Next.js (Pages Router) + +| Action | File | Description | +| ------------- | ---------------------------------- | ---------------------------------------- | +| CREATE | `proxy.ts` or `middleware.ts` | `clerkMiddleware` with route protection | +| CREATE/MODIFY | `pages/_app.tsx` | `ClerkProvider` wrapping `` | +| CREATE | `pages/sign-in/[[...sign-in]].tsx` | Sign-in page with `` component | +| CREATE | `pages/sign-up/[[...sign-up]].tsx` | Sign-up page with `` component | + +### React / Vite + +| Action | File | Description | +| ------ | ---------- | -------------------------------------------- | +| MODIFY | `main.tsx` | Add `ClerkProvider` import and wrap app root | + +### React Router + +| Action | File | Description | +| ------ | ------------------------ | ------------------------------------------------------ | +| MODIFY | `react-router.config.ts` | Enable `v8_middleware` future flag | +| MODIFY | `app/root.tsx` | Add ClerkProvider, clerkMiddleware, and rootAuthLoader | +| CREATE | `app/routes/sign-in.tsx` | Sign-in route with `` component | +| CREATE | `app/routes/sign-up.tsx` | Sign-up route with `` component | + +### Nuxt + +| Action | File | Description | +| ------ | ------------------- | ---------------------------------------- | +| MODIFY | `nuxt.config.ts` | Add `@clerk/nuxt` to modules array | +| CREATE | `pages/sign-in.vue` | Sign-in page with `` component | +| CREATE | `pages/sign-up.vue` | Sign-up page with `` component | + +Nuxt's module system auto-configures middleware and auto-imports components. + +### TanStack Start + +| Action | File | Description | +| ------ | -------------------------- | ------------------------------------------- | +| MODIFY | `src/start.ts` | Add `clerkMiddleware` to request middleware | +| MODIFY | `src/routes/__root.tsx` | Add `ClerkProvider` and wrap body contents | +| CREATE | `src/routes/sign-in.$.tsx` | Sign-in route with `` component | +| CREATE | `src/routes/sign-up.$.tsx` | Sign-up route with `` component | + +### Astro + +| Action | File | Description | +| ------ | ------------------------- | ------------------------------------------- | +| MODIFY | `astro.config.mjs` | Add `clerk()` integration import and config | +| CREATE | `src/middleware.ts` | Clerk middleware with `onRequest` export | +| CREATE | `src/pages/sign-in.astro` | Sign-in page with `` component | +| CREATE | `src/pages/sign-up.astro` | Sign-up page with `` component | + +### Vue + +| Action | File | Description | +| ------ | --------- | -------------------------------------------------- | +| MODIFY | `main.ts` | Add `clerkPlugin` with `publishableKey` to Vue app | ## API Endpoints From f24b49b0814c0fa6e3ad130cb5227b19d116af2c Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 17 Mar 2026 23:15:27 -0300 Subject: [PATCH 08/35] refactor(init): restructure agent prompts into markdown templates Replace monolithic prompts.ts with individual .md template files per framework, matching the clerk-docs structured format (sections, rules, deprecated patterns, verification checklists). - Move prompts to src/commands/init/prompts/*.md with {{PLACEHOLDER}} interpolation - Add prompts for expo, express, and fastify frameworks - Merge DOCS_URLS + TEMPLATE_MAP into single FRAMEWORK_PROMPTS record - Extract shared pmInstallCommand, remove duplicate pmAddCommand - Fix formatter-escaped underscores (\_app, \_\_root) in template loader - Add "After Setup" section to all prompts (matching clerk-docs) --- packages/cli-core/src/commands/init/index.ts | 14 +- .../cli-core/src/commands/init/prompts.ts | 160 ------------------ .../src/commands/init/prompts/astro.md | 111 ++++++++++++ .../src/commands/init/prompts/expo.md | 117 +++++++++++++ .../src/commands/init/prompts/express.md | 76 +++++++++ .../src/commands/init/prompts/fastify.md | 76 +++++++++ .../commands/init/prompts/generic-fallback.md | 47 +++++ .../src/commands/init/prompts/generic.md | 42 +++++ .../src/commands/init/prompts/index.ts | 134 +++++++++++++++ .../init/prompts/nextjs-app-router.md | 127 ++++++++++++++ .../init/prompts/nextjs-pages-router.md | 110 ++++++++++++ .../src/commands/init/prompts/nuxt.md | 92 ++++++++++ .../src/commands/init/prompts/react-router.md | 113 +++++++++++++ .../src/commands/init/prompts/react.md | 92 ++++++++++ .../commands/init/prompts/tanstack-start.md | 106 ++++++++++++ .../cli-core/src/commands/init/prompts/vue.md | 83 +++++++++ .../src/test/integration/agent-mode.test.ts | 2 +- 17 files changed, 1329 insertions(+), 173 deletions(-) delete mode 100644 packages/cli-core/src/commands/init/prompts.ts create mode 100644 packages/cli-core/src/commands/init/prompts/astro.md create mode 100644 packages/cli-core/src/commands/init/prompts/expo.md create mode 100644 packages/cli-core/src/commands/init/prompts/express.md create mode 100644 packages/cli-core/src/commands/init/prompts/fastify.md create mode 100644 packages/cli-core/src/commands/init/prompts/generic-fallback.md create mode 100644 packages/cli-core/src/commands/init/prompts/generic.md create mode 100644 packages/cli-core/src/commands/init/prompts/index.ts create mode 100644 packages/cli-core/src/commands/init/prompts/nextjs-app-router.md create mode 100644 packages/cli-core/src/commands/init/prompts/nextjs-pages-router.md create mode 100644 packages/cli-core/src/commands/init/prompts/nuxt.md create mode 100644 packages/cli-core/src/commands/init/prompts/react-router.md create mode 100644 packages/cli-core/src/commands/init/prompts/react.md create mode 100644 packages/cli-core/src/commands/init/prompts/tanstack-start.md create mode 100644 packages/cli-core/src/commands/init/prompts/vue.md diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 713eb46b1..1648e1ea2 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -14,7 +14,7 @@ import { scaffold } from "./scaffold.js"; import { previewAndConfirm } from "./preview.js"; import { runFormatters } from "./format.js"; import { detectAuthLibraries, scanForIssues, printFindings } from "./scan.js"; -import { buildAgentPrompt, GENERIC_AGENT_PROMPT } from "./prompts.js"; +import { buildAgentPrompt, GENERIC_AGENT_PROMPT, pmInstallCommand } from "./prompts/index.js"; import type { ProjectContext, ScaffoldPlan } from "./frameworks/types.js"; import type { ScanFinding } from "./scan.js"; @@ -22,18 +22,8 @@ import type { ScanFinding } from "./scan.js"; // Helpers // --------------------------------------------------------------------------- -function pmAddCommand(pm: ProjectContext["packageManager"]): string { - const commands: Record = { - bun: "bun add", - yarn: "yarn add", - pnpm: "pnpm add", - npm: "npm install", - }; - return commands[pm]; -} - async function installSdk(ctx: ProjectContext): Promise { - const addCmd = pmAddCommand(ctx.packageManager); + const addCmd = pmInstallCommand(ctx.packageManager); console.log(`Installing ${cyan(ctx.framework.sdk)} for ${ctx.framework.name}...`); const proc = Bun.spawn(addCmd.split(" ").concat(ctx.framework.sdk), { diff --git a/packages/cli-core/src/commands/init/prompts.ts b/packages/cli-core/src/commands/init/prompts.ts deleted file mode 100644 index 401fc64ad..000000000 --- a/packages/cli-core/src/commands/init/prompts.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { ProjectContext } from "./frameworks/types.js"; - -type PromptBuilder = (ctx: ProjectContext, base: string, ext: string, jsx: string) => string; - -function nextjsAppRouterPrompt( - ctx: ProjectContext, - base: string, - ext: string, - jsx: string, -): string { - return `Integrate Clerk auth into this Next.js (App Router) project. - -1. Install: ${ctx.framework.sdk} -2. Create ${base}${ctx.middlewareBasename}.${ext}: - \`\`\`typescript - import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; - const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]); - export default clerkMiddleware(async (auth, request) => { - if (!isPublicRoute(request)) { await auth.protect(); } - }); - export const config = { matcher: ["/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)"] }; - \`\`\` -3. Modify ${ctx.layoutPath ?? `${base}app/layout.${jsx}`}: Add \`import { ClerkProvider } from "@clerk/nextjs"\` and wrap the body children with \`...\`. -4. Create ${base}app/sign-in/[[...sign-in]]/page.${jsx}: \`import { SignIn } from "@clerk/nextjs"; export default function SignInPage() { return ; }\` -5. Create ${base}app/sign-up/[[...sign-up]]/page.${jsx}: \`import { SignUp } from "@clerk/nextjs"; export default function SignUpPage() { return ; }\` -6. Add to .env.local: NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up -7. Env vars (${ctx.framework.envVar} and CLERK_SECRET_KEY) are in .env.local via \`clerk env pull\`. - -Docs: https://clerk.com/docs/quickstarts/nextjs`; -} - -function nextjsPagesRouterPrompt( - ctx: ProjectContext, - base: string, - ext: string, - jsx: string, -): string { - return `Integrate Clerk auth into this Next.js (Pages Router) project. - -1. Install: ${ctx.framework.sdk} -2. Create ${base}${ctx.middlewareBasename}.${ext}: Same as App Router (clerkMiddleware with createRouteMatcher). -3. Create or modify ${base}pages/_app.${jsx}: Wrap \`\` with \`...\`. -4. Create ${base}pages/sign-in/[[...sign-in]].${jsx}: \`import { SignIn } from "@clerk/nextjs"; export default function SignInPage() { return ; }\` -5. Create ${base}pages/sign-up/[[...sign-up]].${jsx}: \`import { SignUp } from "@clerk/nextjs"; export default function SignUpPage() { return ; }\` -6. Add to .env.local: NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up - -Docs: https://clerk.com/docs/quickstarts/nextjs`; -} - -const AGENT_PROMPTS: Record = { - react: (ctx, base, _ext, jsx) => `Integrate Clerk auth into this React project. - -1. Install: ${ctx.framework.sdk} -2. Modify ${base}main.${jsx} (or entry file): Add \`import { ClerkProvider } from "@clerk/react"\` and wrap the app root with \`......\`. -3. Use \`\`, \`\`, \`\` from @clerk/react in your components. -4. Use \`\` and \`\` from @clerk/react for conditional rendering. -5. Env var (${ctx.framework.envVar}) is in ${ctx.envFile} via \`clerk env pull\`. - -Docs: https://clerk.com/docs/quickstarts/react`, - - "react-router": (ctx, _base, _ext, jsx) => `Integrate Clerk auth into this React Router project. - -1. Install: ${ctx.framework.sdk} -2. Enable middleware in react-router.config.ts: Add \`future: { v8_middleware: true }\` to the config. -3. Modify app/root.tsx: - - Add \`import { clerkMiddleware, rootAuthLoader } from "@clerk/react-router/server"\` - - Add \`import { ClerkProvider } from "@clerk/react-router"\` - - Export \`const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]\` - - Export \`const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)\` - - Wrap content with \`...\` -4. Create app/routes/sign-in.${jsx}: \`import { SignIn } from "@clerk/react-router"; export default function SignInPage() { return ; }\` -5. Create app/routes/sign-up.${jsx}: \`import { SignUp } from "@clerk/react-router"; export default function SignUpPage() { return ; }\` -6. Add routes to app/routes.ts: \`route('sign-in/*', 'routes/sign-in.tsx')\` and \`route('sign-up/*', 'routes/sign-up.tsx')\` -7. Env vars (${ctx.framework.envVar} and CLERK_SECRET_KEY) are in ${ctx.envFile} via \`clerk env pull\`. - -Docs: https://clerk.com/docs/quickstarts/react-router`, - - nuxt: (ctx) => `Integrate Clerk auth into this Nuxt project. - -1. Install: ${ctx.framework.sdk} -2. Modify nuxt.config.ts: Add \`'@clerk/nuxt'\` to the \`modules\` array. Middleware is auto-configured. -3. Create pages/sign-in.vue: \`\` (components are auto-imported). -4. Create pages/sign-up.vue: \`\`. -5. Use \`\` and \`\` in your templates for conditional rendering. -6. Env vars (${ctx.framework.envVar} and NUXT_CLERK_SECRET_KEY) are in ${ctx.envFile} via \`clerk env pull\`. - -Docs: https://clerk.com/docs/quickstarts/nuxt`, - - "@tanstack/react-start": (ctx, _base, _ext, jsx) => - `Integrate Clerk auth into this TanStack Start project. - -1. Install: ${ctx.framework.sdk} -2. Modify src/start.ts: Add \`import { clerkMiddleware } from "@clerk/tanstack-react-start/server"\` and add \`requestMiddleware: [clerkMiddleware()]\` to createStart config. -3. Modify src/routes/__root.tsx: Add \`import { ClerkProvider } from "@clerk/tanstack-react-start"\` and wrap body contents with \`\`. -4. Create src/routes/sign-in.$.${jsx}: Use \`createFileRoute("/sign-in/$")\` with \`\` from @clerk/tanstack-react-start. -5. Create src/routes/sign-up.$.${jsx}: Use \`createFileRoute("/sign-up/$")\` with \`\`. -6. Env vars (${ctx.framework.envVar} and CLERK_SECRET_KEY) are in ${ctx.envFile} via \`clerk env pull\`. - -Docs: https://clerk.com/docs/quickstarts/tanstack-start`, - - astro: (ctx) => `Integrate Clerk auth into this Astro project. - -1. Install: ${ctx.framework.sdk} -2. Modify astro.config.mjs: Add \`import clerk from "@clerk/astro"\` and \`clerk()\` to integrations. Ensure \`output: "server"\` and an SSR adapter. -3. Create src/middleware.ts: \`import { clerkMiddleware } from "@clerk/astro/server"; export const onRequest = clerkMiddleware();\` -4. Create src/pages/sign-in.astro with \`\` from @clerk/astro/components. -5. Create src/pages/sign-up.astro with \`\` from @clerk/astro/components. -6. Use \`\` and \`\` from @clerk/astro/components in layouts. -7. Env vars (${ctx.framework.envVar} and CLERK_SECRET_KEY) are in ${ctx.envFile} via \`clerk env pull\`. - -Docs: https://clerk.com/docs/quickstarts/astro`, - - vue: (ctx, base) => `Integrate Clerk auth into this Vue project. - -1. Install: ${ctx.framework.sdk} -2. Modify ${base}main.ts: Add \`import { clerkPlugin } from "@clerk/vue"\` and \`app.use(clerkPlugin, { publishableKey: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY })\`. -3. Use \`\`, \`\`, \`\`, \`\` from @clerk/vue in components. -4. Env var (${ctx.framework.envVar}) is in ${ctx.envFile} via \`clerk env pull\`. - -Docs: https://clerk.com/docs/quickstarts/vue`, -}; - -export const GENERIC_AGENT_PROMPT = `You are integrating Clerk authentication into an existing project. Follow these steps: - -1. Authenticate with Clerk: Run \`clerk auth login\` to log in via the browser. -2. Link a Clerk application: Run \`clerk link\` to associate this directory with a Clerk application. -3. Install the Clerk SDK appropriate for the project's framework (see https://clerk.com/docs/quickstarts). -4. Pull environment variables with \`clerk env pull\`. -5. Set up the Clerk provider at the root of the application. -6. Add sign-in and sign-up routes/components. -7. Protect routes that require authentication. - -Refer to the Clerk docs at https://clerk.com/docs for framework-specific details.`; - -export function buildAgentPrompt(ctx: ProjectContext): string { - const base = ctx.srcDir ? "src/" : ""; - const ext = ctx.typescript ? "ts" : "js"; - const jsx = ctx.typescript ? "tsx" : "jsx"; - - if (ctx.framework.dep === "next") { - if (ctx.variant === "pages-router") { - return nextjsPagesRouterPrompt(ctx, base, ext, jsx); - } - return nextjsAppRouterPrompt(ctx, base, ext, jsx); - } - - const builder = AGENT_PROMPTS[ctx.framework.dep]; - if (builder) { - return builder(ctx, base, ext, jsx); - } - - return `Integrate Clerk auth into this ${ctx.framework.name} project. - -1. Install: ${ctx.framework.sdk} -2. Set up the Clerk provider/middleware for ${ctx.framework.name}. -3. Create sign-in and sign-up routes/components. -4. Env vars (${ctx.framework.envVar} and CLERK_SECRET_KEY) are in ${ctx.envFile} via \`clerk env pull\`. - -Docs: https://clerk.com/docs`; -} diff --git a/packages/cli-core/src/commands/init/prompts/astro.md b/packages/cli-core/src/commands/init/prompts/astro.md new file mode 100644 index 000000000..84de3f3d1 --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/astro.md @@ -0,0 +1,111 @@ +# Add Clerk to Astro + +Install `{{SDK}}`. Add `clerk()` integration to `astro.config.mjs`. Create middleware with `clerkMiddleware()`. Use ``, ``, ``, `` from `@clerk/astro/components`. + +Latest docs: {{DOCS_URL}} + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## astro.config.mjs + +```typescript +import { defineConfig } from "astro/config"; +import clerk from "@clerk/astro"; + +export default defineConfig({ + integrations: [clerk()], + output: "server", +}); +``` + +## src/middleware.ts + +```typescript +import { clerkMiddleware } from "@clerk/astro/server"; + +export const onRequest = clerkMiddleware(); +``` + +## src/pages/sign-in.astro + +```astro +--- +import { SignIn } from '@clerk/astro/components'; +--- + +``` + +## src/pages/sign-up.astro + +```astro +--- +import { SignUp } from '@clerk/astro/components'; +--- + +``` + +## Example usage in a layout + +```astro +--- +import { Show, SignInButton, SignUpButton, UserButton } from '@clerk/astro/components'; +--- +
+ + + + + + + +
+``` + +## Environment + +Env vars (`{{ENV_VAR}}` and `CLERK_SECRET_KEY`) are in `{{ENV_FILE}}` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Add `clerk()` to `integrations` in `astro.config.mjs` +- Set `output: 'server'` (SSR required) with an SSR adapter +- Use `clerkMiddleware()` from `@clerk/astro/server` in `src/middleware.ts` +- Import components from `@clerk/astro/components` +- Use `` for conditional rendering +- Use existing package manager (`{{PM}}`) + +NEVER: + +- Use `authMiddleware()` (replaced by `clerkMiddleware()`) +- Use deprecated ``, `` (replaced by ``) +- Import from `@clerk/react` or `@clerk/nextjs` — use `@clerk/astro` +- Skip `output: 'server'` (Clerk requires SSR) + +## Deprecated (DO NOT use) + +```typescript +import { authMiddleware } from '@clerk/astro' // WRONG — use clerkMiddleware + // WRONG — use + // WRONG — use +output: 'static' // WRONG — Clerk requires SSR +``` + +## Verify Before Responding + +1. Is `clerk()` in `integrations` in `astro.config.mjs`? +2. Is `output: 'server'` set? +3. Is `clerkMiddleware()` exported as `onRequest` in `src/middleware.ts`? +4. Are components imported from `@clerk/astro/components`? +5. Is it using `` instead of ``/``? + +If any fails, revise. + +## After Setup + +Have the user sign up as their first test user. After signup succeeds and a profile icon appears, congratulate them. Then recommend exploring: Components (https://clerk.com/docs/reference/components/overview), Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/expo.md b/packages/cli-core/src/commands/init/prompts/expo.md new file mode 100644 index 000000000..599c726c7 --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/expo.md @@ -0,0 +1,117 @@ +# Add Clerk to Expo + +Install `{{SDK}}`. Wrap the app in `` with a secure token cache. Use ``, ``, ``, `` from `@clerk/expo`. + +Latest docs: {{DOCS_URL}} + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## Token Cache + +Create a secure token cache using `expo-secure-store`: + +```bash +{{INSTALL_CMD_EXTRA}} +``` + +```typescript +import * as SecureStore from "expo-secure-store"; + +export const tokenCache = { + async getToken(key: string) { + return SecureStore.getItemAsync(key); + }, + async saveToken(key: string, value: string) { + return SecureStore.setItemAsync(key, value); + }, + async clearToken(key: string) { + return SecureStore.deleteItemAsync(key); + }, +}; +``` + +## App entry (app/\_layout.{{JSX}} or App.{{JSX}}) + +```typescript +import { ClerkProvider, ClerkLoaded } from "@clerk/expo"; +import { tokenCache } from "./token-cache"; + +export default function RootLayout() { + const publishableKey = process.env.{{ENV_VAR}}; + + return ( + + + {/* your app content */} + + + ); +} +``` + +## Example usage + +```typescript +import { Show, SignInButton, SignUpButton, UserButton } from "@clerk/expo"; + +export default function Home() { + return ( + <> + + + + + + + + + ); +} +``` + +## Environment + +Env var (`{{ENV_VAR}}`) is in `{{ENV_FILE}}` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Use `@clerk/expo` (not `@clerk/react` or `@clerk/nextjs`) +- Pass `tokenCache` to `` for secure token storage +- Pass `publishableKey` explicitly from `process.env.{{ENV_VAR}}` +- Wrap content with `` inside `` +- Use `` for conditional rendering +- Use existing package manager (`{{PM}}`) + +NEVER: + +- Use `@clerk/react` or `@clerk/nextjs` — use `@clerk/expo` +- Use deprecated ``, `` (replaced by ``) +- Skip the `tokenCache` (tokens won't persist across app restarts) +- Use `localStorage` or `AsyncStorage` directly for tokens + +## Deprecated (DO NOT use) + +```typescript +import { ClerkProvider } from "@clerk/react" // WRONG — use @clerk/expo + // WRONG — use + // WRONG — use +``` + +## Verify Before Responding + +1. Is `@clerk/expo` used (not `@clerk/react`)? +2. Is `tokenCache` passed to ``? +3. Is `` wrapping the app content? +4. Is it using `` instead of ``/``? + +If any fails, revise. + +## After Setup + +Have the user sign up as their first test user. After signup succeeds, congratulate them. Then recommend exploring: Components (https://clerk.com/docs/reference/components/overview), Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/express.md b/packages/cli-core/src/commands/init/prompts/express.md new file mode 100644 index 000000000..63db89e2b --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/express.md @@ -0,0 +1,76 @@ +# Add Clerk to Express + +Install `{{SDK}}`. Add `clerkMiddleware()` to the Express app. Use `requireAuth()` to protect routes. Use `getAuth()` to access auth state in handlers. + +Latest docs: {{DOCS_URL}} + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## Server setup + +```typescript +import express from "express"; +import { clerkMiddleware, requireAuth, getAuth } from "@clerk/express"; + +const app = express(); + +// Apply Clerk middleware to all routes +app.use(clerkMiddleware()); + +// Public route — no auth required +app.get("/", (req, res) => { + res.json({ message: "Public route" }); +}); + +// Protected route — requires authentication +app.get("/protected", requireAuth(), (req, res) => { + const { userId } = getAuth(req); + res.json({ userId }); +}); + +app.listen(3000); +``` + +## Environment + +Env vars (`{{ENV_VAR}}` and `CLERK_SECRET_KEY`) are in `{{ENV_FILE}}` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Use `clerkMiddleware()` from `@clerk/express` as Express middleware +- Use `requireAuth()` to protect routes that need authentication +- Use `getAuth(req)` to access auth state (`userId`, `sessionId`, etc.) +- Apply `clerkMiddleware()` before any route that needs auth +- Use existing package manager (`{{PM}}`) + +NEVER: + +- Use `@clerk/nextjs` or `@clerk/react` — use `@clerk/express` +- Use deprecated `ClerkExpressRequireAuth` or `ClerkExpressWithAuth` (replaced by `requireAuth` and `getAuth`) +- Skip `clerkMiddleware()` — it's required for `requireAuth()` and `getAuth()` to work + +## Deprecated (DO NOT use) + +```typescript +import { ClerkExpressRequireAuth } from "@clerk/express"; // WRONG — use requireAuth +import { ClerkExpressWithAuth } from "@clerk/express"; // WRONG — use clerkMiddleware + getAuth +``` + +## Verify Before Responding + +1. Is `clerkMiddleware()` applied as middleware? +2. Are protected routes using `requireAuth()`? +3. Is `getAuth(req)` used to access auth state (not `req.auth` directly)? +4. Are imports from `@clerk/express`? + +If any fails, revise. + +## After Setup + +Have the user test the protected route by sending a request without authentication (should get 401) and with a valid session token (should succeed). Then recommend exploring: Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/fastify.md b/packages/cli-core/src/commands/init/prompts/fastify.md new file mode 100644 index 000000000..495cd19ce --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/fastify.md @@ -0,0 +1,76 @@ +# Add Clerk to Fastify + +Install `{{SDK}}`. Register `clerkPlugin` with the Fastify instance. Use `getAuth()` to access auth state in handlers. + +Latest docs: {{DOCS_URL}} + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## Server setup + +```typescript +import Fastify from "fastify"; +import { clerkPlugin, getAuth } from "@clerk/fastify"; + +const fastify = Fastify(); + +// Register Clerk plugin +fastify.register(clerkPlugin); + +// Public route — no auth required +fastify.get("/", async (request, reply) => { + return { message: "Public route" }; +}); + +// Protected route — check auth in handler +fastify.get("/protected", async (request, reply) => { + const { userId } = getAuth(request); + if (!userId) { + return reply.code(401).send({ error: "Unauthorized" }); + } + return { userId }; +}); + +fastify.listen({ port: 3000 }); +``` + +## Environment + +Env vars (`{{ENV_VAR}}` and `CLERK_SECRET_KEY`) are in `{{ENV_FILE}}` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Register `clerkPlugin` from `@clerk/fastify` with `fastify.register()` +- Use `getAuth(request)` to access auth state (`userId`, `sessionId`, etc.) +- Register the plugin before defining routes that need auth +- Use existing package manager (`{{PM}}`) + +NEVER: + +- Use `@clerk/nextjs` or `@clerk/express` — use `@clerk/fastify` +- Use deprecated `clerkPreHandler` (replaced by `clerkPlugin` + `getAuth`) +- Skip `clerkPlugin` registration — it's required for `getAuth()` to work + +## Deprecated (DO NOT use) + +```typescript +import { clerkPreHandler } from "@clerk/fastify"; // WRONG — use clerkPlugin + getAuth +``` + +## Verify Before Responding + +1. Is `clerkPlugin` registered with `fastify.register()`? +2. Is `getAuth(request)` used to access auth state? +3. Are imports from `@clerk/fastify`? + +If any fails, revise. + +## After Setup + +Have the user test the protected route by sending a request without authentication (should get 401) and with a valid session token (should succeed). Then recommend exploring: Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/generic-fallback.md b/packages/cli-core/src/commands/init/prompts/generic-fallback.md new file mode 100644 index 000000000..5cb8a3d46 --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/generic-fallback.md @@ -0,0 +1,47 @@ +# Add Clerk to {{FRAMEWORK_NAME}} + +Install `{{SDK}}`. Set up the Clerk provider/middleware for {{FRAMEWORK_NAME}}. Use `` for conditional auth rendering. + +Latest docs: {{DOCS_URL}} + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## Steps + +1. Set up the Clerk provider/middleware for {{FRAMEWORK_NAME}}. +2. Create sign-in and sign-up routes/components. +3. Use `` and `` for conditional rendering. + +## Environment + +Env vars (`{{ENV_VAR}}` and `CLERK_SECRET_KEY`) are in `{{ENV_FILE}}` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Use `{{SDK}}` — the correct SDK for {{FRAMEWORK_NAME}} +- Use `` for conditional rendering +- Use existing package manager (`{{PM}}`) + +NEVER: + +- Use deprecated ``, `` (replaced by ``) +- Use `authMiddleware()` (replaced by `clerkMiddleware()`) +- Import from the wrong Clerk SDK package + +## Verify Before Responding + +1. Is `{{SDK}}` installed? +2. Is the Clerk provider wrapping the application? +3. Is it using `` instead of ``/``? + +If any fails, revise. + +## After Setup + +Have the user sign up as their first test user. After signup succeeds and a profile icon appears, congratulate them. Then recommend exploring: Components (https://clerk.com/docs/reference/components/overview), Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/generic.md b/packages/cli-core/src/commands/init/prompts/generic.md new file mode 100644 index 000000000..f688b901b --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/generic.md @@ -0,0 +1,42 @@ +# Add Clerk Authentication + +Integrate Clerk auth into this project. Use the Clerk SDK appropriate for the project's framework. + +Latest docs: https://clerk.com/docs + +## Steps + +1. Authenticate with Clerk: Run `clerk auth login` to log in via the browser. +2. Link a Clerk application: Run `clerk link` to associate this directory with a Clerk application. +3. Install the Clerk SDK appropriate for the project's framework (see https://clerk.com/docs). +4. Pull environment variables with `clerk env pull`. +5. Set up the Clerk provider at the root of the application. +6. Add sign-in and sign-up routes/components. +7. Protect routes that require authentication. + +## Rules + +ALWAYS: + +- Use the framework-specific Clerk SDK (e.g. `@clerk/nextjs`, `@clerk/react`, `@clerk/vue`) +- Use `` for conditional rendering based on auth state +- Use existing package manager +- Follow the framework-specific quickstart at https://clerk.com/docs + +NEVER: + +- Use deprecated ``, `` (replaced by ``) +- Use `authMiddleware()` (replaced by `clerkMiddleware()`) +- Use `frontendApi` (removed, use `publishableKey` env var) + +## Verify Before Responding + +1. Is the correct framework-specific Clerk SDK installed? +2. Is the Clerk provider wrapping the application root? +3. Is it using `` instead of ``/``? + +If any fails, revise. + +## After Setup + +Have the user sign up as their first test user. After signup succeeds and a profile icon appears, congratulate them. Then recommend exploring: Components (https://clerk.com/docs/reference/components/overview), Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/index.ts b/packages/cli-core/src/commands/init/prompts/index.ts new file mode 100644 index 000000000..c3ad0de91 --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/index.ts @@ -0,0 +1,134 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { ProjectContext } from "../frameworks/types.js"; + +// --------------------------------------------------------------------------- +// Template loading +// --------------------------------------------------------------------------- + +const PROMPTS_DIR = import.meta.dir; + +const templateCache = new Map(); + +function loadTemplate(name: string): string { + const cached = templateCache.get(name); + if (cached) return cached; + + // The project formatter escapes underscores in markdown headings (e.g. `_app` → `\_app`). + // These templates are output as plain text, so undo that escaping. + const template = readFileSync(join(PROMPTS_DIR, `${name}.md`), "utf-8").replaceAll("\\_", "_"); + templateCache.set(name, template); + return template; +} + +function interpolate(template: string, vars: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const PM_COMMANDS: Record = { + bun: "bun add", + yarn: "yarn add", + pnpm: "pnpm add", + npm: "npm install", +}; + +export function pmInstallCommand(pm: ProjectContext["packageManager"]): string { + return PM_COMMANDS[pm]; +} + +// Maps framework dep to its template filename and docs URL. +// Next.js defaults to app-router; pages-router variant is handled in resolveTemplate. +const FRAMEWORK_PROMPTS: Record = { + next: { + template: "nextjs-app-router", + docsUrl: "https://clerk.com/docs/nextjs/getting-started/quickstart", + }, + react: { template: "react", docsUrl: "https://clerk.com/docs/react/getting-started/quickstart" }, + "react-router": { + template: "react-router", + docsUrl: "https://clerk.com/docs/react-router/getting-started/quickstart", + }, + nuxt: { template: "nuxt", docsUrl: "https://clerk.com/docs/nuxt/getting-started/quickstart" }, + "@tanstack/react-start": { + template: "tanstack-start", + docsUrl: "https://clerk.com/docs/tanstack-start/getting-started/quickstart", + }, + astro: { template: "astro", docsUrl: "https://clerk.com/docs/astro/getting-started/quickstart" }, + vue: { template: "vue", docsUrl: "https://clerk.com/docs/vue/getting-started/quickstart" }, + expo: { template: "expo", docsUrl: "https://clerk.com/docs/expo/getting-started/quickstart" }, + express: { + template: "express", + docsUrl: "https://clerk.com/docs/express/getting-started/quickstart", + }, + fastify: { + template: "fastify", + docsUrl: "https://clerk.com/docs/fastify/getting-started/quickstart", + }, +}; + +const DEFAULT_DOCS_URL = "https://clerk.com/docs"; + +// --------------------------------------------------------------------------- +// Variable builders +// --------------------------------------------------------------------------- + +// NOTE: The agent prompts show simple `clerkMiddleware()` (matching official docs). +// The scaffold code in `frameworks/helpers.ts` uses `createRouteMatcher` + `auth.protect()` +// which is more opinionated. This divergence is intentional — agents should follow the +// docs pattern; scaffolded code provides a production-ready starting point. + +function buildVars( + ctx: ProjectContext, + base: string, + ext: string, + jsx: string, +): Record { + const installCmd = `${pmInstallCommand(ctx.packageManager)} ${ctx.framework.sdk}`; + + const vars: Record = { + SDK: ctx.framework.sdk, + ENV_VAR: ctx.framework.envVar, + INSTALL_CMD: installCmd, + BASE: base, + BASE_DISPLAY: base || "project root", + EXT: ext, + JSX: jsx, + MIDDLEWARE_BASENAME: ctx.middlewareBasename, + LAYOUT_PATH: ctx.layoutPath ?? `${base}app/layout.${jsx}`, + ENV_FILE: ctx.envFile, + PM: ctx.packageManager, + DOCS_URL: FRAMEWORK_PROMPTS[ctx.framework.dep]?.docsUrl ?? DEFAULT_DOCS_URL, + FRAMEWORK_NAME: ctx.framework.name, + }; + + if (ctx.framework.dep === "expo") { + vars.INSTALL_CMD_EXTRA = `${pmInstallCommand(ctx.packageManager)} expo-secure-store`; + } + + return vars; +} + +function resolveTemplate(ctx: ProjectContext): string { + if (ctx.framework.dep === "next" && ctx.variant === "pages-router") { + return "nextjs-pages-router"; + } + return FRAMEWORK_PROMPTS[ctx.framework.dep]?.template ?? "generic-fallback"; +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export const GENERIC_AGENT_PROMPT = loadTemplate("generic"); + +export function buildAgentPrompt(ctx: ProjectContext): string { + const base = ctx.srcDir ? "src/" : ""; + const ext = ctx.typescript ? "ts" : "js"; + const jsx = ctx.typescript ? "tsx" : "jsx"; + + return interpolate(loadTemplate(resolveTemplate(ctx)), buildVars(ctx, base, ext, jsx)); +} diff --git a/packages/cli-core/src/commands/init/prompts/nextjs-app-router.md b/packages/cli-core/src/commands/init/prompts/nextjs-app-router.md new file mode 100644 index 000000000..a9da8ad6a --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/nextjs-app-router.md @@ -0,0 +1,127 @@ +# Add Clerk to Next.js App Router + +Install `{{SDK}}`. Create `{{MIDDLEWARE_BASENAME}}.{{EXT}}` with `clerkMiddleware()` from `@clerk/nextjs/server` (in `{{BASE_DISPLAY}}`). Add `` inside `` in `{{LAYOUT_PATH}}`. Use ``, ``, ``, `` from `@clerk/nextjs`. + +Latest docs: {{DOCS_URL}} + +## Keyless Mode + +No signup required. Without env vars (`{{ENV_VAR}}`, `CLERK_SECRET_KEY`), Clerk auto-generates temporary keys. A "Configure your application" prompt appears to claim later. Do NOT tell users to sign up, create accounts, get API keys, or add env vars before running. + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## {{BASE}}{{MIDDLEWARE_BASENAME}}.{{EXT}} + +```typescript +import { clerkMiddleware } from "@clerk/nextjs/server"; + +export default clerkMiddleware(); + +export const config = { + matcher: [ + "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + "/(api|trpc)(.*)", + ], +}; +``` + +## {{LAYOUT_PATH}} + +```typescript +import { ClerkProvider, SignInButton, SignUpButton, Show, UserButton } from "@clerk/nextjs"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
+ + + + + + + +
+ {children} +
+ + + ); +} +``` + +## {{BASE}}app/sign-in/[[...sign-in]]/page.{{JSX}} + +```typescript +import { SignIn } from "@clerk/nextjs"; +export default function SignInPage() { return ; } +``` + +## {{BASE}}app/sign-up/[[...sign-up]]/page.{{JSX}} + +```typescript +import { SignUp } from "@clerk/nextjs"; +export default function SignUpPage() { return ; } +``` + +## Environment + +Add to `.env.local`: + +``` +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +``` + +Env vars (`{{ENV_VAR}}` and `CLERK_SECRET_KEY`) are in `.env.local` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Use `clerkMiddleware()` from `@clerk/nextjs/server` in `{{MIDDLEWARE_BASENAME}}.{{EXT}}` +- Add `` inside `` in `{{LAYOUT_PATH}}` +- Import from `@clerk/nextjs` or `@clerk/nextjs/server` +- Use App Router (`app/page.tsx`, `app/layout.tsx`) +- Use `async/await` with `auth()` from `@clerk/nextjs/server` +- Use existing package manager (`{{PM}}`) +- Rely on keyless mode — skip account creation and API keys + +NEVER: + +- Reference `_app.tsx` or pages router +- Use `authMiddleware()` (replaced by `clerkMiddleware()`) +- Use old env var patterns +- Import deprecated APIs (`withAuth`, old `currentUser`) +- Use deprecated ``, `` (replaced by ``) +- Tell users to sign up or get API keys first + +## Deprecated (DO NOT use) + +```typescript +import { authMiddleware } from '@clerk/nextjs' // WRONG — use clerkMiddleware +function MyApp({ Component, pageProps }) {} // WRONG — pages router pattern +pages/signin.js // WRONG — use app/ directory + // WRONG — use + // WRONG — use +``` + +## Verify Before Responding + +1. Is `clerkMiddleware()` used in `{{MIDDLEWARE_BASENAME}}.{{EXT}}`? +2. Is `ClerkProvider` inside `` in `{{LAYOUT_PATH}}`? +3. Are imports only from `@clerk/nextjs` or `@clerk/nextjs/server`? +4. Is it using App Router, not `_app.tsx` or `pages/`? +5. Is it using `` instead of ``/``? + +If any fails, revise. + +## After Setup + +Have the user sign up as their first test user in the nav. After signup succeeds and a profile icon appears, congratulate them. If a "Configure your application" callout appears, tell them to click it. Then recommend exploring: Organizations (https://clerk.com/docs/guides/organizations/overview), Components (https://clerk.com/docs/reference/components/overview), Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/nextjs-pages-router.md b/packages/cli-core/src/commands/init/prompts/nextjs-pages-router.md new file mode 100644 index 000000000..b8c0950a2 --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/nextjs-pages-router.md @@ -0,0 +1,110 @@ +# Add Clerk to Next.js Pages Router + +Install `{{SDK}}`. Create `{{MIDDLEWARE_BASENAME}}.{{EXT}}` with `clerkMiddleware()` from `@clerk/nextjs/server`. Wrap `` with `` in `_app.{{JSX}}`. Use ``, ``, ``, `` from `@clerk/nextjs`. + +Latest docs: {{DOCS_URL}} + +## Keyless Mode + +No signup required. Without env vars (`{{ENV_VAR}}`, `CLERK_SECRET_KEY`), Clerk auto-generates temporary keys. A "Configure your application" prompt appears to claim later. Do NOT tell users to sign up, create accounts, get API keys, or add env vars before running. + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## {{BASE}}{{MIDDLEWARE_BASENAME}}.{{EXT}} + +```typescript +import { clerkMiddleware } from "@clerk/nextjs/server"; + +export default clerkMiddleware(); + +export const config = { + matcher: [ + "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + "/(api|trpc)(.*)", + ], +}; +``` + +## {{BASE}}pages/\_app.{{JSX}} + +```typescript +import { ClerkProvider } from "@clerk/nextjs"; +import type { AppProps } from "next/app"; + +export default function MyApp({ Component, pageProps }: AppProps) { + return ( + + + + ); +} +``` + +## {{BASE}}pages/sign-in/[[...sign-in]].{{JSX}} + +```typescript +import { SignIn } from "@clerk/nextjs"; +export default function SignInPage() { return ; } +``` + +## {{BASE}}pages/sign-up/[[...sign-up]].{{JSX}} + +```typescript +import { SignUp } from "@clerk/nextjs"; +export default function SignUpPage() { return ; } +``` + +## Environment + +Add to `.env.local`: + +``` +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +``` + +Env vars (`{{ENV_VAR}}` and `CLERK_SECRET_KEY`) are in `.env.local` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Use `clerkMiddleware()` from `@clerk/nextjs/server` in `{{MIDDLEWARE_BASENAME}}.{{EXT}}` +- Wrap `` with `` in `_app.{{JSX}}` +- Import from `@clerk/nextjs` or `@clerk/nextjs/server` +- Use `async/await` with `auth()` from `@clerk/nextjs/server` in API routes +- Use existing package manager (`{{PM}}`) +- Rely on keyless mode — skip account creation and API keys + +NEVER: + +- Use `authMiddleware()` (replaced by `clerkMiddleware()`) +- Use old env var patterns +- Import deprecated APIs (`withAuth`, old `currentUser`) +- Use deprecated ``, `` (replaced by ``) +- Tell users to sign up or get API keys first + +## Deprecated (DO NOT use) + +```typescript +import { authMiddleware } from '@clerk/nextjs' // WRONG — use clerkMiddleware + // WRONG — use + // WRONG — use +``` + +## Verify Before Responding + +1. Is `clerkMiddleware()` used in `{{MIDDLEWARE_BASENAME}}.{{EXT}}`? +2. Is `` wrapping `` in `_app.{{JSX}}`? +3. Are imports only from `@clerk/nextjs` or `@clerk/nextjs/server`? +4. Is it using `` instead of ``/``? + +If any fails, revise. + +## After Setup + +Have the user sign up as their first test user in the nav. After signup succeeds and a profile icon appears, congratulate them. If a "Configure your application" callout appears, tell them to click it. Then recommend exploring: Organizations (https://clerk.com/docs/guides/organizations/overview), Components (https://clerk.com/docs/reference/components/overview), Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/nuxt.md b/packages/cli-core/src/commands/init/prompts/nuxt.md new file mode 100644 index 000000000..c53c8952d --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/nuxt.md @@ -0,0 +1,92 @@ +# Add Clerk to Nuxt + +Install `{{SDK}}`. Add `@clerk/nuxt` to the `modules` array in `nuxt.config.ts`. Middleware is auto-configured. Use ``, ``, ``, `` (auto-imported). + +Latest docs: {{DOCS_URL}} + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## nuxt.config.ts + +```typescript +export default defineNuxtConfig({ + modules: ["@clerk/nuxt"], +}); +``` + +## pages/sign-in.vue + +```vue + +``` + +## pages/sign-up.vue + +```vue + +``` + +## Example usage in a component + +```vue + +``` + +## Environment + +Env vars (`{{ENV_VAR}}` and `NUXT_CLERK_SECRET_KEY`) are in `{{ENV_FILE}}` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Add `'@clerk/nuxt'` to the `modules` array in `nuxt.config.ts` +- Let Nuxt auto-import Clerk components (no manual imports needed) +- Use `` for conditional rendering +- Use existing package manager (`{{PM}}`) + +NEVER: + +- Manually configure middleware (Nuxt module handles it) +- Manually import Clerk components in `.vue` files (auto-imported) +- Use deprecated ``, `` (replaced by ``) +- Import from `@clerk/vue` — use `@clerk/nuxt` + +## Deprecated (DO NOT use) + +```typescript +import { clerkMiddleware } from '@clerk/nuxt' // WRONG — module auto-configures middleware + // WRONG — use + // WRONG — use +import { SignIn } from "@clerk/vue" // WRONG — auto-imported by Nuxt module +``` + +## Verify Before Responding + +1. Is `'@clerk/nuxt'` in the `modules` array in `nuxt.config.ts`? +2. Are Clerk components used without manual imports? +3. Is it using `` instead of ``/``? + +If any fails, revise. + +## After Setup + +Have the user sign up as their first test user. After signup succeeds and a profile icon appears, congratulate them. Then recommend exploring: Components (https://clerk.com/docs/reference/components/overview), Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/react-router.md b/packages/cli-core/src/commands/init/prompts/react-router.md new file mode 100644 index 000000000..80fc54c91 --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/react-router.md @@ -0,0 +1,113 @@ +# Add Clerk to React Router + +Install `{{SDK}}`. Enable middleware in `react-router.config.ts`. Set up `clerkMiddleware()` and `ClerkProvider` in `app/root.tsx`. Use ``, ``, ``, `` from `@clerk/react-router`. + +Latest docs: {{DOCS_URL}} + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## react-router.config.ts + +```typescript +import type { Config } from "@react-router/dev/config"; + +export default { + future: { + v8_middleware: true, + }, +} satisfies Config; +``` + +## app/root.tsx + +```typescript +import { clerkMiddleware, rootAuthLoader } from "@clerk/react-router/server"; +import { ClerkProvider } from "@clerk/react-router"; +import type { Route } from "./+types/root"; + +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; + +export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); + +export default function Root({ loaderData }: Route.ComponentProps) { + return ( + + {/* your app content */} + + ); +} +``` + +## app/routes/sign-in.{{JSX}} + +```typescript +import { SignIn } from "@clerk/react-router"; +export default function SignInPage() { return ; } +``` + +## app/routes/sign-up.{{JSX}} + +```typescript +import { SignUp } from "@clerk/react-router"; +export default function SignUpPage() { return ; } +``` + +## app/routes.ts + +```typescript +import { type RouteConfig, route } from "@react-router/dev/routes"; + +export default [ + route("sign-in/*", "routes/sign-in.tsx"), + route("sign-up/*", "routes/sign-up.tsx"), +] satisfies RouteConfig; +``` + +## Environment + +Env vars (`{{ENV_VAR}}` and `CLERK_SECRET_KEY`) are in `{{ENV_FILE}}` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Enable `future: { v8_middleware: true }` in `react-router.config.ts` +- Use `clerkMiddleware()` from `@clerk/react-router/server` in `app/root.tsx` +- Use `rootAuthLoader` for the root loader +- Wrap content with `` +- Use `` for conditional rendering +- Use existing package manager (`{{PM}}`) + +NEVER: + +- Use `authMiddleware()` (replaced by `clerkMiddleware()`) +- Skip the `rootAuthLoader` in the root route +- Use deprecated ``, `` (replaced by ``) +- Import from `@clerk/nextjs` or `@clerk/react` — use `@clerk/react-router` + +## Deprecated (DO NOT use) + +```typescript +import { authMiddleware } from '@clerk/react-router' // WRONG — use clerkMiddleware + // WRONG — use + // WRONG — use +import { ClerkProvider } from "@clerk/react" // WRONG — use @clerk/react-router +``` + +## Verify Before Responding + +1. Is `v8_middleware: true` set in `react-router.config.ts`? +2. Is `clerkMiddleware()` exported in `app/root.tsx`? +3. Is `rootAuthLoader` used as the root loader? +4. Is `` wrapping the app? +5. Is it using `` instead of ``/``? + +If any fails, revise. + +## After Setup + +Have the user sign up as their first test user. After signup succeeds and a profile icon appears, congratulate them. Then recommend exploring: Components (https://clerk.com/docs/reference/components/overview), Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/react.md b/packages/cli-core/src/commands/init/prompts/react.md new file mode 100644 index 000000000..f1deaa713 --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/react.md @@ -0,0 +1,92 @@ +# Add Clerk to React + +Install `{{SDK}}`. Wrap the app in `` in `{{BASE}}main.{{JSX}}`. Use ``, ``, ``, `` from `@clerk/react`. + +Latest docs: {{DOCS_URL}} + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## {{BASE}}main.{{JSX}} + +```typescript +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.{{JSX}}"; +import { ClerkProvider } from "@clerk/react"; + +createRoot(document.getElementById("root")!).render( + + + + + +); +``` + +## App.{{JSX}} + +```typescript +import { Show, SignInButton, SignUpButton, UserButton } from "@clerk/react"; + +export default function App() { + return ( +
+ + + + + + + +
+ ); +} +``` + +## Environment + +Env var (`{{ENV_VAR}}`) is in `{{ENV_FILE}}` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Use `@clerk/react` (not any other Clerk package) +- Reference env var as `{{ENV_VAR}}` in `{{ENV_FILE}}` +- Wrap the entire app in `` within `{{BASE}}main.{{JSX}}` +- Use ``, ``, ``, `` +- Use existing package manager (`{{PM}}`) + +NEVER: + +- Use `frontendApi` in place of `publishableKey` +- Use older env var names like `REACT_APP_CLERK_FRONTEND_API` or `VITE_REACT_APP_CLERK_PUBLISHABLE_KEY` +- Manually pass `publishableKey` as a prop to `` +- Place `` deeper in the component tree instead of `{{BASE}}main.{{JSX}}` +- Use deprecated ``, `` (replaced by ``) + +## Deprecated (DO NOT use) + +```typescript +import { SignedIn, SignedOut } from "@clerk/react" // WRONG — use + // WRONG — reads from env automatically +frontendApi="..." // WRONG — removed, use publishableKey env var +REACT_APP_CLERK_FRONTEND_API // WRONG — use {{ENV_VAR}} +``` + +## Verify Before Responding + +1. Is `` in `{{BASE}}main.{{JSX}}` without a manual `publishableKey` prop? +2. Is env var named `{{ENV_VAR}}`? +3. Is it using `` instead of ``/``? +4. No usage of `frontendApi`? + +If any fails, revise. + +## After Setup + +Have the user sign up as their first test user. After signup succeeds and a profile icon appears, congratulate them. Then recommend exploring: Components (https://clerk.com/docs/reference/components/overview), Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/tanstack-start.md b/packages/cli-core/src/commands/init/prompts/tanstack-start.md new file mode 100644 index 000000000..65e251fe1 --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/tanstack-start.md @@ -0,0 +1,106 @@ +# Add Clerk to TanStack Start + +Install `{{SDK}}`. Add `clerkMiddleware()` to `src/start.ts`. Wrap content with `` in `src/routes/__root.tsx`. Use ``, ``, ``, `` from `@clerk/tanstack-react-start`. + +Latest docs: {{DOCS_URL}} + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## src/start.ts + +```typescript +import { createStart } from "@tanstack/react-start/server"; +import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; + +export default createStart({ + requestMiddleware: [clerkMiddleware()], +}); +``` + +## src/routes/\_\_root.tsx + +```typescript +import { ClerkProvider } from "@clerk/tanstack-react-start"; +import { createRootRoute, Outlet } from "@tanstack/react-router"; + +export const Route = createRootRoute({ + component: RootLayout, +}); + +function RootLayout() { + return ( + + + + ); +} +``` + +## src/routes/sign-in.$.{{JSX}} + +```typescript +import { createFileRoute } from "@tanstack/react-router"; +import { SignIn } from "@clerk/tanstack-react-start"; + +export const Route = createFileRoute("/sign-in/$")({ + component: () => , +}); +``` + +## src/routes/sign-up.$.{{JSX}} + +```typescript +import { createFileRoute } from "@tanstack/react-router"; +import { SignUp } from "@clerk/tanstack-react-start"; + +export const Route = createFileRoute("/sign-up/$")({ + component: () => , +}); +``` + +## Environment + +Env vars (`{{ENV_VAR}}` and `CLERK_SECRET_KEY`) are in `{{ENV_FILE}}` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Use `clerkMiddleware()` from `@clerk/tanstack-react-start/server` in `src/start.ts` +- Wrap content with `` in `src/routes/__root.tsx` +- Use `createFileRoute` for sign-in/sign-up routes with `$` splat +- Use `` for conditional rendering +- Use existing package manager (`{{PM}}`) + +NEVER: + +- Use `authMiddleware()` (replaced by `clerkMiddleware()`) +- Use deprecated ``, `` (replaced by ``) +- Import from `@clerk/react` or `@clerk/nextjs` — use `@clerk/tanstack-react-start` +- Skip `requestMiddleware` in `createStart` config + +## Deprecated (DO NOT use) + +```typescript +import { authMiddleware } from '@clerk/tanstack-react-start' // WRONG — use clerkMiddleware + // WRONG — use + // WRONG — use +import { ClerkProvider } from "@clerk/react" // WRONG — use @clerk/tanstack-react-start +``` + +## Verify Before Responding + +1. Is `clerkMiddleware()` in `requestMiddleware` in `src/start.ts`? +2. Is `` wrapping `` in `src/routes/__root.tsx`? +3. Are sign-in/sign-up routes using `createFileRoute` with `$` splat? +4. Is it using `` instead of ``/``? + +If any fails, revise. + +## After Setup + +Have the user sign up as their first test user. After signup succeeds and a profile icon appears, congratulate them. Then recommend exploring: Components (https://clerk.com/docs/reference/components/overview), Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/commands/init/prompts/vue.md b/packages/cli-core/src/commands/init/prompts/vue.md new file mode 100644 index 000000000..58716d348 --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/vue.md @@ -0,0 +1,83 @@ +# Add Clerk to Vue + +Install `{{SDK}}`. Add `clerkPlugin` to the Vue app in `{{BASE}}main.ts`. Use ``, ``, ``, `` from `@clerk/vue`. + +Latest docs: {{DOCS_URL}} + +## Install + +```bash +{{INSTALL_CMD}} +``` + +## {{BASE}}main.ts + +```typescript +import { createApp } from 'vue'; +import App from './App.vue'; +import { clerkPlugin } from '@clerk/vue'; + +const app = createApp(App); +app.use(clerkPlugin, { + publishableKey: import.meta.env.{{ENV_VAR}}, +}); +app.mount('#app'); +``` + +## Example usage in App.vue + +```vue + + + +``` + +## Environment + +Env var (`{{ENV_VAR}}`) is in `{{ENV_FILE}}` via `clerk env pull`. + +## Rules + +ALWAYS: + +- Use `clerkPlugin` from `@clerk/vue` in `{{BASE}}main.ts` +- Pass `publishableKey: import.meta.env.{{ENV_VAR}}` to `clerkPlugin` +- Use `` for conditional rendering +- Use existing package manager (`{{PM}}`) + +NEVER: + +- Use deprecated ``, `` (replaced by ``) +- Import from `@clerk/react` or `@clerk/nextjs` — use `@clerk/vue` +- Use old env var names + +## Deprecated (DO NOT use) + +```typescript + // WRONG — use + // WRONG — use +``` + +## Verify Before Responding + +1. Is `clerkPlugin` used with `app.use()` in `{{BASE}}main.ts`? +2. Is `publishableKey` reading from `import.meta.env.{{ENV_VAR}}`? +3. Is it using `` instead of ``/``? + +If any fails, revise. + +## After Setup + +Have the user sign up as their first test user. After signup succeeds and a profile icon appears, congratulate them. Then recommend exploring: Components (https://clerk.com/docs/reference/components/overview), Dashboard (https://dashboard.clerk.com/). diff --git a/packages/cli-core/src/test/integration/agent-mode.test.ts b/packages/cli-core/src/test/integration/agent-mode.test.ts index d126167b6..c70618cf8 100644 --- a/packages/cli-core/src/test/integration/agent-mode.test.ts +++ b/packages/cli-core/src/test/integration/agent-mode.test.ts @@ -10,7 +10,7 @@ useIntegrationTestHarness(); test("init outputs structured agent prompt without API calls", async () => { const { stdout } = await clerk("--mode", "agent", "init"); - expect(stdout).toContain("integrating Clerk authentication"); + expect(stdout).toContain("# Add Clerk Authentication"); expect(stdout).toContain("clerk auth login"); expect(http.requests.length).toBe(0); }); From aede96f12954f1cc0c52c151bd71b19f724f4a84 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 19 Mar 2026 16:48:48 -0300 Subject: [PATCH 09/35] refactor(init): make FileAction a discriminated union and extend FrameworkScaffold FileAction is now a proper discriminated union with a dedicated `skip` variant that has no `content` field, enforced at the type level. The FrameworkScaffold interface gains `dep`, `variant`, `minMajorVersion`, `matches()`, and optional `enrichContext()` so each scaffolder is self-describing. Enrichment fields (variant, layoutPath, middlewareBasename) become optional on ProjectContext since they are populated after gatherContext. --- .../src/commands/init/frameworks/types.ts | 33 +++++++++++-------- packages/cli-core/src/commands/init/index.ts | 15 +++++---- .../cli-core/src/commands/init/preview.ts | 2 +- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/types.ts b/packages/cli-core/src/commands/init/frameworks/types.ts index 61b49f297..1fbdc421d 100644 --- a/packages/cli-core/src/commands/init/frameworks/types.ts +++ b/packages/cli-core/src/commands/init/frameworks/types.ts @@ -3,27 +3,24 @@ import type { FrameworkInfo } from "../../../lib/framework.js"; export interface ProjectContext { cwd: string; framework: FrameworkInfo; - variant: "app-router" | "pages-router" | null; typescript: boolean; srcDir: boolean; packageManager: "bun" | "yarn" | "pnpm" | "npm"; existingClerk: boolean; deps: Record; - layoutPath: string | null; envFile: string; - /** Next.js middleware basename: "proxy" for Next.js 16+, "middleware" for ≤15 */ - middlewareBasename: "proxy" | "middleware"; + /** Framework-specific variant (e.g., "app-router" | "pages-router"). Populated by enrichContext. */ + variant?: "app-router" | "pages-router" | null; + /** Path to the layout/entry file. Populated by enrichContext. */ + layoutPath?: string | null; + /** Next.js middleware basename: "proxy" for Next.js 16+, "middleware" for ≤15. Populated by enrichContext. */ + middlewareBasename?: "proxy" | "middleware"; } -export interface FileAction { - /** Relative path from cwd */ - path: string; - type: "create" | "modify"; - content: string; - description: string; - /** If set, this action is skipped and the reason is shown in the preview */ - skipReason?: string; -} +export type FileAction = + | { type: "create"; path: string; content: string; description: string } + | { type: "modify"; path: string; content: string; description: string } + | { type: "skip"; path: string; skipReason: string }; export interface ScaffoldPlan { actions: FileAction[]; @@ -32,5 +29,15 @@ export interface ScaffoldPlan { export interface FrameworkScaffold { name: string; + /** The npm dependency name this scaffolder targets (e.g., "next", "react", "astro"). */ + dep: string; + /** Optional variant label (e.g., "app-router", "pages-router"). */ + variant?: string; + /** Minimum major version of the framework dependency required for scaffolding. */ + minMajorVersion?: number; + /** Return true if this scaffolder handles the given project context. */ + matches(ctx: ProjectContext): boolean; + /** Populate framework-specific fields on the context before scaffolding. */ + enrichContext?(ctx: ProjectContext): Promise; scaffold(ctx: ProjectContext): Promise; } diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 1648e1ea2..ceadc290f 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -10,7 +10,7 @@ import { getToken } from "../../lib/credential-store.js"; import { resolveProfile } from "../../lib/config.js"; import { fetchUserInfo } from "../../lib/token-exchange.js"; import { gatherContext } from "./context.js"; -import { scaffold } from "./scaffold.js"; +import { scaffold, enrichProjectContext } from "./scaffold.js"; import { previewAndConfirm } from "./preview.js"; import { runFormatters } from "./format.js"; import { detectAuthLibraries, scanForIssues, printFindings } from "./scan.js"; @@ -46,7 +46,7 @@ async function writePlan(cwd: string, plan: ScaffoldPlan): Promise { const written: string[] = []; for (const action of plan.actions) { - if (action.skipReason) continue; + if (action.type === "skip") continue; const fullPath = join(cwd, action.path); @@ -77,9 +77,9 @@ async function checkGitDirty(cwd: string): Promise { } function printOutro(plan: ScaffoldPlan, findings: ScanFinding[]): void { - const created = plan.actions.filter((a) => a.type === "create" && !a.skipReason); - const modified = plan.actions.filter((a) => a.type === "modify" && !a.skipReason); - const skipped = plan.actions.filter((a) => a.skipReason); + const created = plan.actions.filter((a) => a.type === "create"); + const modified = plan.actions.filter((a) => a.type === "modify"); + const skipped = plan.actions.filter((a) => a.type === "skip"); console.log(bold(green("\n✓ Clerk has been set up in your project!\n"))); @@ -128,6 +128,9 @@ export async function init() { const cwd = process.cwd(); const ctx = await gatherContext(cwd); + // Populate framework-specific context (variant, layoutPath, middlewareBasename) + if (ctx) await enrichProjectContext(ctx); + if (isAgent()) { console.log(ctx ? buildAgentPrompt(ctx) : GENERIC_AGENT_PROMPT); return; @@ -187,7 +190,7 @@ async function detectAndInstall(cwd: string, ctx: ProjectContext | null): Promis async function scaffoldAndWrite(cwd: string, ctx: ProjectContext): Promise { const plan = await scaffold(ctx); - const hasChanges = plan.actions.some((a) => !a.skipReason); + const hasChanges = plan.actions.some((a) => a.type !== "skip"); if (!hasChanges && plan.postInstructions.length === 0) { console.log(green("\nClerk is already set up in this project.")); diff --git a/packages/cli-core/src/commands/init/preview.ts b/packages/cli-core/src/commands/init/preview.ts index 2908032c5..e12f6a0a0 100644 --- a/packages/cli-core/src/commands/init/preview.ts +++ b/packages/cli-core/src/commands/init/preview.ts @@ -6,7 +6,7 @@ export async function previewAndConfirm(plan: ScaffoldPlan): Promise { console.log("\nclerk init will make the following changes:\n"); for (const action of plan.actions) { - if (action.skipReason) { + if (action.type === "skip") { console.log(` ${dim("SKIP")} ${dim(action.path)} — ${dim(action.skipReason)}`); } else if (action.type === "create") { console.log(` ${green("CREATE")} ${cyan(action.path)}`); From d0d39e11c96036d4ee19efce3284c53a1709e2c7 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 19 Mar 2026 16:48:56 -0300 Subject: [PATCH 10/35] refactor(init): extract Next.js context enrichment to frameworks/nextjs-context Move parseNextMajorVersion, detectMiddlewareBasename, detectNextjsVariant, and detectLayoutPath from context.ts into a dedicated nextjs-context.ts module. This isolates framework-specific logic behind the enrichContext hook on FrameworkScaffold rather than coupling it to gatherContext. Parallelize dirExists calls with Promise.all in both files. --- .../cli-core/src/commands/init/context.ts | 109 ++---------------- .../init/frameworks/nextjs-context.ts | 87 ++++++++++++++ 2 files changed, 94 insertions(+), 102 deletions(-) create mode 100644 packages/cli-core/src/commands/init/frameworks/nextjs-context.ts diff --git a/packages/cli-core/src/commands/init/context.ts b/packages/cli-core/src/commands/init/context.ts index 2390b6263..94422a89b 100644 --- a/packages/cli-core/src/commands/init/context.ts +++ b/packages/cli-core/src/commands/init/context.ts @@ -1,14 +1,13 @@ import { join } from "node:path"; import { stat } from "node:fs/promises"; import { detectFramework, readDeps } from "../../lib/framework.js"; -import { findFirstFile } from "./frameworks/helpers.js"; import type { ProjectContext } from "./frameworks/types.js"; export async function fileExists(path: string): Promise { return Bun.file(path).exists(); } -async function dirExists(path: string): Promise { +export async function dirExists(path: string): Promise { try { const s = await stat(path); return s.isDirectory(); @@ -35,133 +34,39 @@ async function detectPackageManager(cwd: string): Promise=15", etc. - * Returns null for non-numeric versions like "latest", "canary", "*". - */ -export function parseNextMajorVersion(version: string): number | null { - const match = version.match(/(\d+)/); - return match ? parseInt(match[1]!, 10) : null; -} - -/** - * Determine the correct middleware filename for a Next.js project. - * Next.js 16+ uses proxy.ts, ≤15 uses middleware.ts. - * - * Priority: existing file > version-based > default to proxy (latest convention). - */ -async function detectMiddlewareBasename( - cwd: string, - srcDir: boolean, - ext: string, - nextVersion: string | undefined, -): Promise { - const base = srcDir ? "src/" : ""; - - // Existing file takes precedence - if (await fileExists(join(cwd, `${base}proxy.${ext}`))) return "proxy"; - if (await fileExists(join(cwd, `${base}middleware.${ext}`))) return "middleware"; - - // Fall back to version detection - if (!nextVersion) return "proxy"; - - const major = parseNextMajorVersion(nextVersion); - if (major === null) return "proxy"; // Unknown version (e.g., "latest", "*") - - return major >= 16 ? "proxy" : "middleware"; -} - -async function detectLayoutPath( - cwd: string, - dep: string, - variant: ProjectContext["variant"], - srcDir: boolean, - ext: string, -): Promise { - const base = srcDir ? "src/" : ""; - - if (dep === "next") { - if (variant === "pages-router") { - return findFirstFile(cwd, [`${base}pages/_app.${ext}x`, `${base}pages/_app.${ext}`]); - } - return findFirstFile(cwd, [`${base}app/layout.${ext}x`, `${base}app/layout.${ext}`]); - } - - return null; -} - -function detectNextjsVariant( - dep: string, - dirs: { - srcDir: boolean; - srcAppDir: boolean; - srcPagesDir: boolean; - rootAppDir: boolean; - rootPagesDir: boolean; - }, -): ProjectContext["variant"] { - if (dep !== "next") return null; - - const appExists = dirs.srcDir ? dirs.srcAppDir : dirs.rootAppDir; - if (appExists) return "app-router"; - - const pagesExists = dirs.srcDir ? dirs.srcPagesDir : dirs.rootPagesDir; - if (pagesExists) return "pages-router"; - - return "app-router"; // Default for new Next.js projects -} - export async function gatherContext(cwd: string): Promise { const framework = await detectFramework(cwd); if (!framework) return null; const typescript = await fileExists(join(cwd, "tsconfig.json")); - const ext = typescript ? "ts" : "js"; - const srcAppDir = await dirExists(join(cwd, "src/app")); - const srcPagesDir = await dirExists(join(cwd, "src/pages")); - const rootAppDir = await dirExists(join(cwd, "app")); - const rootPagesDir = await dirExists(join(cwd, "pages")); + const [srcAppDir, srcPagesDir, rootAppDir, rootPagesDir] = await Promise.all([ + dirExists(join(cwd, "src/app")), + dirExists(join(cwd, "src/pages")), + dirExists(join(cwd, "app")), + dirExists(join(cwd, "pages")), + ]); // Use src/ convention only when app/pages dirs exist in src/ but NOT in root const hasSrcStructure = srcAppDir || srcPagesDir; const hasRootStructure = rootAppDir || rootPagesDir; const srcDir = hasSrcStructure && !hasRootStructure; - const variant = detectNextjsVariant(framework.dep, { - srcDir, - srcAppDir, - srcPagesDir, - rootAppDir, - rootPagesDir, - }); - const packageManager = await detectPackageManager(cwd); const deps = await readDeps(cwd); const existingClerk = deps ? Object.keys(deps).some((d) => d.startsWith("@clerk/")) : false; - const layoutPath = await detectLayoutPath(cwd, framework.dep, variant, srcDir, ext); - const envFile = (await fileExists(join(cwd, ".env.local"))) ? ".env.local" : ".env"; - const middlewareBasename = - framework.dep === "next" - ? await detectMiddlewareBasename(cwd, srcDir, ext, deps?.[framework.dep]) - : ("middleware" as const); - return { cwd, framework, - variant, typescript, srcDir, packageManager, existingClerk, deps: deps ?? {}, - layoutPath, envFile, - middlewareBasename, }; } diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts new file mode 100644 index 000000000..40f5d5151 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts @@ -0,0 +1,87 @@ +import { join } from "node:path"; +import { fileExists, dirExists } from "../context.js"; +import { findFirstFile, resolveNextjsMiddlewareBasename } from "./helpers.js"; +import type { ProjectContext } from "./types.js"; + +/** + * Determine the correct middleware filename for a Next.js project. + * Next.js 16+ uses proxy.ts, ≤15 uses middleware.ts. + * + * Priority: existing file > version-based > default to proxy (latest convention). + */ +async function detectMiddlewareBasename( + cwd: string, + srcDir: boolean, + ext: string, + nextVersion: string | undefined, +): Promise> { + const base = srcDir ? "src/" : ""; + + // Existing file takes precedence + if (await fileExists(join(cwd, `${base}proxy.${ext}`))) return "proxy"; + if (await fileExists(join(cwd, `${base}middleware.${ext}`))) return "middleware"; + + return resolveNextjsMiddlewareBasename(nextVersion); +} + +function detectNextjsVariant(dirs: { + srcDir: boolean; + srcAppDir: boolean; + srcPagesDir: boolean; + rootAppDir: boolean; + rootPagesDir: boolean; +}): NonNullable { + const appExists = dirs.srcDir ? dirs.srcAppDir : dirs.rootAppDir; + if (appExists) return "app-router"; + + const pagesExists = dirs.srcDir ? dirs.srcPagesDir : dirs.rootPagesDir; + if (pagesExists) return "pages-router"; + + return "app-router"; // Default for new Next.js projects +} + +async function detectLayoutPath( + cwd: string, + variant: ProjectContext["variant"], + srcDir: boolean, + ext: string, +): Promise { + const base = srcDir ? "src/" : ""; + + if (variant === "pages-router") { + return findFirstFile(cwd, [`${base}pages/_app.${ext}x`, `${base}pages/_app.${ext}`]); + } + return findFirstFile(cwd, [`${base}app/layout.${ext}x`, `${base}app/layout.${ext}`]); +} + +/** + * Enrich a ProjectContext with Next.js-specific fields: + * variant, layoutPath, middlewareBasename. + */ +export async function enrichNextjsContext(ctx: ProjectContext): Promise { + const ext = ctx.typescript ? "ts" : "js"; + + const [srcAppDir, srcPagesDir, rootAppDir, rootPagesDir] = await Promise.all([ + dirExists(join(ctx.cwd, "src/app")), + dirExists(join(ctx.cwd, "src/pages")), + dirExists(join(ctx.cwd, "app")), + dirExists(join(ctx.cwd, "pages")), + ]); + + ctx.variant = detectNextjsVariant({ + srcDir: ctx.srcDir, + srcAppDir, + srcPagesDir, + rootAppDir, + rootPagesDir, + }); + + ctx.layoutPath = await detectLayoutPath(ctx.cwd, ctx.variant, ctx.srcDir, ext); + + ctx.middlewareBasename = await detectMiddlewareBasename( + ctx.cwd, + ctx.srcDir, + ext, + ctx.deps[ctx.framework.dep], + ); +} From 1f8fc6d7fa4917d0d89e21d3170b8ede505df05e Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 19 Mar 2026 16:49:03 -0300 Subject: [PATCH 11/35] refactor(init): replace scaffold key map with matches() dispatch and version check Replace the Record keyed map with an array using satisfies and matches()-based lookup. Add enrichProjectContext() that delegates to each scaffolder's enrichContext hook. Add minMajorVersion guard that checks the framework dep version before scaffolding. --- .../cli-core/src/commands/init/scaffold.ts | 55 +++++++++++++------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/packages/cli-core/src/commands/init/scaffold.ts b/packages/cli-core/src/commands/init/scaffold.ts index 88b43cbf6..b5056fb7b 100644 --- a/packages/cli-core/src/commands/init/scaffold.ts +++ b/packages/cli-core/src/commands/init/scaffold.ts @@ -6,29 +6,32 @@ import { nuxt } from "./frameworks/nuxt.js"; import { tanstackStart } from "./frameworks/tanstack-start.js"; import { astro } from "./frameworks/astro.js"; import { vue } from "./frameworks/vue.js"; +import { parseMajorVersion } from "./frameworks/helpers.js"; import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./frameworks/types.js"; -const SCAFFOLDS: Record = { - "next:app-router": nextjsApp, - "next:pages-router": nextjsPages, - react: reactVite, - "react-router": reactRouter, - nuxt: nuxt, - "@tanstack/react-start": tanstackStart, - astro: astro, - vue: vue, -}; - -export function getScaffoldKey(ctx: ProjectContext): string { - if (ctx.framework.dep === "next") { - return `next:${ctx.variant ?? "app-router"}`; - } - return ctx.framework.dep; +const SCAFFOLDERS = [ + nextjsApp, + nextjsPages, + reactVite, + reactRouter, + nuxt, + tanstackStart, + astro, + vue, +] satisfies FrameworkScaffold[]; + +/** + * Run the matching scaffolder's enrichContext to populate framework-specific + * fields (variant, layoutPath, middlewareBasename) on the context. + * Must be called before scaffold() or buildAgentPrompt(). + */ +export async function enrichProjectContext(ctx: ProjectContext): Promise { + const scaffolder = SCAFFOLDERS.find((s) => s.dep === ctx.framework.dep); + if (scaffolder?.enrichContext) await scaffolder.enrichContext(ctx); } export async function scaffold(ctx: ProjectContext): Promise { - const key = getScaffoldKey(ctx); - const scaffolder = SCAFFOLDS[key]; + const scaffolder = SCAFFOLDERS.find((s) => s.matches(ctx)); if (!scaffolder) { return { @@ -39,5 +42,21 @@ export async function scaffold(ctx: ProjectContext): Promise { }; } + const { minMajorVersion } = scaffolder; + + if (minMajorVersion !== undefined) { + const version = ctx.deps[scaffolder.dep]; + const major = version ? parseMajorVersion(version) : null; + + if (major !== null && major < minMajorVersion) { + return { + actions: [], + postInstructions: [ + `${ctx.framework.name} v${major} is below the minimum supported version (v${minMajorVersion}+). Visit https://clerk.com/docs/quickstarts for manual setup instructions.`, + ], + }; + } + } + return scaffolder.scaffold(ctx); } From 953baaf82b0cb4d05adab7a46ceb6d76a21db36c Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 19 Mar 2026 16:49:12 -0300 Subject: [PATCH 12/35] refactor(init): add shared helpers and flatten scaffoldNextjsMiddleware Add parseMajorVersion, insertAfterLastImport, wrapBodyWithProvider, and resolveNextjsMiddlewareBasename to helpers. Use proper skip FileAction in scaffoldAuthPage and scaffoldNextjsMiddleware. Flatten nested ifs with early returns and store BunFile reference once. The middleware fallback now resolves from the actual Next.js version in deps. --- .../src/commands/init/frameworks/helpers.ts | 124 +++++++++++------- 1 file changed, 73 insertions(+), 51 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/helpers.ts b/packages/cli-core/src/commands/init/frameworks/helpers.ts index c21c4f98a..8bdbe9342 100644 --- a/packages/cli-core/src/commands/init/frameworks/helpers.ts +++ b/packages/cli-core/src/commands/init/frameworks/helpers.ts @@ -2,6 +2,16 @@ import { join } from "node:path"; import { parseModule } from "magicast"; import type { FileAction } from "./types.js"; +/** + * Parse the major version from a semver-like string. + * Handles: "15.0.0", "^15.0.0", "~15.0.0", ">=15", etc. + * Returns null for non-numeric versions like "latest", "canary", "*". + */ +export function parseMajorVersion(version: string): number | null { + const match = version.match(/(\d+)/); + return match ? parseInt(match[1]!, 10) : null; +} + /** Check if file content already imports from a @clerk/ package. */ export function hasClerkImport(content: string): boolean { return content.includes("@clerk/"); @@ -29,6 +39,31 @@ export function safeAddImport(content: string, source: string, imported: string) } } +/** 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; + return source.slice(0, lineEnd + 1) + snippet + source.slice(lineEnd + 1); +} + +/** Wrap the contents of a `` tag with a provider component (e.g. ``). */ +export function wrapBodyWithProvider(content: string, provider: string): string { + let result = content.replace(/(]*>)(\s*)/, `$1$2<${provider}>\n`); + result = result.replace(/(\s*)(<\/body>)/, `\n$1$2`); + return result; +} + +/** Resolve the middleware basename from a Next.js version string. >=16 uses proxy, <=15 uses middleware. */ +export function resolveNextjsMiddlewareBasename( + nextVersion: string | undefined, +): "proxy" | "middleware" { + if (!nextVersion) return "proxy"; + const major = parseMajorVersion(nextVersion); + if (major === null) return "proxy"; + return major >= 16 ? "proxy" : "middleware"; +} + /** Next.js clerkMiddleware with route protection and matcher config. */ export function nextjsMiddlewareContent(): string { return `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; @@ -77,25 +112,25 @@ export default function SignUpPage() { export function composeWithExistingMiddleware(existing: string): string { const clerkImport = `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";\n`; const routeMatcher = `\nconst isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]);\n`; + const preamble = clerkImport + routeMatcher + "\n"; + + if (!/export\s+default\s+/.test(existing)) { + return preamble + existing + "\n" + nextjsMiddlewareContent(); + } - const hasDefaultExport = /export\s+default\s+/.test(existing); - - if (hasDefaultExport) { - let content = existing.replace( - /export\s+default\s+(?:async\s+)?function\s+(\w+)?/, - "async function existingMiddleware", - ); - content = content.replace( - /export\s+default\s+(?:async\s+)?(\([^)]*\)\s*=>)/, - "const existingMiddleware = async $1", - ); - - return ( - clerkImport + - routeMatcher + - "\n" + - content + - `\nexport default clerkMiddleware(async (auth, request) => { + let content = existing.replace( + /export\s+default\s+(?:async\s+)?function\s+(\w+)?/, + "async function existingMiddleware", + ); + content = content.replace( + /export\s+default\s+(?:async\s+)?(\([^)]*\)\s*=>)/, + "const existingMiddleware = async $1", + ); + + return ( + preamble + + content + + `\nexport default clerkMiddleware(async (auth, request) => { if (!isPublicRoute(request)) { await auth.protect(); } @@ -109,10 +144,7 @@ export const config = { ], }; ` - ); - } - - return clerkImport + routeMatcher + "\n" + existing + "\n" + nextjsMiddlewareContent(); + ); } /** @@ -124,39 +156,35 @@ export async function scaffoldNextjsMiddleware(ctx: { cwd: string; srcDir: boolean; typescript: boolean; - middlewareBasename: "proxy" | "middleware"; + deps?: Record; + middlewareBasename?: "proxy" | "middleware"; }): Promise { const base = ctx.srcDir ? "src/" : ""; const ext = ctx.typescript ? "ts" : "js"; - const path = `${base}${ctx.middlewareBasename}.${ext}`; - const fullPath = join(ctx.cwd, path); - - const file = Bun.file(fullPath); - if (await file.exists()) { - const content = await file.text(); - if (hasClerkImport(content)) { - return { - path, - type: "modify", - content: "", - description: "Create Clerk middleware", - skipReason: "Already has Clerk middleware", - }; - } + const basename = ctx.middlewareBasename ?? resolveNextjsMiddlewareBasename(ctx.deps?.["next"]); + const path = `${base}${basename}.${ext}`; + const file = Bun.file(join(ctx.cwd, path)); + if (!(await file.exists())) { return { path, - type: "modify", - content: composeWithExistingMiddleware(content), - description: "Add clerkMiddleware to existing middleware", + type: "create", + content: nextjsMiddlewareContent(), + description: "Create Clerk middleware with route protection", }; } + const content = await file.text(); + + if (hasClerkImport(content)) { + return { type: "skip", path, skipReason: "Already has Clerk middleware" }; + } + return { path, - type: "create", - content: nextjsMiddlewareContent(), - description: "Create Clerk middleware with route protection", + type: "modify", + content: composeWithExistingMiddleware(content), + description: "Add clerkMiddleware to existing middleware", }; } @@ -181,13 +209,7 @@ export async function scaffoldAuthPage( const capitalizedLabel = capitalize(label); if (await Bun.file(join(cwd, path)).exists()) { - return { - path, - type: "create", - content: "", - description: `Create ${label}`, - skipReason: `${capitalizedLabel} already exists`, - }; + return { type: "skip", path, skipReason: `${capitalizedLabel} already exists` }; } const component = label.includes("sign-in") ? "SignIn" : "SignUp"; From 40b2443bc2c371dfa5d4cf165695e8591e9ae622 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 19 Mar 2026 16:49:21 -0300 Subject: [PATCH 13/35] refactor(init): update all scaffolders with skip actions, dep/matches, and minMajorVersion Each scaffolder now exports dep, matches(), and minMajorVersion. All skip cases use the proper FileAction skip variant. Flatten nested ifs with early returns across scaffoldLayout, scaffoldApp, scaffoldMiddleware, and scaffoldConfig. Use shared wrapBodyWithProvider (nextjs-app, tanstack-start), insertAfterLastImport (react-router, vue), and extract wrapWithClerkProvider in react.ts. Store BunFile references to avoid duplicate creation. --- .../src/commands/init/frameworks/astro.ts | 92 ++++++++++--------- .../commands/init/frameworks/nextjs-app.ts | 61 ++++++------ .../commands/init/frameworks/nextjs-pages.ts | 60 ++++++------ .../src/commands/init/frameworks/nuxt.ts | 32 +++---- .../commands/init/frameworks/react-router.ts | 34 +++---- .../src/commands/init/frameworks/react.ts | 55 +++++------ .../init/frameworks/tanstack-start.ts | 31 +++---- .../src/commands/init/frameworks/vue.ts | 23 ++--- 8 files changed, 189 insertions(+), 199 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/astro.ts b/packages/cli-core/src/commands/init/frameworks/astro.ts index 4d862e209..35493f24c 100644 --- a/packages/cli-core/src/commands/init/frameworks/astro.ts +++ b/packages/cli-core/src/commands/init/frameworks/astro.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import { parseModule } from "magicast"; +import { parseModule, builders } from "magicast"; import { findFirstFile, hasClerkImport, scaffoldAuthPage } from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; @@ -38,7 +38,26 @@ function addClerkImport(content: string): string { } } +function addClerkToIntegrationsViaAst(content: string): string | null { + try { + const mod = parseModule(content); + const defaultExport = mod.exports.default; + if (!defaultExport || typeof defaultExport !== "object") return null; + if (!defaultExport.integrations) defaultExport.integrations = []; + if (!Array.isArray(defaultExport.integrations)) return null; + + defaultExport.integrations.push(builders.raw("clerk()")); + return mod.generate().code; + } catch { + return null; + } +} + function addClerkToIntegrations(content: string): string { + const astResult = addClerkToIntegrationsViaAst(content); + if (astResult) return astResult; + + // String fallback for non-standard config shapes if (content.includes("integrations:")) { return content.replace(/(integrations:\s*\[)/, "$1clerk(), "); } @@ -52,24 +71,25 @@ function addClerkIntegration(content: string): string { return addClerkToIntegrations(addClerkImport(content)); } -async function scaffoldConfig(ctx: ProjectContext): Promise { +async function scaffoldConfig(ctx: ProjectContext): Promise { const configPath = await findFirstFile(ctx.cwd, [ "astro.config.mjs", "astro.config.ts", "astro.config.js", ]); - if (!configPath) return null; + + if (!configPath) { + return { + type: "skip", + path: "astro.config.mjs", + skipReason: "No Astro config file found — create one and add clerk() integration manually", + }; + } const content = await Bun.file(join(ctx.cwd, configPath)).text(); if (content.includes("@clerk/astro")) { - return { - path: configPath, - type: "modify", - content, - description: "Add clerk() integration", - skipReason: "Already has @clerk/astro integration", - }; + return { type: "skip", path: configPath, skipReason: "Already has @clerk/astro integration" }; } const newContent = addClerkIntegration(content); @@ -85,55 +105,43 @@ async function scaffoldConfig(ctx: ProjectContext): Promise { async function scaffoldMiddleware(ctx: ProjectContext): Promise { const ext = ctx.typescript ? "ts" : "js"; const path = `src/middleware.${ext}`; - const fullPath = join(ctx.cwd, path); - - const file = Bun.file(fullPath); - if (await file.exists()) { - const content = await file.text(); - if (hasClerkImport(content)) { - return { - path, - type: "modify", - content: "", - description: "Create Clerk middleware", - skipReason: "Already has Clerk middleware", - }; - } - - // Existing non-Clerk middleware — skip to avoid overwriting user code + const file = Bun.file(join(ctx.cwd, path)); + + if (!(await file.exists())) { return { path, - type: "modify", - content: "", - description: "Create Clerk middleware", - skipReason: "Existing middleware found — add clerkMiddleware() manually", + type: "create", + content: middlewareContent(), + description: "Create Clerk middleware with onRequest export", }; } + const content = await file.text(); + + if (hasClerkImport(content)) { + return { type: "skip", path, skipReason: "Already has Clerk middleware" }; + } + + // Existing non-Clerk middleware — skip to avoid overwriting user code return { + type: "skip", path, - type: "create", - content: middlewareContent(), - description: "Create Clerk middleware with onRequest export", + skipReason: "Existing middleware found — add clerkMiddleware() manually", }; } export const astro: FrameworkScaffold = { name: "Astro", + dep: "astro", + minMajorVersion: 3, + + matches: (ctx) => ctx.framework.dep === "astro", async scaffold(ctx: ProjectContext): Promise { const actions: FileAction[] = []; const postInstructions: string[] = []; - const configAction = await scaffoldConfig(ctx); - if (configAction) { - actions.push(configAction); - } else { - postInstructions.push( - "Add `import clerk from '@clerk/astro'` and `clerk()` to integrations in astro.config.mjs. See: https://clerk.com/docs/quickstarts/astro", - ); - } - + actions.push(await scaffoldConfig(ctx)); actions.push(await scaffoldMiddleware(ctx)); actions.push( await scaffoldAuthPage( diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts index c85b21e22..0ae467602 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts @@ -6,47 +6,49 @@ import { safeAddImport, scaffoldAuthPage, scaffoldNextjsMiddleware, + wrapBodyWithProvider, } from "./helpers.js"; +import { enrichNextjsContext } from "./nextjs-context.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; -async function scaffoldLayout(ctx: ProjectContext): Promise { - if (!ctx.layoutPath) return null; +async function scaffoldLayout(ctx: ProjectContext): Promise { + const base = ctx.srcDir ? "src/" : ""; + const jsx = ctx.typescript ? "tsx" : "jsx"; + const expectedPath = ctx.layoutPath ?? `${base}app/layout.${jsx}`; + + if (!ctx.layoutPath) { + return { type: "skip", path: expectedPath, skipReason: "Layout file not found" }; + } const fullPath = join(ctx.cwd, ctx.layoutPath); const file = Bun.file(fullPath); - if (!(await file.exists())) return null; + if (!(await file.exists())) { + return { type: "skip", path: ctx.layoutPath, skipReason: "Layout file not found" }; + } const content = await file.text(); if (content.includes("ClerkProvider")) { - return { - path: ctx.layoutPath, - type: "modify", - content, - description: "Add ClerkProvider to layout", - skipReason: "Already has ClerkProvider", - }; + return { type: "skip", path: ctx.layoutPath, skipReason: "Already has ClerkProvider" }; } let newContent = safeAddImport(content, "@clerk/nextjs", "ClerkProvider"); - if (newContent.includes("]*>)(\s*)/, "$1$2\n"); - newContent = newContent.replace(/(\s*)(<\/body>)/, "\n$1$2"); - } else { - return { - path: ctx.layoutPath, - type: "modify", - content: newContent, - description: "Add ClerkProvider import (manual wrapping needed)", - }; + // TODO: Consider using AST (e.g. ts-morph) for JSX manipulation to enforce + // modifying the default export. Magicast does not support JSX/TSX. + const hasBody = newContent.includes(" ctx.framework.dep === "next" && ctx.variant !== "pages-router", async scaffold(ctx: ProjectContext): Promise { const actions: FileAction[] = []; const postInstructions: string[] = []; actions.push(await scaffoldNextjsMiddleware(ctx)); - - const layoutAction = await scaffoldLayout(ctx); - if (layoutAction) { - actions.push(layoutAction); - } else { - postInstructions.push( - "Wrap your root layout with from @clerk/nextjs. See: https://clerk.com/docs/quickstarts/nextjs", - ); - } + actions.push(await scaffoldLayout(ctx)); actions.push( await scaffoldAuthPage(ctx.cwd, signInPath(ctx), nextjsSignInPageContent(), "sign-in page"), diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts index 7cdbd6484..20d42314a 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts @@ -7,6 +7,7 @@ import { scaffoldAuthPage, scaffoldNextjsMiddleware, } from "./helpers.js"; +import { enrichNextjsContext } from "./nextjs-context.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; function appWrapperContent(typescript: boolean): string { @@ -40,43 +41,37 @@ async function scaffoldApp(ctx: ProjectContext): Promise { const base = ctx.srcDir ? "src/" : ""; const ext = ctx.typescript ? "tsx" : "jsx"; const path = `${base}pages/_app.${ext}`; - const fullPath = join(ctx.cwd, path); - - const file = Bun.file(fullPath); - if (await file.exists()) { - const content = await file.text(); - if (content.includes("ClerkProvider")) { - return { - path, - type: "modify", - content, - description: "Wrap _app with ClerkProvider", - skipReason: "Already has ClerkProvider", - }; - } - - let newContent = safeAddImport(content, "@clerk/nextjs", "ClerkProvider"); - - if (newContent.includes(")/, - "\n $1\n ", - ); - } + const file = Bun.file(join(ctx.cwd, path)); + if (!(await file.exists())) { return { path, - type: "modify", - content: newContent, - description: "Add ClerkProvider import and wrap Component", + type: "create", + content: appWrapperContent(ctx.typescript), + description: "Create _app with ClerkProvider wrapper", }; } + const content = await file.text(); + + if (content.includes("ClerkProvider")) { + return { type: "skip", path, skipReason: "Already has ClerkProvider" }; + } + + let newContent = safeAddImport(content, "@clerk/nextjs", "ClerkProvider"); + + if (newContent.includes(")/, + "\n $1\n ", + ); + } + return { path, - type: "create", - content: appWrapperContent(ctx.typescript), - description: "Create _app with ClerkProvider wrapper", + type: "modify", + content: newContent, + description: "Add ClerkProvider import and wrap Component", }; } @@ -94,6 +89,13 @@ function signUpPath(ctx: ProjectContext): string { export const nextjsPages: FrameworkScaffold = { name: "Next.js (Pages Router)", + dep: "next", + variant: "pages-router", + minMajorVersion: 13, + + enrichContext: enrichNextjsContext, + + matches: (ctx) => ctx.framework.dep === "next" && ctx.variant === "pages-router", async scaffold(ctx: ProjectContext): Promise { const actions: FileAction[] = []; diff --git a/packages/cli-core/src/commands/init/frameworks/nuxt.ts b/packages/cli-core/src/commands/init/frameworks/nuxt.ts index 82d085649..06ded419d 100644 --- a/packages/cli-core/src/commands/init/frameworks/nuxt.ts +++ b/packages/cli-core/src/commands/init/frameworks/nuxt.ts @@ -34,20 +34,21 @@ function addNuxtModule(content: string): string { } } -async function scaffoldConfig(ctx: ProjectContext): Promise { +async function scaffoldConfig(ctx: ProjectContext): Promise { const configPath = await findFirstFile(ctx.cwd, ["nuxt.config.ts", "nuxt.config.js"]); - if (!configPath) return null; + + if (!configPath) { + return { + type: "skip", + path: "nuxt.config.ts", + skipReason: "No Nuxt config file found — create one and add @clerk/nuxt to modules", + }; + } const content = await Bun.file(join(ctx.cwd, configPath)).text(); if (content.includes("@clerk/nuxt")) { - return { - path: configPath, - type: "modify", - content, - description: "Add @clerk/nuxt to modules", - skipReason: "Already has @clerk/nuxt module", - }; + return { type: "skip", path: configPath, skipReason: "Already has @clerk/nuxt module" }; } const newContent = addNuxtModule(content); @@ -62,19 +63,16 @@ async function scaffoldConfig(ctx: ProjectContext): Promise { export const nuxt: FrameworkScaffold = { name: "Nuxt", + dep: "nuxt", + minMajorVersion: 3, + + matches: (ctx) => ctx.framework.dep === "nuxt", async scaffold(ctx: ProjectContext): Promise { const actions: FileAction[] = []; const postInstructions: string[] = []; - const configAction = await scaffoldConfig(ctx); - if (configAction) { - actions.push(configAction); - } else { - postInstructions.push( - "Add '@clerk/nuxt' to the modules array in your nuxt.config.ts. See: https://clerk.com/docs/quickstarts/nuxt", - ); - } + actions.push(await scaffoldConfig(ctx)); actions.push( await scaffoldAuthPage(ctx.cwd, "pages/sign-in.vue", signInPageContent(), "sign-in page"), diff --git a/packages/cli-core/src/commands/init/frameworks/react-router.ts b/packages/cli-core/src/commands/init/frameworks/react-router.ts index a90451f09..22b25cada 100644 --- a/packages/cli-core/src/commands/init/frameworks/react-router.ts +++ b/packages/cli-core/src/commands/init/frameworks/react-router.ts @@ -1,6 +1,11 @@ import { join } from "node:path"; import { parseModule } from "magicast"; -import { findFirstFile, safeAddImport, scaffoldAuthPage } from "./helpers.js"; +import { + findFirstFile, + insertAfterLastImport, + safeAddImport, + scaffoldAuthPage, +} from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; function signInRouteContent(): string { @@ -29,13 +34,6 @@ function addServerImports(source: string): string { return result; } -function insertAfterLastImport(source: string, snippet: string): string { - const lastImportIdx = source.lastIndexOf("import "); - const lineEnd = source.indexOf("\n", lastImportIdx); - if (lineEnd === -1) return source; - return source.slice(0, lineEnd + 1) + snippet + source.slice(lineEnd + 1); -} - function addMiddlewareExport(source: string, typescript: boolean): string { if (source.includes("export const middleware")) return source; const typeAnnotation = typescript ? ": Route.MiddlewareFunction[]" : ""; @@ -77,13 +75,7 @@ async function scaffoldRoot(ctx: ProjectContext): Promise { const content = await Bun.file(join(ctx.cwd, rootPath)).text(); if (content.includes("ClerkProvider")) { - return { - path: rootPath, - type: "modify", - content, - description: "Add ClerkProvider to root", - skipReason: "Already has ClerkProvider", - }; + return { type: "skip", path: rootPath, skipReason: "Already has ClerkProvider" }; } let result = addServerImports(content); @@ -130,13 +122,7 @@ async function scaffoldConfig(ctx: ProjectContext): Promise { const content = await Bun.file(join(ctx.cwd, configPath)).text(); if (content.includes("v8_middleware")) { - return { - path: configPath, - type: "modify", - content, - description: "Enable v8_middleware future flag", - skipReason: "Already has v8_middleware flag", - }; + return { type: "skip", path: configPath, skipReason: "Already has v8_middleware flag" }; } const newContent = enableV8Middleware(content); @@ -151,6 +137,10 @@ async function scaffoldConfig(ctx: ProjectContext): Promise { export const reactRouter: FrameworkScaffold = { name: "React Router", + dep: "react-router", + minMajorVersion: 7, + + matches: (ctx) => ctx.framework.dep === "react-router", async scaffold(ctx: ProjectContext): Promise { const actions: FileAction[] = []; diff --git a/packages/cli-core/src/commands/init/frameworks/react.ts b/packages/cli-core/src/commands/init/frameworks/react.ts index f320382f2..a6702e763 100644 --- a/packages/cli-core/src/commands/init/frameworks/react.ts +++ b/packages/cli-core/src/commands/init/frameworks/react.ts @@ -4,11 +4,28 @@ import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from async function findEntryFile(ctx: ProjectContext): Promise { const base = ctx.srcDir ? "src/" : ""; - const ext = ctx.typescript ? "tsx" : "jsx"; - return findFirstFile(ctx.cwd, [ - `${base}main.${ext}`, - `${base}main.${ctx.typescript ? "ts" : "js"}`, - ]); + const jsx = ctx.typescript ? "tsx" : "jsx"; + const ext = ctx.typescript ? "ts" : "js"; + return findFirstFile(ctx.cwd, [`${base}main.${jsx}`, `${base}main.${ext}`]); +} + +function wrapWithClerkProvider(content: string): string { + if (content.includes("")) { + let result = content.replace( + /()(\s*)/, + '$1$2\n', + ); + return result.replace(/(\s*)(<\/StrictMode>)/, "\n$1$2"); + } + + if (content.includes(")/, + '\n $1\n ', + ); + } + + return content; } async function scaffoldEntry(ctx: ProjectContext): Promise { @@ -18,29 +35,11 @@ async function scaffoldEntry(ctx: ProjectContext): Promise { const content = await Bun.file(join(ctx.cwd, entryPath)).text(); if (content.includes("ClerkProvider")) { - return { - path: entryPath, - type: "modify", - content, - description: "Add ClerkProvider to entry", - skipReason: "Already has ClerkProvider", - }; + return { type: "skip", path: entryPath, skipReason: "Already has ClerkProvider" }; } - let newContent = safeAddImport(content, "@clerk/react", "ClerkProvider"); - - if (newContent.includes("")) { - newContent = newContent.replace( - /()(\s*)/, - '$1$2\n', - ); - newContent = newContent.replace(/(\s*)(<\/StrictMode>)/, "\n$1$2"); - } else if (newContent.includes(")/, - '\n $1\n ', - ); - } + const imported = safeAddImport(content, "@clerk/react", "ClerkProvider"); + const newContent = wrapWithClerkProvider(imported); return { path: entryPath, @@ -52,6 +51,10 @@ async function scaffoldEntry(ctx: ProjectContext): Promise { export const reactVite: FrameworkScaffold = { name: "React (Vite)", + dep: "react", + minMajorVersion: 18, + + matches: (ctx) => ctx.framework.dep === "react", async scaffold(ctx: ProjectContext): Promise { const actions: FileAction[] = []; diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts index e2509f5f2..125959a8c 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -1,5 +1,11 @@ import { join } from "node:path"; -import { hasClerkImport, safeAddImport, findFirstFile, scaffoldAuthPage } from "./helpers.js"; +import { + findFirstFile, + hasClerkImport, + safeAddImport, + scaffoldAuthPage, + wrapBodyWithProvider, +} from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; function signInRouteContent(): string { @@ -41,13 +47,7 @@ async function scaffoldStartServer(ctx: ProjectContext): Promise { const content = await Bun.file(join(ctx.cwd, rootPath)).text(); if (content.includes("ClerkProvider")) { - return { - path: rootPath, - type: "modify", - content, - description: "Add ClerkProvider to root route", - skipReason: "Already has ClerkProvider", - }; + return { type: "skip", path: rootPath, skipReason: "Already has ClerkProvider" }; } let newContent = safeAddImport(content, "@clerk/tanstack-react-start", "ClerkProvider"); - // Wrap children or body content with if (newContent.includes("]*>)(\s*)/, "$1$2\n"); - newContent = newContent.replace(/(\s*)(<\/body>)/, "\n$1$2"); + newContent = wrapBodyWithProvider(newContent, "ClerkProvider"); } return { @@ -106,6 +98,9 @@ async function scaffoldRoot(ctx: ProjectContext): Promise { export const tanstackStart: FrameworkScaffold = { name: "TanStack Start", + dep: "@tanstack/react-start", + + matches: (ctx) => ctx.framework.dep === "@tanstack/react-start", async scaffold(ctx: ProjectContext): Promise { const actions: FileAction[] = []; diff --git a/packages/cli-core/src/commands/init/frameworks/vue.ts b/packages/cli-core/src/commands/init/frameworks/vue.ts index b2e6c9d7f..7f35d9af4 100644 --- a/packages/cli-core/src/commands/init/frameworks/vue.ts +++ b/packages/cli-core/src/commands/init/frameworks/vue.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import { findFirstFile, safeAddImport } from "./helpers.js"; +import { findFirstFile, insertAfterLastImport, safeAddImport } from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; async function findEntryFile(ctx: ProjectContext): Promise { @@ -11,17 +11,12 @@ function addClerkPluginSetup(source: string): string { const keyBlock = `\nconst PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;\n\nif (!PUBLISHABLE_KEY) {\n throw new Error("Add your Clerk Publishable Key to the .env file");\n}\n`; // Insert app.use(clerkPlugin, ...) before app.mount() - let result = source.replace( + const result = source.replace( /((\w+)\.mount\s*\()/, `$2.use(clerkPlugin, { publishableKey: PUBLISHABLE_KEY });\n$1`, ); - // Insert key block after last import - const lastImportIdx = result.lastIndexOf("import "); - const lineEnd = result.indexOf("\n", lastImportIdx); - if (lineEnd === -1) return result; - - return result.slice(0, lineEnd + 1) + keyBlock + result.slice(lineEnd + 1); + return insertAfterLastImport(result, keyBlock); } async function scaffoldEntry(ctx: ProjectContext): Promise { @@ -31,13 +26,7 @@ async function scaffoldEntry(ctx: ProjectContext): Promise { const content = await Bun.file(join(ctx.cwd, entryPath)).text(); if (content.includes("clerkPlugin") || content.includes("@clerk/vue")) { - return { - path: entryPath, - type: "modify", - content, - description: "Add clerkPlugin to Vue app", - skipReason: "Already has Clerk plugin", - }; + return { type: "skip", path: entryPath, skipReason: "Already has Clerk plugin" }; } let newContent = safeAddImport(content, "@clerk/vue", "clerkPlugin"); @@ -57,6 +46,10 @@ async function scaffoldEntry(ctx: ProjectContext): Promise { export const vue: FrameworkScaffold = { name: "Vue", + dep: "vue", + minMajorVersion: 3, + + matches: (ctx) => ctx.framework.dep === "vue", async scaffold(ctx: ProjectContext): Promise { const actions: FileAction[] = []; From 3a68466f11dea45aab272dd76ab3c0c1bbdcb564 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 19 Mar 2026 16:49:30 -0300 Subject: [PATCH 14/35] refactor(init): remove findings cap, precompile regexes, and unexport internal types Remove MAX_FINDINGS so no context is lost. Convert IGNORE_DIRS to a Set for O(1) lookups. Precompile CODE_SCANS regexes once at module level. Inline matchesFramework guard. Convert AuthLibraryScan and CodeScan from exported interfaces to local types since they are not imported elsewhere. --- packages/cli-core/src/commands/init/scan.ts | 42 +++++++++------------ 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/packages/cli-core/src/commands/init/scan.ts b/packages/cli-core/src/commands/init/scan.ts index 3ea8fcd5f..27168e0fd 100644 --- a/packages/cli-core/src/commands/init/scan.ts +++ b/packages/cli-core/src/commands/init/scan.ts @@ -5,26 +5,26 @@ import { yellow, dim, cyan } from "../../lib/color.js"; // Types // --------------------------------------------------------------------------- -export interface AuthLibraryScan { +type AuthLibraryScan = { packages: string[]; name: string; docsUrl: string; -} +}; -export interface CodeScan { +type CodeScan = { pattern: string; flags?: string; message: string; docsUrl?: string; frameworks?: string[]; -} +}; -export interface ScanFinding { +export type ScanFinding = { file: string; line: number; message: string; docsUrl?: string; -} +}; // --------------------------------------------------------------------------- // Pre-scaffold: auth library detection @@ -129,31 +129,29 @@ const CODE_SCANS: CodeScan[] = [ }, ]; -const IGNORE_DIRS = ["node_modules", ".next", "dist", ".git", "build", ".output", ".nuxt"]; +const IGNORE_DIRS = new Set(["node_modules", ".next", "dist", ".git", "build", ".output", ".nuxt"]); -const MAX_FINDINGS = 10; +// Precompile regexes once instead of per-file +const COMPILED_CODE_SCANS = CODE_SCANS.map((scan) => ({ + ...scan, + regex: new RegExp(scan.pattern, scan.flags ?? "m"), +})); function findLineNumber(content: string, matchIndex: number): number { return content.slice(0, matchIndex).split("\n").length; } -function matchesFramework(scan: CodeScan, frameworkDep: string): boolean { - if (!scan.frameworks) return true; - return scan.frameworks.includes(frameworkDep); -} - function isIgnored(relPath: string): boolean { - return relPath.split("/").some((seg) => IGNORE_DIRS.includes(seg)); + return relPath.split("/").some((seg) => IGNORE_DIRS.has(seg)); } function scanFileContent(content: string, relPath: string, frameworkDep: string): ScanFinding[] { const results: ScanFinding[] = []; - for (const scan of CODE_SCANS) { - if (!matchesFramework(scan, frameworkDep)) continue; + for (const scan of COMPILED_CODE_SCANS) { + if (scan.frameworks && !scan.frameworks.includes(frameworkDep)) continue; - const regex = new RegExp(scan.pattern, scan.flags ?? "m"); - const match = regex.exec(content); + const match = scan.regex.exec(content); if (!match) continue; results.push({ @@ -173,15 +171,9 @@ export async function scanForIssues(cwd: string, frameworkDep: string): Promise< for await (const relPath of glob.scan({ cwd })) { if (isIgnored(relPath)) continue; - if (findings.length >= MAX_FINDINGS) break; const content = await Bun.file(join(cwd, relPath)).text(); - const fileFindings = scanFileContent(content, relPath, frameworkDep); - - for (const finding of fileFindings) { - if (findings.length >= MAX_FINDINGS) break; - findings.push(finding); - } + findings.push(...scanFileContent(content, relPath, frameworkDep)); } return findings; From d484e38b08646e8b57f8ece7592d69bc12f62cc6 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 19 Mar 2026 16:49:38 -0300 Subject: [PATCH 15/35] refactor(init): replace readFileSync with static text imports for compiled binaries Use Bun's `import ... with { type: "text" }` to embed markdown prompt templates at build time. This replaces the runtime readFileSync + import.meta.dir approach that would break in compiled Bun binaries. Remove the template cache since imports are already static. --- .../src/commands/init/prompts/index.ts | 44 ++++++++++++++----- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/cli-core/src/commands/init/prompts/index.ts b/packages/cli-core/src/commands/init/prompts/index.ts index c3ad0de91..5077d6132 100644 --- a/packages/cli-core/src/commands/init/prompts/index.ts +++ b/packages/cli-core/src/commands/init/prompts/index.ts @@ -1,24 +1,46 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import type { ProjectContext } from "../frameworks/types.js"; +// Static text imports — embedded at build time, safe for compiled binaries. +import genericMd from "./generic.md" with { type: "text" }; +import genericFallbackMd from "./generic-fallback.md" with { type: "text" }; +import nextjsAppRouterMd from "./nextjs-app-router.md" with { type: "text" }; +import nextjsPagesRouterMd from "./nextjs-pages-router.md" with { type: "text" }; +import reactMd from "./react.md" with { type: "text" }; +import reactRouterMd from "./react-router.md" with { type: "text" }; +import nuxtMd from "./nuxt.md" with { type: "text" }; +import tanstackStartMd from "./tanstack-start.md" with { type: "text" }; +import astroMd from "./astro.md" with { type: "text" }; +import vueMd from "./vue.md" with { type: "text" }; +import expoMd from "./expo.md" with { type: "text" }; +import expressMd from "./express.md" with { type: "text" }; +import fastifyMd from "./fastify.md" with { type: "text" }; + // --------------------------------------------------------------------------- // Template loading // --------------------------------------------------------------------------- -const PROMPTS_DIR = import.meta.dir; - -const templateCache = new Map(); +const TEMPLATES: Record = { + generic: genericMd, + "generic-fallback": genericFallbackMd, + "nextjs-app-router": nextjsAppRouterMd, + "nextjs-pages-router": nextjsPagesRouterMd, + react: reactMd, + "react-router": reactRouterMd, + nuxt: nuxtMd, + "tanstack-start": tanstackStartMd, + astro: astroMd, + vue: vueMd, + expo: expoMd, + express: expressMd, + fastify: fastifyMd, +}; function loadTemplate(name: string): string { - const cached = templateCache.get(name); - if (cached) return cached; - + const template = TEMPLATES[name]; + if (!template) throw new Error(`Unknown prompt template: ${name}`); // The project formatter escapes underscores in markdown headings (e.g. `_app` → `\_app`). // These templates are output as plain text, so undo that escaping. - const template = readFileSync(join(PROMPTS_DIR, `${name}.md`), "utf-8").replaceAll("\\_", "_"); - templateCache.set(name, template); - return template; + return template.replaceAll("\\_", "_"); } function interpolate(template: string, vars: Record): string { From 811a156ef42ebb0dde523ffab038074c7775ad5c Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 19 Mar 2026 16:49:45 -0300 Subject: [PATCH 16/35] refactor(init): abstract formatter config into data-driven array Replace inline if-checks for prettier/biome with a FormatterConfig type and a FORMATTERS array. The runFormatters loop now iterates the config, making it trivial to add new formatters. --- packages/cli-core/src/commands/init/format.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/cli-core/src/commands/init/format.ts b/packages/cli-core/src/commands/init/format.ts index 762385db1..76d5774df 100644 --- a/packages/cli-core/src/commands/init/format.ts +++ b/packages/cli-core/src/commands/init/format.ts @@ -1,27 +1,31 @@ import { readDeps } from "./context.js"; +type FormatterConfig = { + pkg: string; + args: (files: string[]) => string[]; +}; + +const FORMATTERS: FormatterConfig[] = [ + { + pkg: "prettier", + args: (files) => ["npx", "prettier", "--ignore-unknown", "--write", ...files], + }, + { + pkg: "@biomejs/biome", + args: (files) => ["npx", "@biomejs/biome", "format", "--write", ...files], + }, +]; + export async function runFormatters(cwd: string, files: string[]): Promise { if (files.length === 0) return; const deps = await readDeps(cwd); if (!deps) return; - const hasPrettier = "prettier" in deps; - const hasBiome = "@biomejs/biome" in deps; - - if (!hasPrettier && !hasBiome) return; - - if (hasPrettier) { - const proc = Bun.spawn(["npx", "prettier", "--ignore-unknown", "--write", ...files], { - cwd, - stdout: "ignore", - stderr: "ignore", - }); - await proc.exited; - } + for (const formatter of FORMATTERS) { + if (!(formatter.pkg in deps)) continue; - if (hasBiome) { - const proc = Bun.spawn(["npx", "@biomejs/biome", "format", "--write", ...files], { + const proc = Bun.spawn(formatter.args(files), { cwd, stdout: "ignore", stderr: "ignore", From 3f814d40182709b6bc239b432a9c13203b1ed5fd Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 19 Mar 2026 16:49:53 -0300 Subject: [PATCH 17/35] test(init): update tests for discriminated union, enrichContext, and version check Update context tests to call enrichProjectContext separately from gatherContext and import parseMajorVersion from helpers. Add tests for scaffold version check (below minimum, meets minimum, Next.js 16 proxy). Update nextjs-app tests to assert on the skip type instead of skipReason field. Update scan test to verify all findings are returned without cap. --- .../src/commands/init/context.test.ts | 84 +++++++++++++++++-- .../init/frameworks/nextjs-app.test.ts | 35 ++++---- .../cli-core/src/commands/init/scan.test.ts | 4 +- 3 files changed, 96 insertions(+), 27 deletions(-) diff --git a/packages/cli-core/src/commands/init/context.test.ts b/packages/cli-core/src/commands/init/context.test.ts index 61cef1c18..acdea3bfd 100644 --- a/packages/cli-core/src/commands/init/context.test.ts +++ b/packages/cli-core/src/commands/init/context.test.ts @@ -2,7 +2,9 @@ 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 { gatherContext, parseNextMajorVersion } from "./context.ts"; +import { gatherContext } from "./context.ts"; +import { enrichProjectContext, scaffold } from "./scaffold.ts"; +import { parseMajorVersion } from "./frameworks/helpers.ts"; let tempDir: string; @@ -38,6 +40,7 @@ test("detects Next.js with app-router variant", async () => { await Bun.write(join(tempDir, "tsconfig.json"), "{}"); const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); expect(ctx).not.toBeNull(); expect(ctx!.framework.dep).toBe("next"); @@ -59,6 +62,7 @@ test("detects Next.js with pages-router variant", async () => { await Bun.write(join(tempDir, "tsconfig.json"), "{}"); const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); expect(ctx).not.toBeNull(); expect(ctx!.variant).toBe("pages-router"); @@ -75,6 +79,7 @@ test("detects src/ directory convention", async () => { await Bun.write(join(tempDir, "tsconfig.json"), "{}"); const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); expect(ctx).not.toBeNull(); expect(ctx!.srcDir).toBe(true); @@ -90,6 +95,7 @@ test("detects JavaScript projects (no tsconfig)", async () => { await Bun.write(join(tempDir, "app/layout.jsx"), "{children}"); const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); expect(ctx).not.toBeNull(); expect(ctx!.typescript).toBe(false); @@ -167,6 +173,7 @@ test("defaults to app-router when neither app/ nor pages/ exists", async () => { ); const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); expect(ctx!.variant).toBe("app-router"); expect(ctx!.layoutPath).toBeNull(); @@ -180,6 +187,7 @@ test("uses proxy.ts for Next.js 16+", async () => { await mkdir(join(tempDir, "app"), { recursive: true }); const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); expect(ctx!.middlewareBasename).toBe("proxy"); }); @@ -192,6 +200,7 @@ test("uses middleware.ts for Next.js 15", async () => { await mkdir(join(tempDir, "app"), { recursive: true }); const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); expect(ctx!.middlewareBasename).toBe("middleware"); }); @@ -204,6 +213,7 @@ test("uses middleware.ts for Next.js with caret range ≤15", async () => { await mkdir(join(tempDir, "app"), { recursive: true }); const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); expect(ctx!.middlewareBasename).toBe("middleware"); }); @@ -216,6 +226,7 @@ test("uses proxy.ts for Next.js with caret range ≥16", async () => { await mkdir(join(tempDir, "app"), { recursive: true }); const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); expect(ctx!.middlewareBasename).toBe("proxy"); }); @@ -230,6 +241,7 @@ test("prefers existing proxy.ts over version detection", async () => { await Bun.write(join(tempDir, "proxy.ts"), "export default function() {}"); const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); // Even though version is 15 (would normally pick middleware), proxy.ts exists expect(ctx!.middlewareBasename).toBe("proxy"); @@ -245,17 +257,71 @@ test("prefers existing middleware.ts over version detection", async () => { await Bun.write(join(tempDir, "middleware.ts"), "export default function() {}"); const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); // Even though version is 16 (would normally pick proxy), middleware.ts exists expect(ctx!.middlewareBasename).toBe("middleware"); }); -test("parseNextMajorVersion handles various formats", () => { - expect(parseNextMajorVersion("15.0.0")).toBe(15); - expect(parseNextMajorVersion("^16.1.0")).toBe(16); - expect(parseNextMajorVersion("~14.2.3")).toBe(14); - expect(parseNextMajorVersion(">=16")).toBe(16); - expect(parseNextMajorVersion("latest")).toBeNull(); - expect(parseNextMajorVersion("*")).toBeNull(); - expect(parseNextMajorVersion("canary")).toBeNull(); +test("parseMajorVersion handles various formats", () => { + expect(parseMajorVersion("15.0.0")).toBe(15); + expect(parseMajorVersion("^16.1.0")).toBe(16); + expect(parseMajorVersion("~14.2.3")).toBe(14); + expect(parseMajorVersion(">=16")).toBe(16); + expect(parseMajorVersion("latest")).toBeNull(); + expect(parseMajorVersion("*")).toBeNull(); + expect(parseMajorVersion("canary")).toBeNull(); +}); + +test("scaffold skips when framework version is below minimum", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "12.0.0", react: "18.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + + const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); + + const plan = await scaffold(ctx!); + + expect(plan.actions).toHaveLength(0); + expect(plan.postInstructions[0]).toContain("below the minimum supported version"); +}); + +test("scaffold proceeds when framework version meets minimum", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); + + const plan = await scaffold(ctx!); + + expect(plan.actions.length).toBeGreaterThan(0); +}); + +test("scaffold proceeds for Next.js 16 and uses proxy.ts", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "16.0.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); + + expect(ctx!.middlewareBasename).toBe("proxy"); + + const plan = await scaffold(ctx!); + + expect(plan.actions.length).toBeGreaterThan(0); + expect(plan.actions[0]!.path).toBe("proxy.ts"); }); diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts index b01dfd3df..b6224ebc2 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts @@ -58,25 +58,19 @@ test("scaffolds all 4 files for a fresh Next.js App Router project", async () => // Middleware expect(plan.actions[0]!.path).toBe("middleware.ts"); expect(plan.actions[0]!.type).toBe("create"); - expect(plan.actions[0]!.content).toContain("clerkMiddleware"); - expect(plan.actions[0]!.content).toContain("createRouteMatcher"); - expect(plan.actions[0]!.skipReason).toBeUndefined(); + expect(plan.actions[0]!.type).not.toBe("skip"); // Layout expect(plan.actions[1]!.path).toBe("app/layout.tsx"); expect(plan.actions[1]!.type).toBe("modify"); - expect(plan.actions[1]!.content).toContain("ClerkProvider"); - expect(plan.actions[1]!.content).toContain("@clerk/nextjs"); // Sign-in expect(plan.actions[2]!.path).toBe("app/sign-in/[[...sign-in]]/page.tsx"); expect(plan.actions[2]!.type).toBe("create"); - expect(plan.actions[2]!.content).toContain(""); // Sign-up expect(plan.actions[3]!.path).toBe("app/sign-up/[[...sign-up]]/page.tsx"); expect(plan.actions[3]!.type).toBe("create"); - expect(plan.actions[3]!.content).toContain(""); }); test("skips middleware when already has Clerk", async () => { @@ -89,7 +83,10 @@ test("skips middleware when already has Clerk", async () => { const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.actions[0]!.skipReason).toBe("Already has Clerk middleware"); + expect(plan.actions[0]).toMatchObject({ + type: "skip", + skipReason: "Already has Clerk middleware", + }); }); test("skips layout when already has ClerkProvider", async () => { @@ -101,7 +98,10 @@ test("skips layout when already has ClerkProvider", async () => { const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.actions[1]!.skipReason).toBe("Already has ClerkProvider"); + expect(plan.actions[1]).toMatchObject({ + type: "skip", + skipReason: "Already has ClerkProvider", + }); }); test("skips sign-in page when it already exists", async () => { @@ -115,7 +115,10 @@ test("skips sign-in page when it already exists", async () => { const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.actions[2]!.skipReason).toBe("Sign-in page already exists"); + expect(plan.actions[2]).toMatchObject({ + type: "skip", + skipReason: "Sign-in page already exists", + }); }); test("uses src/ paths when srcDir is true", async () => { @@ -153,10 +156,13 @@ test("adds post-instructions for sign-in/sign-up URLs", async () => { expect(plan.postInstructions.some((i) => i.includes("NEXT_PUBLIC_CLERK_SIGN_IN_URL"))).toBe(true); }); -test("adds post-instruction when no layout found", async () => { +test("returns skip action when no layout found", async () => { const plan = await nextjsApp.scaffold(makeCtx({ layoutPath: null })); - expect(plan.postInstructions.some((i) => i.includes("ClerkProvider"))).toBe(true); + expect(plan.actions[1]).toMatchObject({ + type: "skip", + skipReason: "Layout file not found", + }); }); test("composes with existing non-Clerk middleware", async () => { @@ -174,9 +180,7 @@ export default function middleware(request) { const plan = await nextjsApp.scaffold(makeCtx()); expect(plan.actions[0]!.type).toBe("modify"); - expect(plan.actions[0]!.content).toContain("clerkMiddleware"); - expect(plan.actions[0]!.content).toContain("existingMiddleware"); - expect(plan.actions[0]!.skipReason).toBeUndefined(); + expect(plan.actions[0]!.type).not.toBe("skip"); }); test("uses proxy.ts when middlewareBasename is proxy", async () => { @@ -186,7 +190,6 @@ test("uses proxy.ts when middlewareBasename is proxy", async () => { const plan = await nextjsApp.scaffold(makeCtx({ middlewareBasename: "proxy" })); expect(plan.actions[0]!.path).toBe("proxy.ts"); - expect(plan.actions[0]!.content).toContain("clerkMiddleware"); }); test("uses src/proxy.ts when srcDir and middlewareBasename is proxy", async () => { diff --git a/packages/cli-core/src/commands/init/scan.test.ts b/packages/cli-core/src/commands/init/scan.test.ts index 0c75a6a16..a4129b6d7 100644 --- a/packages/cli-core/src/commands/init/scan.test.ts +++ b/packages/cli-core/src/commands/init/scan.test.ts @@ -201,7 +201,7 @@ describe("scanForIssues", () => { expect(findings).toEqual([]); }); - test("caps findings at 10", async () => { + test("returns all findings without a cap", async () => { await mkdir(join(tempDir, "src"), { recursive: true }); // Create 12 files with hardcoded keys for (let i = 0; i < 12; i++) { @@ -209,7 +209,7 @@ describe("scanForIssues", () => { } const findings = await scanForIssues(tempDir, "next"); - expect(findings.length).toBeLessThanOrEqual(10); + expect(findings.length).toBe(12); }); test("ignores node_modules", async () => { From 55c848302010629a203a4cbdc119db885276aa39 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 20 Mar 2026 17:43:02 -0300 Subject: [PATCH 18/35] chore: update bun.lock with magicast dependency --- bun.lock | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bun.lock b/bun.lock index 2a043c81b..4df2e1b51 100644 --- a/bun.lock +++ b/bun.lock @@ -29,13 +29,22 @@ "@napi-rs/keyring": "^1.2.0", "commander": "^14.0.3", "env-paths": "^4.0.0", + "magicast": "^0.5.2", "yaml": "^2.8.2", }, }, }, "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.0", "", { "dependencies": { "@changesets/config": "^3.1.3", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="], @@ -308,6 +317,8 @@ "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], + "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -374,6 +385,8 @@ "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], From 628343c3228ff1d1963d7b08d51ee8a043767b1d Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 20 Mar 2026 18:54:17 -0300 Subject: [PATCH 19/35] refactor(init): extract shared auth and config scaffolding helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add jsxAuthPageContent with type-safe JsxClerkPackage union, scaffoldConfigFile generic for the find→check→modify pattern, authComponentName, and inline capitalize. These shared helpers eliminate duplication across framework scaffolders. --- .../src/commands/init/frameworks/helpers.ts | 197 +++++++++++++----- 1 file changed, 142 insertions(+), 55 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/helpers.ts b/packages/cli-core/src/commands/init/frameworks/helpers.ts index 8bdbe9342..3fe3f5faf 100644 --- a/packages/cli-core/src/commands/init/frameworks/helpers.ts +++ b/packages/cli-core/src/commands/init/frameworks/helpers.ts @@ -1,6 +1,18 @@ import { join } from "node:path"; import { parseModule } from "magicast"; -import type { FileAction } from "./types.js"; +import type { FileAction, ProjectContext } from "./types.js"; + +export type AuthKind = "sign-in" | "sign-up"; +type AuthSurface = "page" | "route"; + +/** Clerk SDK packages that export JSX auth components (SignIn, SignUp). */ +type JsxClerkPackage = "@clerk/nextjs" | "@clerk/react-router"; +type AuthFileSpec = { + path: string; + content: string; + kind: AuthKind; + surface: AuthSurface; +}; /** * Parse the major version from a semver-like string. @@ -17,6 +29,18 @@ export function hasClerkImport(content: string): boolean { return content.includes("@clerk/"); } +export function srcPrefix(ctx: Pick): string { + return ctx.srcDir ? "src/" : ""; +} + +export function scriptExt(ctx: Pick): "ts" | "js" { + return ctx.typescript ? "ts" : "js"; +} + +export function jsxExt(ctx: Pick): "tsx" | "jsx" { + return ctx.typescript ? "tsx" : "jsx"; +} + /** Find the first existing file from a list of candidates relative to cwd. */ export async function findFirstFile(cwd: string, candidates: string[]): Promise { for (const candidate of candidates) { @@ -68,39 +92,50 @@ export function resolveNextjsMiddlewareBasename( export function nextjsMiddlewareContent(): string { return `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; -const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]); +${nextjsPublicRouteMatcher()} + +${nextjsMiddlewareHandler()} + +${nextjsMiddlewareConfig()} +`; +} + +function nextjsPublicRouteMatcher(): string { + return `const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]);`; +} + +function nextjsMiddlewareHandler(returnStatement = ""): string { + const returnLine = returnStatement ? `\n return ${returnStatement};` : ""; -export default clerkMiddleware(async (auth, request) => { + return `export default clerkMiddleware(async (auth, request) => { if (!isPublicRoute(request)) { await auth.protect(); - } -}); + }${returnLine} +});`; +} -export const config = { +function nextjsMiddlewareConfig(): string { + return `export const config = { matcher: [ "/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)", ], -}; -`; +};`; } -/** Next.js sign-in page component. */ -export function nextjsSignInPageContent(): string { - return `import { SignIn } from "@clerk/nextjs"; - -export default function SignInPage() { - return ; -} -`; +export function authComponentName(kind: AuthKind): "SignIn" | "SignUp" { + return kind === "sign-in" ? "SignIn" : "SignUp"; } -/** Next.js sign-up page component. */ -export function nextjsSignUpPageContent(): string { - return `import { SignUp } from "@clerk/nextjs"; +/** Generate a JSX auth page component for a Clerk framework SDK that exports SignIn/SignUp. */ +export function jsxAuthPageContent(kind: AuthKind, clerkPackage: JsxClerkPackage): string { + const component = authComponentName(kind); + const pageName = component === "SignIn" ? "SignInPage" : "SignUpPage"; + + return `import { ${component} } from "${clerkPackage}"; -export default function SignUpPage() { - return ; +export default function ${pageName}() { + return <${component} />; } `; } @@ -109,41 +144,44 @@ export default function SignUpPage() { * Compose Clerk middleware with existing non-Clerk middleware. * Renames the existing default export and wraps it inside clerkMiddleware. */ -export function composeWithExistingMiddleware(existing: string): string { +function renameDefaultMiddlewareExport(existing: string): string | null { + const functionExportPattern = /export\s+default\s+(?:async\s+)?function(?:\s+\w+)?/; + if (functionExportPattern.test(existing)) { + return existing.replace(functionExportPattern, "async function existingMiddleware"); + } + + const arrowExportPattern = /export\s+default\s+(?:async\s+)?(\([^)]*\)\s*=>)/; + if (arrowExportPattern.test(existing)) { + return existing.replace(arrowExportPattern, "const existingMiddleware = async $1"); + } + + return null; +} + +function hasMiddlewareConfigExport(existing: string): boolean { + return /export\s+const\s+config\s*=/.test(existing); +} + +export function composeWithExistingMiddleware(existing: string): string | null { const clerkImport = `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";\n`; - const routeMatcher = `\nconst isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]);\n`; + const routeMatcher = `\n${nextjsPublicRouteMatcher()}\n`; const preamble = clerkImport + routeMatcher + "\n"; + if (hasMiddlewareConfigExport(existing)) { + return null; + } + if (!/export\s+default\s+/.test(existing)) { - return preamble + existing + "\n" + nextjsMiddlewareContent(); + return `${preamble}${existing}\n${nextjsMiddlewareHandler()}\n\n${nextjsMiddlewareConfig()}\n`; } - let content = existing.replace( - /export\s+default\s+(?:async\s+)?function\s+(\w+)?/, - "async function existingMiddleware", - ); - content = content.replace( - /export\s+default\s+(?:async\s+)?(\([^)]*\)\s*=>)/, - "const existingMiddleware = async $1", - ); + const content = renameDefaultMiddlewareExport(existing); + if (!content) return null; return ( preamble + content + - `\nexport default clerkMiddleware(async (auth, request) => { - if (!isPublicRoute(request)) { - await auth.protect(); - } - return existingMiddleware(request); -}); - -export const config = { - matcher: [ - "/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", - "/(api|trpc)(.*)", - ], -}; -` + `\n${nextjsMiddlewareHandler("existingMiddleware(request)")}\n\n${nextjsMiddlewareConfig()}\n` ); } @@ -159,8 +197,8 @@ export async function scaffoldNextjsMiddleware(ctx: { deps?: Record; middlewareBasename?: "proxy" | "middleware"; }): Promise { - const base = ctx.srcDir ? "src/" : ""; - const ext = ctx.typescript ? "ts" : "js"; + const base = srcPrefix(ctx); + const ext = scriptExt(ctx); const basename = ctx.middlewareBasename ?? resolveNextjsMiddlewareBasename(ctx.deps?.["next"]); const path = `${base}${basename}.${ext}`; const file = Bun.file(join(ctx.cwd, path)); @@ -180,10 +218,19 @@ export async function scaffoldNextjsMiddleware(ctx: { return { type: "skip", path, skipReason: "Already has Clerk middleware" }; } + const composedContent = composeWithExistingMiddleware(content); + if (!composedContent) { + return { + type: "skip", + path, + skipReason: "Existing middleware uses an unsupported shape for automatic Clerk composition", + }; + } + return { path, type: "modify", - content: composeWithExistingMiddleware(content), + content: composedContent, description: "Add clerkMiddleware to existing middleware", }; } @@ -192,27 +239,58 @@ export async function scaffoldNextjsMiddleware(ctx: { export const NEXTJS_SIGN_ROUTES_INSTRUCTION = "Add to your .env.local: NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in, NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up, NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/, NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/"; -function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1); +/** + * Generic helper for scaffolding a framework config file. + * Handles the common find → check → modify → return pattern used by Astro, Nuxt, and React Router. + * The generic preserves the return type: when missingAction is a FileAction, the return is FileAction; + * when missingAction is null, the return is FileAction | null. + */ +export async function scaffoldConfigFile( + cwd: string, + options: { + candidates: string[]; + existsCheck: string; + modify: (content: string) => string; + description: string; + existingSkipReason: string; + missingAction: TMissing; + }, +): Promise { + const configPath = await findFirstFile(cwd, options.candidates); + if (!configPath) return options.missingAction; + + const content = await Bun.file(join(cwd, configPath)).text(); + if (content.includes(options.existsCheck)) { + return { type: "skip", path: configPath, skipReason: options.existingSkipReason }; + } + + return { + path: configPath, + type: "modify", + content: options.modify(content), + description: options.description, + }; } /** * Generic helper for scaffolding an auth page (sign-in or sign-up). * Handles the common create-or-skip pattern used by every framework scaffolder. */ -export async function scaffoldAuthPage( +export async function scaffoldAuthFile( cwd: string, path: string, content: string, - label: string, + kind: AuthKind, + surface: AuthSurface, ): Promise { - const capitalizedLabel = capitalize(label); + const label = `${kind} ${surface}`; + const capitalizedLabel = `${label[0]!.toUpperCase()}${label.slice(1)}`; if (await Bun.file(join(cwd, path)).exists()) { return { type: "skip", path, skipReason: `${capitalizedLabel} already exists` }; } - const component = label.includes("sign-in") ? "SignIn" : "SignUp"; + const component = authComponentName(kind); return { path, type: "create", @@ -220,3 +298,12 @@ export async function scaffoldAuthPage( description: `Create ${label} with <${component} /> component`, }; } + +export async function scaffoldAuthFiles( + cwd: string, + specs: readonly AuthFileSpec[], +): Promise { + return Promise.all( + specs.map((spec) => scaffoldAuthFile(cwd, spec.path, spec.content, spec.kind, spec.surface)), + ); +} From 6f6e24c4822112256199e549f4b3961e13dd88ea Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 20 Mar 2026 18:54:34 -0300 Subject: [PATCH 20/35] refactor(init): use shared helpers in framework scaffolders Replace duplicated authRouteContent/nextjsAuthPageContent with jsxAuthPageContent, replace per-framework scaffoldConfig with scaffoldConfigFile in astro, nuxt, and react-router. Remove unused imports and add enableV8Middleware documentation. --- .../src/commands/init/frameworks/astro.ts | 113 ++++----- .../commands/init/frameworks/nextjs-app.ts | 62 ++--- .../init/frameworks/nextjs-context.ts | 8 +- .../commands/init/frameworks/nextjs-pages.ts | 59 +++-- .../src/commands/init/frameworks/nuxt.ts | 86 +++---- .../commands/init/frameworks/react-router.ts | 236 +++++++++++------- .../src/commands/init/frameworks/react.ts | 8 +- .../init/frameworks/tanstack-start.ts | 135 ++++++---- .../src/commands/init/frameworks/vue.ts | 4 +- 9 files changed, 390 insertions(+), 321 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/astro.ts b/packages/cli-core/src/commands/init/frameworks/astro.ts index 35493f24c..63cca461c 100644 --- a/packages/cli-core/src/commands/init/frameworks/astro.ts +++ b/packages/cli-core/src/commands/init/frameworks/astro.ts @@ -1,6 +1,12 @@ import { join } from "node:path"; import { parseModule, builders } from "magicast"; -import { findFirstFile, hasClerkImport, scaffoldAuthPage } from "./helpers.js"; +import { + authComponentName, + hasClerkImport, + scaffoldAuthFiles, + scaffoldConfigFile, + scriptExt, +} from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; function middlewareContent(): string { @@ -10,21 +16,14 @@ export const onRequest = clerkMiddleware(); `; } -function signInPageContent(): string { - return `--- -import { SignIn } from "@clerk/astro/components"; ---- - - -`; -} +function authPageContent(kind: "sign-in" | "sign-up"): string { + const component = authComponentName(kind); -function signUpPageContent(): string { return `--- -import { SignUp } from "@clerk/astro/components"; +import { ${component} } from "@clerk/astro/components"; --- - +<${component} /> `; } @@ -71,39 +70,23 @@ function addClerkIntegration(content: string): string { return addClerkToIntegrations(addClerkImport(content)); } -async function scaffoldConfig(ctx: ProjectContext): Promise { - const configPath = await findFirstFile(ctx.cwd, [ - "astro.config.mjs", - "astro.config.ts", - "astro.config.js", - ]); - - if (!configPath) { - return { +function scaffoldConfig(ctx: ProjectContext): Promise { + return scaffoldConfigFile(ctx.cwd, { + candidates: ["astro.config.mjs", "astro.config.ts", "astro.config.js"], + existsCheck: "@clerk/astro", + modify: addClerkIntegration, + description: "Add clerk() to integrations and import", + existingSkipReason: "Already has @clerk/astro integration", + missingAction: { type: "skip", path: "astro.config.mjs", skipReason: "No Astro config file found — create one and add clerk() integration manually", - }; - } - - const content = await Bun.file(join(ctx.cwd, configPath)).text(); - - if (content.includes("@clerk/astro")) { - return { type: "skip", path: configPath, skipReason: "Already has @clerk/astro integration" }; - } - - const newContent = addClerkIntegration(content); - - return { - path: configPath, - type: "modify", - content: newContent, - description: "Add clerk() to integrations and import", - }; + }, + }); } async function scaffoldMiddleware(ctx: ProjectContext): Promise { - const ext = ctx.typescript ? "ts" : "js"; + const ext = scriptExt(ctx); const path = `src/middleware.${ext}`; const file = Bun.file(join(ctx.cwd, path)); @@ -138,32 +121,30 @@ export const astro: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "astro", async scaffold(ctx: ProjectContext): Promise { - const actions: FileAction[] = []; - const postInstructions: string[] = []; - - actions.push(await scaffoldConfig(ctx)); - actions.push(await scaffoldMiddleware(ctx)); - actions.push( - await scaffoldAuthPage( - ctx.cwd, - "src/pages/sign-in.astro", - signInPageContent(), - "sign-in page", - ), - ); - actions.push( - await scaffoldAuthPage( - ctx.cwd, - "src/pages/sign-up.astro", - signUpPageContent(), - "sign-up page", - ), - ); - - postInstructions.push( - "Ensure your Astro config has `output: 'server'` and an SSR adapter (e.g., @astrojs/node)", - ); - - return { actions, postInstructions }; + const [configAction, middlewareAction, authActions] = await Promise.all([ + scaffoldConfig(ctx), + scaffoldMiddleware(ctx), + scaffoldAuthFiles(ctx.cwd, [ + { + path: "src/pages/sign-in.astro", + content: authPageContent("sign-in"), + kind: "sign-in", + surface: "page", + }, + { + path: "src/pages/sign-up.astro", + content: authPageContent("sign-up"), + kind: "sign-up", + surface: "page", + }, + ]), + ]); + + return { + actions: [configAction, middlewareAction, ...authActions], + postInstructions: [ + "Ensure your Astro config has `output: 'server'` and an SSR adapter (e.g., @astrojs/node)", + ], + }; }, }; diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts index 0ae467602..ef0a0a849 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts @@ -1,19 +1,20 @@ import { join } from "node:path"; import { + jsxAuthPageContent, + jsxExt, NEXTJS_SIGN_ROUTES_INSTRUCTION, - nextjsSignInPageContent, - nextjsSignUpPageContent, safeAddImport, - scaffoldAuthPage, + scaffoldAuthFiles, scaffoldNextjsMiddleware, + srcPrefix, wrapBodyWithProvider, } from "./helpers.js"; import { enrichNextjsContext } from "./nextjs-context.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; async function scaffoldLayout(ctx: ProjectContext): Promise { - const base = ctx.srcDir ? "src/" : ""; - const jsx = ctx.typescript ? "tsx" : "jsx"; + const base = srcPrefix(ctx); + const jsx = jsxExt(ctx); const expectedPath = ctx.layoutPath ?? `${base}app/layout.${jsx}`; if (!ctx.layoutPath) { @@ -52,16 +53,25 @@ async function scaffoldLayout(ctx: ProjectContext): Promise { }; } -function signInPath(ctx: ProjectContext): string { - const base = ctx.srcDir ? "src/" : ""; - const ext = ctx.typescript ? "tsx" : "jsx"; - return `${base}app/sign-in/[[...sign-in]]/page.${ext}`; +function authPagePath(ctx: ProjectContext, kind: "sign-in" | "sign-up"): string { + return `${srcPrefix(ctx)}app/${kind}/[[...${kind}]]/page.${jsxExt(ctx)}`; } -function signUpPath(ctx: ProjectContext): string { - const base = ctx.srcDir ? "src/" : ""; - const ext = ctx.typescript ? "tsx" : "jsx"; - return `${base}app/sign-up/[[...sign-up]]/page.${ext}`; +async function scaffoldAuthPages(ctx: ProjectContext): Promise { + return scaffoldAuthFiles(ctx.cwd, [ + { + path: authPagePath(ctx, "sign-in"), + content: jsxAuthPageContent("sign-in", "@clerk/nextjs"), + kind: "sign-in", + surface: "page", + }, + { + path: authPagePath(ctx, "sign-up"), + content: jsxAuthPageContent("sign-up", "@clerk/nextjs"), + kind: "sign-up", + surface: "page", + }, + ]); } export const nextjsApp: FrameworkScaffold = { @@ -75,21 +85,15 @@ export const nextjsApp: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "next" && ctx.variant !== "pages-router", async scaffold(ctx: ProjectContext): Promise { - const actions: FileAction[] = []; - const postInstructions: string[] = []; - - actions.push(await scaffoldNextjsMiddleware(ctx)); - actions.push(await scaffoldLayout(ctx)); - - actions.push( - await scaffoldAuthPage(ctx.cwd, signInPath(ctx), nextjsSignInPageContent(), "sign-in page"), - ); - actions.push( - await scaffoldAuthPage(ctx.cwd, signUpPath(ctx), nextjsSignUpPageContent(), "sign-up page"), - ); - - postInstructions.push(NEXTJS_SIGN_ROUTES_INSTRUCTION); - - return { actions, postInstructions }; + const [middlewareAction, layoutAction, authActions] = await Promise.all([ + scaffoldNextjsMiddleware(ctx), + scaffoldLayout(ctx), + scaffoldAuthPages(ctx), + ]); + + return { + actions: [middlewareAction, layoutAction, ...authActions], + postInstructions: [NEXTJS_SIGN_ROUTES_INSTRUCTION], + }; }, }; diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts index 40f5d5151..82e7748b3 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; import { fileExists, dirExists } from "../context.js"; -import { findFirstFile, resolveNextjsMiddlewareBasename } from "./helpers.js"; +import { findFirstFile, resolveNextjsMiddlewareBasename, scriptExt, srcPrefix } from "./helpers.js"; import type { ProjectContext } from "./types.js"; /** @@ -15,7 +15,7 @@ async function detectMiddlewareBasename( ext: string, nextVersion: string | undefined, ): Promise> { - const base = srcDir ? "src/" : ""; + const base = srcPrefix({ srcDir }); // Existing file takes precedence if (await fileExists(join(cwd, `${base}proxy.${ext}`))) return "proxy"; @@ -46,7 +46,7 @@ async function detectLayoutPath( srcDir: boolean, ext: string, ): Promise { - const base = srcDir ? "src/" : ""; + const base = srcPrefix({ srcDir }); if (variant === "pages-router") { return findFirstFile(cwd, [`${base}pages/_app.${ext}x`, `${base}pages/_app.${ext}`]); @@ -59,7 +59,7 @@ async function detectLayoutPath( * variant, layoutPath, middlewareBasename. */ export async function enrichNextjsContext(ctx: ProjectContext): Promise { - const ext = ctx.typescript ? "ts" : "js"; + const ext = scriptExt(ctx); const [srcAppDir, srcPagesDir, rootAppDir, rootPagesDir] = await Promise.all([ dirExists(join(ctx.cwd, "src/app")), diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts index 20d42314a..12fd58bfc 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts @@ -1,11 +1,12 @@ import { join } from "node:path"; import { + jsxAuthPageContent, + jsxExt, NEXTJS_SIGN_ROUTES_INSTRUCTION, - nextjsSignInPageContent, - nextjsSignUpPageContent, safeAddImport, - scaffoldAuthPage, + scaffoldAuthFiles, scaffoldNextjsMiddleware, + srcPrefix, } from "./helpers.js"; import { enrichNextjsContext } from "./nextjs-context.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; @@ -38,8 +39,8 @@ export default function MyApp({ Component, pageProps }) { } async function scaffoldApp(ctx: ProjectContext): Promise { - const base = ctx.srcDir ? "src/" : ""; - const ext = ctx.typescript ? "tsx" : "jsx"; + const base = srcPrefix(ctx); + const ext = jsxExt(ctx); const path = `${base}pages/_app.${ext}`; const file = Bun.file(join(ctx.cwd, path)); @@ -75,16 +76,25 @@ async function scaffoldApp(ctx: ProjectContext): Promise { }; } -function signInPath(ctx: ProjectContext): string { - const base = ctx.srcDir ? "src/" : ""; - const ext = ctx.typescript ? "tsx" : "jsx"; - return `${base}pages/sign-in/[[...sign-in]].${ext}`; +function authPagePath(ctx: ProjectContext, kind: "sign-in" | "sign-up"): string { + return `${srcPrefix(ctx)}pages/${kind}/[[...${kind}]].${jsxExt(ctx)}`; } -function signUpPath(ctx: ProjectContext): string { - const base = ctx.srcDir ? "src/" : ""; - const ext = ctx.typescript ? "tsx" : "jsx"; - return `${base}pages/sign-up/[[...sign-up]].${ext}`; +async function scaffoldAuthPages(ctx: ProjectContext): Promise { + return scaffoldAuthFiles(ctx.cwd, [ + { + path: authPagePath(ctx, "sign-in"), + content: jsxAuthPageContent("sign-in", "@clerk/nextjs"), + kind: "sign-in", + surface: "page", + }, + { + path: authPagePath(ctx, "sign-up"), + content: jsxAuthPageContent("sign-up", "@clerk/nextjs"), + kind: "sign-up", + surface: "page", + }, + ]); } export const nextjsPages: FrameworkScaffold = { @@ -98,20 +108,15 @@ export const nextjsPages: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "next" && ctx.variant === "pages-router", async scaffold(ctx: ProjectContext): Promise { - const actions: FileAction[] = []; - const postInstructions: string[] = []; + const [middlewareAction, appAction, authActions] = await Promise.all([ + scaffoldNextjsMiddleware(ctx), + scaffoldApp(ctx), + scaffoldAuthPages(ctx), + ]); - actions.push(await scaffoldNextjsMiddleware(ctx)); - actions.push(await scaffoldApp(ctx)); - actions.push( - await scaffoldAuthPage(ctx.cwd, signInPath(ctx), nextjsSignInPageContent(), "sign-in page"), - ); - actions.push( - await scaffoldAuthPage(ctx.cwd, signUpPath(ctx), nextjsSignUpPageContent(), "sign-up page"), - ); - - postInstructions.push(NEXTJS_SIGN_ROUTES_INSTRUCTION); - - return { actions, postInstructions }; + return { + actions: [middlewareAction, appAction, ...authActions], + postInstructions: [NEXTJS_SIGN_ROUTES_INSTRUCTION], + }; }, }; diff --git a/packages/cli-core/src/commands/init/frameworks/nuxt.ts b/packages/cli-core/src/commands/init/frameworks/nuxt.ts index 06ded419d..75753d050 100644 --- a/packages/cli-core/src/commands/init/frameworks/nuxt.ts +++ b/packages/cli-core/src/commands/init/frameworks/nuxt.ts @@ -1,18 +1,11 @@ -import { join } from "node:path"; import { parseModule } from "magicast"; -import { findFirstFile, scaffoldAuthPage } from "./helpers.js"; +import { authComponentName, scaffoldAuthFiles, scaffoldConfigFile } from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; -function signInPageContent(): string { +function authPageContent(kind: "sign-in" | "sign-up"): string { + const component = authComponentName(kind); return ` -`; -} - -function signUpPageContent(): string { - return ` `; } @@ -34,31 +27,19 @@ function addNuxtModule(content: string): string { } } -async function scaffoldConfig(ctx: ProjectContext): Promise { - const configPath = await findFirstFile(ctx.cwd, ["nuxt.config.ts", "nuxt.config.js"]); - - if (!configPath) { - return { +function scaffoldConfig(ctx: ProjectContext): Promise { + return scaffoldConfigFile(ctx.cwd, { + candidates: ["nuxt.config.ts", "nuxt.config.js"], + existsCheck: "@clerk/nuxt", + modify: addNuxtModule, + description: "Add @clerk/nuxt to modules array", + existingSkipReason: "Already has @clerk/nuxt module", + missingAction: { type: "skip", path: "nuxt.config.ts", skipReason: "No Nuxt config file found — create one and add @clerk/nuxt to modules", - }; - } - - const content = await Bun.file(join(ctx.cwd, configPath)).text(); - - if (content.includes("@clerk/nuxt")) { - return { type: "skip", path: configPath, skipReason: "Already has @clerk/nuxt module" }; - } - - const newContent = addNuxtModule(content); - - return { - path: configPath, - type: "modify", - content: newContent, - description: "Add @clerk/nuxt to modules array", - }; + }, + }); } export const nuxt: FrameworkScaffold = { @@ -69,22 +50,29 @@ export const nuxt: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "nuxt", async scaffold(ctx: ProjectContext): Promise { - const actions: FileAction[] = []; - const postInstructions: string[] = []; + const [configAction, authActions] = await Promise.all([ + scaffoldConfig(ctx), + scaffoldAuthFiles(ctx.cwd, [ + { + path: "pages/sign-in.vue", + content: authPageContent("sign-in"), + kind: "sign-in", + surface: "page", + }, + { + path: "pages/sign-up.vue", + content: authPageContent("sign-up"), + kind: "sign-up", + surface: "page", + }, + ]), + ]); - actions.push(await scaffoldConfig(ctx)); - - actions.push( - await scaffoldAuthPage(ctx.cwd, "pages/sign-in.vue", signInPageContent(), "sign-in page"), - ); - actions.push( - await scaffoldAuthPage(ctx.cwd, "pages/sign-up.vue", signUpPageContent(), "sign-up page"), - ); - - postInstructions.push( - 'Use and components in your app.vue for conditional rendering (auto-imported)', - ); - - return { actions, postInstructions }; + return { + actions: [configAction, ...authActions], + postInstructions: [ + 'Use and components in your app.vue for conditional rendering (auto-imported)', + ], + }; }, }; diff --git a/packages/cli-core/src/commands/init/frameworks/react-router.ts b/packages/cli-core/src/commands/init/frameworks/react-router.ts index 22b25cada..307018852 100644 --- a/packages/cli-core/src/commands/init/frameworks/react-router.ts +++ b/packages/cli-core/src/commands/init/frameworks/react-router.ts @@ -3,48 +3,45 @@ import { parseModule } from "magicast"; import { findFirstFile, insertAfterLastImport, + jsxAuthPageContent, + jsxExt, safeAddImport, - scaffoldAuthPage, + scaffoldAuthFiles, + scaffoldConfigFile, } from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; -function signInRouteContent(): string { - return `import { SignIn } from "@clerk/react-router"; +type RootScaffoldResult = { + action: FileAction | null; + needsManualLoaderMerge: boolean; +}; -export default function SignInPage() { - return ; -} -`; +function addServerImport(source: string, imported: "clerkMiddleware" | "rootAuthLoader"): string { + if (source.includes(imported)) return source; + return safeAddImport(source, "@clerk/react-router/server", imported); } -function signUpRouteContent(): string { - return `import { SignUp } from "@clerk/react-router"; - -export default function SignUpPage() { - return ; -} -`; +function addServerImports(source: string, includeRootAuthLoader: boolean): string { + const withMiddleware = addServerImport(source, "clerkMiddleware"); + return includeRootAuthLoader ? addServerImport(withMiddleware, "rootAuthLoader") : withMiddleware; } -function addServerImports(source: string): string { - if (source.includes("@clerk/react-router/server")) return source; +function addClientImport(source: string, imported: string): string { + if (source.includes(imported)) return source; + return safeAddImport(source, "react-router", imported); +} - let result = safeAddImport(source, "@clerk/react-router/server", "clerkMiddleware"); - result = safeAddImport(result, "@clerk/react-router/server", "rootAuthLoader"); - return result; +function hasLoaderExport(source: string): boolean { + return source.includes("export const loader"); } -function addMiddlewareExport(source: string, typescript: boolean): string { +function addMiddlewareExport(source: string): string { if (source.includes("export const middleware")) return source; - const typeAnnotation = typescript ? ": Route.MiddlewareFunction[]" : ""; - return insertAfterLastImport( - source, - `\nexport const middleware${typeAnnotation} = [clerkMiddleware()];\n`, - ); + return insertAfterLastImport(source, "\nexport const middleware = [clerkMiddleware()];\n"); } function addLoaderExport(source: string, typescript: boolean): string { - if (source.includes("rootAuthLoader")) return source; + if (hasLoaderExport(source)) return source; const middlewareIdx = source.indexOf("export const middleware"); if (middlewareIdx === -1) return source; @@ -52,7 +49,7 @@ function addLoaderExport(source: string, typescript: boolean): string { const lineEnd = source.indexOf("\n", middlewareIdx); if (lineEnd === -1) return source; - const argsParam = typescript ? "(args: Route.LoaderArgs)" : "(args)"; + const argsParam = typescript ? "(args: Parameters[0])" : "(args)"; return ( source.slice(0, lineEnd + 1) + `\nexport const loader = ${argsParam} => rootAuthLoader(args);\n` + @@ -60,38 +57,119 @@ function addLoaderExport(source: string, typescript: boolean): string { ); } -function wrapOutletWithProvider(source: string): string { +function addLoaderDataBinding(source: string): { content: string; hasLoaderData: boolean } { + if (source.includes("loaderData }: Route.ComponentProps")) { + return { content: source, hasLoaderData: true }; + } + + if (source.includes("const loaderData = useLoaderData()")) { + return { content: source, hasLoaderData: true }; + } + + const withImport = addClientImport(source, "useLoaderData"); + const updated = withImport.replace( + /(export\s+default\s+function\s+\w+\([^)]*\)\s*\{)/, + "$1\n const loaderData = useLoaderData();", + ); + + return { + content: updated, + hasLoaderData: updated !== withImport, + }; +} + +function describeRootAction(options: { + hasLoaderData: boolean; + needsManualLoaderMerge: boolean; +}): string { + if (options.needsManualLoaderMerge) { + return "Add ClerkProvider and clerkMiddleware (manual rootAuthLoader merge still required)"; + } + + if (options.hasLoaderData) { + return "Add ClerkProvider, clerkMiddleware, rootAuthLoader, and loaderData wiring"; + } + + return "Add ClerkProvider, clerkMiddleware, and rootAuthLoader (manual loaderData wiring may be needed)"; +} + +function wrapOutletWithProvider(source: string, hasLoaderData: boolean): string { if (!source.includes(")/, - "\n $1\n ", + `\n $1\n `, ); } -async function scaffoldRoot(ctx: ProjectContext): Promise { +function authRoutePath(ctx: ProjectContext, kind: "sign-in" | "sign-up"): string { + return `app/routes/${kind}.${jsxExt(ctx)}`; +} + +async function scaffoldAuthRoutes(ctx: ProjectContext): Promise { + return scaffoldAuthFiles(ctx.cwd, [ + { + path: authRoutePath(ctx, "sign-in"), + content: jsxAuthPageContent("sign-in", "@clerk/react-router"), + kind: "sign-in", + surface: "route", + }, + { + path: authRoutePath(ctx, "sign-up"), + content: jsxAuthPageContent("sign-up", "@clerk/react-router"), + kind: "sign-up", + surface: "route", + }, + ]); +} + +async function scaffoldRoot(ctx: ProjectContext): Promise { const rootPath = await findFirstFile(ctx.cwd, ["app/root.tsx", "app/root.jsx"]); - if (!rootPath) return null; + if (!rootPath) { + return { action: null, needsManualLoaderMerge: false }; + } const content = await Bun.file(join(ctx.cwd, rootPath)).text(); if (content.includes("ClerkProvider")) { - return { type: "skip", path: rootPath, skipReason: "Already has ClerkProvider" }; + return { + action: { type: "skip", path: rootPath, skipReason: "Already has ClerkProvider" }, + needsManualLoaderMerge: false, + }; } - let result = addServerImports(content); + const hasExistingLoader = hasLoaderExport(content); + const needsManualLoaderMerge = hasExistingLoader && !content.includes("rootAuthLoader"); + + let result = addServerImports(content, !needsManualLoaderMerge); result = safeAddImport(result, "@clerk/react-router", "ClerkProvider"); - result = addMiddlewareExport(result, ctx.typescript); - result = addLoaderExport(result, ctx.typescript); - result = wrapOutletWithProvider(result); + result = addMiddlewareExport(result); + result = hasExistingLoader ? result : addLoaderExport(result, ctx.typescript); + const loaderDataResult = needsManualLoaderMerge + ? { content: result, hasLoaderData: false } + : addLoaderDataBinding(result); + result = wrapOutletWithProvider(loaderDataResult.content, loaderDataResult.hasLoaderData); return { - path: rootPath, - type: "modify", - content: result, - description: "Add ClerkProvider, clerkMiddleware, and rootAuthLoader", + action: { + path: rootPath, + type: "modify", + content: result, + description: describeRootAction({ + hasLoaderData: loaderDataResult.hasLoaderData, + needsManualLoaderMerge, + }), + }, + needsManualLoaderMerge, }; } +/** + * Enable the `future.v8_middleware` flag in react-router.config. + * React Router v7 requires this opt-in flag to activate the middleware API + * that clerkMiddleware() depends on. It becomes the default in v8. + */ function enableV8Middleware(content: string): string { try { const mod = parseModule(content); @@ -112,27 +190,15 @@ function enableV8Middleware(content: string): string { } } -async function scaffoldConfig(ctx: ProjectContext): Promise { - const configPath = await findFirstFile(ctx.cwd, [ - "react-router.config.ts", - "react-router.config.js", - ]); - if (!configPath) return null; - - const content = await Bun.file(join(ctx.cwd, configPath)).text(); - - if (content.includes("v8_middleware")) { - return { type: "skip", path: configPath, skipReason: "Already has v8_middleware flag" }; - } - - const newContent = enableV8Middleware(content); - - return { - path: configPath, - type: "modify", - content: newContent, +function scaffoldConfig(ctx: ProjectContext): Promise { + return scaffoldConfigFile(ctx.cwd, { + candidates: ["react-router.config.ts", "react-router.config.js"], + existsCheck: "v8_middleware", + modify: enableV8Middleware, description: "Enable v8_middleware future flag for Clerk middleware", - }; + existingSkipReason: "Already has v8_middleware flag", + missingAction: null, + }); } export const reactRouter: FrameworkScaffold = { @@ -143,44 +209,36 @@ export const reactRouter: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "react-router", async scaffold(ctx: ProjectContext): Promise { - const actions: FileAction[] = []; + const [configAction, rootResult, authActions] = await Promise.all([ + scaffoldConfig(ctx), + scaffoldRoot(ctx), + scaffoldAuthRoutes(ctx), + ]); + + const rootAction = rootResult.action; + const actions = [configAction, rootAction, ...authActions].filter( + (action): action is FileAction => action !== null, + ); const postInstructions: string[] = []; - const configAction = await scaffoldConfig(ctx); - if (configAction) { - actions.push(configAction); - } - - const rootAction = await scaffoldRoot(ctx); if (rootAction) { - actions.push(rootAction); + postInstructions.push( + "Add sign-in and sign-up routes to app/routes.ts: route('sign-in/*', 'routes/sign-in.tsx') and route('sign-up/*', 'routes/sign-up.tsx')", + ); } else { postInstructions.push( "Add ClerkProvider, clerkMiddleware(), and rootAuthLoader() to your app/root.tsx. See: https://clerk.com/docs/quickstarts/react-router", ); + postInstructions.push( + "Add sign-in and sign-up routes to app/routes.ts: route('sign-in/*', 'routes/sign-in.tsx') and route('sign-up/*', 'routes/sign-up.tsx')", + ); } - const ext = ctx.typescript ? "tsx" : "jsx"; - actions.push( - await scaffoldAuthPage( - ctx.cwd, - `app/routes/sign-in.${ext}`, - signInRouteContent(), - "sign-in route", - ), - ); - actions.push( - await scaffoldAuthPage( - ctx.cwd, - `app/routes/sign-up.${ext}`, - signUpRouteContent(), - "sign-up route", - ), - ); - - postInstructions.push( - "Add sign-in and sign-up routes to app/routes.ts: route('sign-in/*', 'routes/sign-in.tsx') and route('sign-up/*', 'routes/sign-up.tsx')", - ); + if (rootAction?.type === "modify" && rootResult.needsManualLoaderMerge) { + postInstructions.push( + "Update your existing app/root.tsx loader to import and call rootAuthLoader(args), then pass that loaderData to .", + ); + } return { actions, postInstructions }; }, diff --git a/packages/cli-core/src/commands/init/frameworks/react.ts b/packages/cli-core/src/commands/init/frameworks/react.ts index a6702e763..2a59983de 100644 --- a/packages/cli-core/src/commands/init/frameworks/react.ts +++ b/packages/cli-core/src/commands/init/frameworks/react.ts @@ -1,11 +1,11 @@ import { join } from "node:path"; -import { findFirstFile, safeAddImport } from "./helpers.js"; +import { findFirstFile, jsxExt, safeAddImport, scriptExt, srcPrefix } from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; async function findEntryFile(ctx: ProjectContext): Promise { - const base = ctx.srcDir ? "src/" : ""; - const jsx = ctx.typescript ? "tsx" : "jsx"; - const ext = ctx.typescript ? "ts" : "js"; + const base = srcPrefix(ctx); + const jsx = jsxExt(ctx); + const ext = scriptExt(ctx); return findFirstFile(ctx.cwd, [`${base}main.${jsx}`, `${base}main.${ext}`]); } diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts index 125959a8c..af3f2ce50 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -1,47 +1,99 @@ import { join } from "node:path"; import { + authComponentName, findFirstFile, hasClerkImport, + jsxExt, safeAddImport, - scaffoldAuthPage, + scaffoldAuthFiles, wrapBodyWithProvider, } from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; -function signInRouteContent(): string { - return `import { SignIn } from "@clerk/tanstack-react-start"; +type TanstackBaseDir = "app" | "src"; + +const START_FILE_CANDIDATES = [ + "src/start.ts", + "src/start.tsx", + "src/start.js", + "src/start.jsx", + "app/start.ts", + "app/start.tsx", + "app/start.js", + "app/start.jsx", +] as const; + +const ROOT_ROUTE_CANDIDATES = [ + "src/routes/__root.tsx", + "src/routes/__root.jsx", + "app/routes/__root.tsx", + "app/routes/__root.jsx", +] as const; + +function authRouteContent(kind: "sign-in" | "sign-up"): string { + const component = authComponentName(kind); + + return `import { ${component} } from "@clerk/tanstack-react-start"; import { createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute("/sign-in/$")({ +export const Route = createFileRoute("/${kind}/$")({ component: Page, }); function Page() { - return ; + return <${component} />; } `; } -function signUpRouteContent(): string { - return `import { SignUp } from "@clerk/tanstack-react-start"; -import { createFileRoute } from "@tanstack/react-router"; +function baseDirFromPath(path: string | null): TanstackBaseDir | null { + if (!path) return null; + return path.startsWith("app/") ? "app" : "src"; +} -export const Route = createFileRoute("/sign-up/$")({ - component: Page, -}); +async function findStartFile(ctx: ProjectContext): Promise { + return findFirstFile(ctx.cwd, [...START_FILE_CANDIDATES]); +} -function Page() { - return ; +async function findRootRouteFile(ctx: ProjectContext): Promise { + return findFirstFile(ctx.cwd, [...ROOT_ROUTE_CANDIDATES]); } -`; + +async function detectBaseDir(ctx: ProjectContext): Promise { + const [rootPath, startPath] = await Promise.all([findRootRouteFile(ctx), findStartFile(ctx)]); + return baseDirFromPath(rootPath) ?? baseDirFromPath(startPath) ?? "src"; } -async function scaffoldStartServer(ctx: ProjectContext): Promise { - const serverPath = await findFirstFile(ctx.cwd, [ - "src/start.ts", - "src/start.tsx", - "app/start.ts", +function authRoutePath( + ctx: ProjectContext, + baseDir: TanstackBaseDir, + kind: "sign-in" | "sign-up", +): string { + return `${baseDir}/routes/${kind}.$.${jsxExt(ctx)}`; +} + +async function scaffoldAuthRoutes( + ctx: ProjectContext, + baseDir: TanstackBaseDir, +): Promise { + return scaffoldAuthFiles(ctx.cwd, [ + { + path: authRoutePath(ctx, baseDir, "sign-in"), + content: authRouteContent("sign-in"), + kind: "sign-in", + surface: "route", + }, + { + path: authRoutePath(ctx, baseDir, "sign-up"), + content: authRouteContent("sign-up"), + kind: "sign-up", + surface: "route", + }, ]); +} + +async function scaffoldStartServer(ctx: ProjectContext): Promise { + const serverPath = await findStartFile(ctx); if (!serverPath) return null; const content = await Bun.file(join(ctx.cwd, serverPath)).text(); @@ -69,11 +121,7 @@ async function scaffoldStartServer(ctx: ProjectContext): Promise { - const rootPath = await findFirstFile(ctx.cwd, [ - "src/routes/__root.tsx", - "src/routes/__root.jsx", - "app/routes/__root.tsx", - ]); + const rootPath = await findRootRouteFile(ctx); if (!rootPath) return null; const content = await Bun.file(join(ctx.cwd, rootPath)).text(); @@ -103,45 +151,30 @@ export const tanstackStart: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "@tanstack/react-start", async scaffold(ctx: ProjectContext): Promise { - const actions: FileAction[] = []; + const [serverAction, rootAction, baseDir] = await Promise.all([ + scaffoldStartServer(ctx), + scaffoldRoot(ctx), + detectBaseDir(ctx), + ]); + const authActions = await scaffoldAuthRoutes(ctx, baseDir); + + const actions = [serverAction, rootAction, ...authActions].filter( + (action): action is FileAction => action !== null, + ); const postInstructions: string[] = []; - const serverAction = await scaffoldStartServer(ctx); - if (serverAction) { - actions.push(serverAction); - } else { + if (!serverAction) { postInstructions.push( "Add clerkMiddleware() to your start server's requestMiddleware. See: https://clerk.com/docs/quickstarts/tanstack-start", ); } - const rootAction = await scaffoldRoot(ctx); - if (rootAction) { - actions.push(rootAction); - } else { + if (!rootAction) { postInstructions.push( "Wrap your root route with from @clerk/tanstack-react-start", ); } - const ext = ctx.typescript ? "tsx" : "jsx"; - actions.push( - await scaffoldAuthPage( - ctx.cwd, - `src/routes/sign-in.$.${ext}`, - signInRouteContent(), - "sign-in route", - ), - ); - actions.push( - await scaffoldAuthPage( - ctx.cwd, - `src/routes/sign-up.$.${ext}`, - signUpRouteContent(), - "sign-up route", - ), - ); - return { actions, postInstructions }; }, }; diff --git a/packages/cli-core/src/commands/init/frameworks/vue.ts b/packages/cli-core/src/commands/init/frameworks/vue.ts index 7f35d9af4..e10ec56eb 100644 --- a/packages/cli-core/src/commands/init/frameworks/vue.ts +++ b/packages/cli-core/src/commands/init/frameworks/vue.ts @@ -1,9 +1,9 @@ import { join } from "node:path"; -import { findFirstFile, insertAfterLastImport, safeAddImport } from "./helpers.js"; +import { findFirstFile, insertAfterLastImport, safeAddImport, srcPrefix } from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; async function findEntryFile(ctx: ProjectContext): Promise { - const base = ctx.srcDir ? "src/" : ""; + const base = srcPrefix(ctx); return findFirstFile(ctx.cwd, [`${base}main.ts`, `${base}main.js`]); } From 75e1764786f746ebdc44f3f358fa15a4799444ca Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 20 Mar 2026 18:54:54 -0300 Subject: [PATCH 21/35] refactor(init): restructure preview, prompts, and scan modules Update preview formatting to use switch on discriminated union, restructure prompts/index.ts with typed template resolution and variable building, and minor scan.ts cleanup. --- .../cli-core/src/commands/init/preview.ts | 21 ++++--- .../src/commands/init/prompts/index.ts | 58 ++++++++++++------- packages/cli-core/src/commands/init/scan.ts | 2 +- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/packages/cli-core/src/commands/init/preview.ts b/packages/cli-core/src/commands/init/preview.ts index e12f6a0a0..8f7564162 100644 --- a/packages/cli-core/src/commands/init/preview.ts +++ b/packages/cli-core/src/commands/init/preview.ts @@ -1,18 +1,23 @@ import { confirm } from "@inquirer/prompts"; import { cyan, dim, green, yellow } from "../../lib/color.js"; -import type { ScaffoldPlan } from "./frameworks/types.js"; +import type { FileAction, ScaffoldPlan } from "./frameworks/types.js"; + +function formatAction(action: FileAction): string { + switch (action.type) { + case "skip": + return ` ${dim("SKIP")} ${dim(action.path)} — ${dim(action.skipReason)}`; + case "create": + return ` ${green("CREATE")} ${cyan(action.path)}`; + case "modify": + return ` ${yellow("MODIFY")} ${cyan(action.path)} — ${action.description}`; + } +} export async function previewAndConfirm(plan: ScaffoldPlan): Promise { console.log("\nclerk init will make the following changes:\n"); for (const action of plan.actions) { - if (action.type === "skip") { - console.log(` ${dim("SKIP")} ${dim(action.path)} — ${dim(action.skipReason)}`); - } else if (action.type === "create") { - console.log(` ${green("CREATE")} ${cyan(action.path)}`); - } else { - console.log(` ${yellow("MODIFY")} ${cyan(action.path)} — ${action.description}`); - } + console.log(formatAction(action)); } if (plan.postInstructions.length > 0) { diff --git a/packages/cli-core/src/commands/init/prompts/index.ts b/packages/cli-core/src/commands/init/prompts/index.ts index 5077d6132..ab9d9b129 100644 --- a/packages/cli-core/src/commands/init/prompts/index.ts +++ b/packages/cli-core/src/commands/init/prompts/index.ts @@ -1,4 +1,5 @@ import type { ProjectContext } from "../frameworks/types.js"; +import { jsxExt, scriptExt, srcPrefix } from "../frameworks/helpers.js"; // Static text imports — embedded at build time, safe for compiled binaries. import genericMd from "./generic.md" with { type: "text" }; @@ -19,7 +20,7 @@ import fastifyMd from "./fastify.md" with { type: "text" }; // Template loading // --------------------------------------------------------------------------- -const TEMPLATES: Record = { +const TEMPLATES = { generic: genericMd, "generic-fallback": genericFallbackMd, "nextjs-app-router": nextjsAppRouterMd, @@ -33,11 +34,14 @@ const TEMPLATES: Record = { expo: expoMd, express: expressMd, fastify: fastifyMd, -}; +} satisfies Record; + +type TemplateName = keyof typeof TEMPLATES; +type FrameworkTemplateName = Exclude; +type FrameworkPromptInfo = { template: FrameworkTemplateName; docsUrl: string }; -function loadTemplate(name: string): string { +function loadTemplate(name: TemplateName): string { const template = TEMPLATES[name]; - if (!template) throw new Error(`Unknown prompt template: ${name}`); // The project formatter escapes underscores in markdown headings (e.g. `_app` → `\_app`). // These templates are output as plain text, so undo that escaping. return template.replaceAll("\\_", "_"); @@ -51,12 +55,12 @@ function interpolate(template: string, vars: Record): string { // Helpers // --------------------------------------------------------------------------- -const PM_COMMANDS: Record = { +const PM_COMMANDS = { bun: "bun add", yarn: "yarn add", pnpm: "pnpm add", npm: "npm install", -}; +} satisfies Record; export function pmInstallCommand(pm: ProjectContext["packageManager"]): string { return PM_COMMANDS[pm]; @@ -64,7 +68,7 @@ export function pmInstallCommand(pm: ProjectContext["packageManager"]): string { // Maps framework dep to its template filename and docs URL. // Next.js defaults to app-router; pages-router variant is handled in resolveTemplate. -const FRAMEWORK_PROMPTS: Record = { +const FRAMEWORK_PROMPTS: Record = { next: { template: "nextjs-app-router", docsUrl: "https://clerk.com/docs/nextjs/getting-started/quickstart", @@ -98,20 +102,36 @@ const DEFAULT_DOCS_URL = "https://clerk.com/docs"; // Variable builders // --------------------------------------------------------------------------- +type RequiredPromptVar = + | "SDK" + | "ENV_VAR" + | "INSTALL_CMD" + | "BASE" + | "BASE_DISPLAY" + | "EXT" + | "JSX" + | "MIDDLEWARE_BASENAME" + | "LAYOUT_PATH" + | "ENV_FILE" + | "PM" + | "DOCS_URL" + | "FRAMEWORK_NAME"; + +type OptionalPromptVar = "INSTALL_CMD_EXTRA"; +type PromptVars = Record & Partial>; + // NOTE: The agent prompts show simple `clerkMiddleware()` (matching official docs). // The scaffold code in `frameworks/helpers.ts` uses `createRouteMatcher` + `auth.protect()` // which is more opinionated. This divergence is intentional — agents should follow the // docs pattern; scaffolded code provides a production-ready starting point. -function buildVars( - ctx: ProjectContext, - base: string, - ext: string, - jsx: string, -): Record { +function buildVars(ctx: ProjectContext): PromptVars { + const base = srcPrefix(ctx); + const ext = scriptExt(ctx); + const jsx = jsxExt(ctx); const installCmd = `${pmInstallCommand(ctx.packageManager)} ${ctx.framework.sdk}`; - const vars: Record = { + const vars: PromptVars = { SDK: ctx.framework.sdk, ENV_VAR: ctx.framework.envVar, INSTALL_CMD: installCmd, @@ -119,7 +139,7 @@ function buildVars( BASE_DISPLAY: base || "project root", EXT: ext, JSX: jsx, - MIDDLEWARE_BASENAME: ctx.middlewareBasename, + MIDDLEWARE_BASENAME: ctx.middlewareBasename ?? "proxy", LAYOUT_PATH: ctx.layoutPath ?? `${base}app/layout.${jsx}`, ENV_FILE: ctx.envFile, PM: ctx.packageManager, @@ -134,7 +154,7 @@ function buildVars( return vars; } -function resolveTemplate(ctx: ProjectContext): string { +function resolveTemplate(ctx: ProjectContext): TemplateName { if (ctx.framework.dep === "next" && ctx.variant === "pages-router") { return "nextjs-pages-router"; } @@ -148,9 +168,5 @@ function resolveTemplate(ctx: ProjectContext): string { export const GENERIC_AGENT_PROMPT = loadTemplate("generic"); export function buildAgentPrompt(ctx: ProjectContext): string { - const base = ctx.srcDir ? "src/" : ""; - const ext = ctx.typescript ? "ts" : "js"; - const jsx = ctx.typescript ? "tsx" : "jsx"; - - return interpolate(loadTemplate(resolveTemplate(ctx)), buildVars(ctx, base, ext, jsx)); + return interpolate(loadTemplate(resolveTemplate(ctx)), buildVars(ctx)); } diff --git a/packages/cli-core/src/commands/init/scan.ts b/packages/cli-core/src/commands/init/scan.ts index 27168e0fd..602df7e19 100644 --- a/packages/cli-core/src/commands/init/scan.ts +++ b/packages/cli-core/src/commands/init/scan.ts @@ -166,7 +166,7 @@ function scanFileContent(content: string, relPath: string, frameworkDep: string) } export async function scanForIssues(cwd: string, frameworkDep: string): Promise { - const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx}"); + const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,mjs,cjs,vue,astro}"); const findings: ScanFinding[] = []; for await (const relPath of glob.scan({ cwd })) { From 9fa8f27f1b87cf41c48c80271f4a780dcd4b8a5d Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 20 Mar 2026 18:55:08 -0300 Subject: [PATCH 22/35] test(init): add framework scaffolder and scan tests Add tests for nextjs-app (middleware composition, provider wrapping, src/ paths, proxy basename), react-router (root modification, loader merge), tanstack-start (base dir detection), and scan (no findings cap). --- .../init/frameworks/nextjs-app.test.ts | 67 +++++++++++ .../init/frameworks/react-router.test.ts | 105 ++++++++++++++++++ .../init/frameworks/tanstack-start.test.ts | 54 +++++++++ .../cli-core/src/commands/init/scan.test.ts | 8 ++ 4 files changed, 234 insertions(+) create mode 100644 packages/cli-core/src/commands/init/frameworks/react-router.test.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts index b6224ebc2..5cebada85 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts @@ -183,6 +183,73 @@ export default function middleware(request) { expect(plan.actions[0]!.type).not.toBe("skip"); }); +test("skips unsupported middleware export shapes", async () => { + await Bun.write( + join(tempDir, "middleware.ts"), + `const middleware = createMiddleware(); +export default middleware; +`, + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx()); + + expect(plan.actions[0]).toMatchObject({ + type: "skip", + skipReason: "Existing middleware uses an unsupported shape for automatic Clerk composition", + }); +}); + +test("skips middleware composition when config export already exists", async () => { + await Bun.write( + join(tempDir, "middleware.ts"), + `export default function middleware() { + return Response.redirect("https://example.com"); +} + +export const config = { + matcher: ["/foo"], +}; +`, + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx()); + + expect(plan.actions[0]).toMatchObject({ + type: "skip", + skipReason: "Existing middleware uses an unsupported shape for automatic Clerk composition", + }); +}); + +test("adds Clerk middleware once when existing middleware has no default export", async () => { + await Bun.write( + join(tempDir, "middleware.ts"), + `export function trace() { + return "ok"; +} +`, + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx()); + const middlewareAction = plan.actions[0]; + + expect(middlewareAction).toBeDefined(); + expect(middlewareAction?.type).toBe("modify"); + + if (middlewareAction?.type !== "modify") { + throw new Error("Expected middleware action to modify middleware.ts"); + } + + expect(middlewareAction.content.match(/@clerk\/nextjs\/server/g)?.length).toBe(1); + expect(middlewareAction.content.match(/const isPublicRoute/g)?.length).toBe(1); + expect(middlewareAction.content.match(/export const config/g)?.length).toBe(1); +}); + test("uses proxy.ts when middlewareBasename is proxy", async () => { await mkdir(join(tempDir, "app"), { recursive: true }); await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); 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 new file mode 100644 index 000000000..a9cee0bb1 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/react-router.test.ts @@ -0,0 +1,105 @@ +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { reactRouter } from "./react-router.ts"; +import type { ProjectContext } from "./types.ts"; + +let tempDir: string; + +function makeCtx(overrides?: Partial): ProjectContext { + return { + cwd: tempDir, + framework: { + dep: "react-router", + name: "React Router", + sdk: "@clerk/react-router", + envVar: "VITE_CLERK_PUBLISHABLE_KEY", + }, + typescript: true, + srcDir: false, + packageManager: "npm", + existingClerk: false, + deps: { "react-router": "7.0.0" }, + envFile: ".env", + ...overrides, + }; +} + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-react-router-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +test("adds middleware, loader, and provider to app/root.tsx", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "app/root.tsx"), + `import { Outlet } from "react-router"; + +export default function Root() { + return ; +} +`, + ); + + const plan = await reactRouter.scaffold(makeCtx()); + const rootAction = plan.actions.find((action) => action.path === "app/root.tsx"); + + expect(rootAction).toBeDefined(); + expect(rootAction?.type).toBe("modify"); + + if (rootAction?.type !== "modify") { + throw new Error("Expected root action to modify app/root.tsx"); + } + + expect(rootAction.content).toContain("@clerk/react-router/server"); + expect(rootAction.content).toContain("useLoaderData"); + expect(rootAction.content).toContain("export const middleware = [clerkMiddleware()];"); + expect(rootAction.content).toContain( + "export const loader = (args: Parameters[0]) => rootAuthLoader(args);", + ); + expect(rootAction.content).toContain("const loaderData = useLoaderData();"); + expect(rootAction.content).toContain(""); +}); + +test("keeps an existing loader manual when rootAuthLoader is not present", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "app/root.tsx"), + `import { Outlet } from "react-router"; + +export const loader = () => ({ ok: true }); + +export default function Root() { + return ; +} +`, + ); + + const plan = await reactRouter.scaffold(makeCtx()); + const rootAction = plan.actions.find((action) => action.path === "app/root.tsx"); + + expect(rootAction).toBeDefined(); + expect(rootAction?.type).toBe("modify"); + + if (rootAction?.type !== "modify") { + throw new Error("Expected root action to modify app/root.tsx"); + } + + expect(rootAction.content).toContain('from "@clerk/react-router/server";'); + expect(rootAction.content).toContain("clerkMiddleware"); + expect(rootAction.content).toContain("export const middleware = [clerkMiddleware()];"); + expect(rootAction.content).not.toContain("rootAuthLoader"); + expect(rootAction.content).not.toContain("useLoaderData"); + expect(rootAction.content).toContain(""); + expect(rootAction.content).not.toContain("loaderData={loaderData}"); + expect( + plan.postInstructions.some((instruction) => + instruction.includes("Update your existing app/root.tsx loader"), + ), + ).toBe(true); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts new file mode 100644 index 000000000..be0ee3fc2 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts @@ -0,0 +1,54 @@ +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { tanstackStart } from "./tanstack-start.ts"; +import type { ProjectContext } from "./types.ts"; + +let tempDir: string; + +function makeCtx(overrides?: Partial): ProjectContext { + return { + cwd: tempDir, + framework: { + dep: "@tanstack/react-start", + name: "TanStack Start", + sdk: "@clerk/tanstack-react-start", + envVar: "VITE_CLERK_PUBLISHABLE_KEY", + }, + typescript: true, + srcDir: true, + packageManager: "npm", + existingClerk: false, + deps: { "@tanstack/react-start": "1.0.0" }, + envFile: ".env", + ...overrides, + }; +} + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-tanstack-start-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +test("uses app routes when an app tree is detected", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "app/start.tsx"), + `import { createStart } from "@tanstack/react-start"; + +export const start = createStart(() => { + return {}; +}); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + + expect(plan.actions.some((action) => action.path === "app/routes/sign-in.$.tsx")).toBe(true); + expect(plan.actions.some((action) => action.path === "app/routes/sign-up.$.tsx")).toBe(true); + expect(plan.actions.some((action) => action.path === "src/routes/sign-in.$.tsx")).toBe(false); +}); diff --git a/packages/cli-core/src/commands/init/scan.test.ts b/packages/cli-core/src/commands/init/scan.test.ts index a4129b6d7..9730cbfb7 100644 --- a/packages/cli-core/src/commands/init/scan.test.ts +++ b/packages/cli-core/src/commands/init/scan.test.ts @@ -169,6 +169,14 @@ describe("scanForIssues", () => { expect(findings.some((f) => f.message.includes("Better Auth"))).toBe(true); }); + test("detects Better Auth import in Vue files", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write(join(tempDir, "src/auth.vue"), 'import { auth } from "better-auth";\n'); + + const findings = await scanForIssues(tempDir, "vue"); + expect(findings.some((f) => f.message.includes("Better Auth"))).toBe(true); + }); + test("detects Passport import", async () => { await mkdir(join(tempDir, "src"), { recursive: true }); await Bun.write(join(tempDir, "src/auth.ts"), 'import passport from "passport";\n'); From ad51c6422753eb2b2b173a513fda71c38b925bb2 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 20 Mar 2026 18:55:21 -0300 Subject: [PATCH 23/35] chore(init): add markdown type declaration for static text imports --- packages/cli-core/src/commands/init/prompts/md.d.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 packages/cli-core/src/commands/init/prompts/md.d.ts diff --git a/packages/cli-core/src/commands/init/prompts/md.d.ts b/packages/cli-core/src/commands/init/prompts/md.d.ts new file mode 100644 index 000000000..c94d67b1a --- /dev/null +++ b/packages/cli-core/src/commands/init/prompts/md.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string; + export default content; +} From f410e6a3e529aa878d1c3c4b8446600da9a795a7 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 23 Mar 2026 23:49:15 -0300 Subject: [PATCH 24/35] feat(init): add framework lookup utility with aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export FRAMEWORK_MAP and add lookupFramework() to resolve framework names or aliases (e.g. "tanstack-start" → "@tanstack/react-start") along with FRAMEWORK_NAMES for validation and display. --- packages/cli-core/src/lib/framework.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/lib/framework.ts b/packages/cli-core/src/lib/framework.ts index 206231344..300b12f84 100644 --- a/packages/cli-core/src/lib/framework.ts +++ b/packages/cli-core/src/lib/framework.ts @@ -13,7 +13,7 @@ export interface FrameworkInfo { } // Order matters: more specific frameworks first (e.g. next before react, nuxt before vue) -const FRAMEWORK_MAP: FrameworkInfo[] = [ +export const FRAMEWORK_MAP: FrameworkInfo[] = [ { dep: "next", name: "Next.js", @@ -46,6 +46,20 @@ const FRAMEWORK_MAP: FrameworkInfo[] = [ { dep: "fastify", name: "Fastify", sdk: "@clerk/fastify", envVar: "CLERK_PUBLISHABLE_KEY" }, ]; +const FRAMEWORK_ALIASES: Record = { + "tanstack-start": "@tanstack/react-start", +}; + +export function lookupFramework(name: string): FrameworkInfo | null { + const dep = FRAMEWORK_ALIASES[name] ?? name; + return FRAMEWORK_MAP.find((fw) => fw.dep === dep) ?? null; +} + +export const FRAMEWORK_NAMES = FRAMEWORK_MAP.map((fw) => { + const alias = Object.entries(FRAMEWORK_ALIASES).find(([, v]) => v === fw.dep); + return alias ? alias[0] : fw.dep; +}); + const FALLBACK_KEY = "CLERK_PUBLISHABLE_KEY"; export async function readDeps(cwd: string): Promise | null> { From e2b96fdc41c491fd7e7279f631049777f2d2c395 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 23 Mar 2026 23:49:21 -0300 Subject: [PATCH 25/35] feat(init): add i18nLocaleDir to ProjectContext type Add optional i18nLocaleDir field to ProjectContext for frameworks that detect locale-based routing directories (e.g. [locale], [lang]). --- packages/cli-core/src/commands/init/frameworks/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli-core/src/commands/init/frameworks/types.ts b/packages/cli-core/src/commands/init/frameworks/types.ts index 1fbdc421d..5a56d218e 100644 --- a/packages/cli-core/src/commands/init/frameworks/types.ts +++ b/packages/cli-core/src/commands/init/frameworks/types.ts @@ -15,6 +15,8 @@ export interface ProjectContext { layoutPath?: string | null; /** Next.js middleware basename: "proxy" for Next.js 16+, "middleware" for ≤15. Populated by enrichContext. */ middlewareBasename?: "proxy" | "middleware"; + /** i18n locale directory segment (e.g., "[locale]"). Set by enrichContext when detected. */ + i18nLocaleDir?: string; } export type FileAction = From 930e82a18d2307cbcdd9c2a5145bc2be2347afa5 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 23 Mar 2026 23:49:26 -0300 Subject: [PATCH 26/35] refactor(init): extract previewPlan display function Split preview logic into previewPlan() (display only) and previewAndConfirm() (display + prompt) to support --yes mode without duplicating the plan rendering code. --- packages/cli-core/src/commands/init/preview.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/preview.ts b/packages/cli-core/src/commands/init/preview.ts index 8f7564162..f53facada 100644 --- a/packages/cli-core/src/commands/init/preview.ts +++ b/packages/cli-core/src/commands/init/preview.ts @@ -13,7 +13,7 @@ function formatAction(action: FileAction): string { } } -export async function previewAndConfirm(plan: ScaffoldPlan): Promise { +export function previewPlan(plan: ScaffoldPlan): void { console.log("\nclerk init will make the following changes:\n"); for (const action of plan.actions) { @@ -28,5 +28,9 @@ export async function previewAndConfirm(plan: ScaffoldPlan): Promise { } console.log(); +} + +export async function previewAndConfirm(plan: ScaffoldPlan): Promise { + previewPlan(plan); return confirm({ message: "Proceed?" }); } From 9938f0a318d21e1f2ffd5360cddc550a8195d3ad Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 23 Mar 2026 23:49:36 -0300 Subject: [PATCH 27/35] feat(init): add shared scaffolding helpers for auth, env, and i18n Add reusable helpers for all framework scaffolders: - authFileSpecs() factory for sign-in/sign-up file generation - scaffoldEnvVars() and SIGN_ROUTE_ENV_VARS for env file management - htmlAuthComponentMarkup() and jsxAuthComponentMarkup() with Tailwind/plain CSS variants - hasTailwindStyles() for style detection - i18n middleware detection, composition, and routing helpers (detectI18nMiddlewareLib, composeWithI18nMiddleware, etc.) - findFirstDirMatch() for generic directory scanning --- .../src/commands/init/frameworks/helpers.ts | 369 +++++++++++++++++- 1 file changed, 356 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/helpers.ts b/packages/cli-core/src/commands/init/frameworks/helpers.ts index 3fe3f5faf..ad27f594c 100644 --- a/packages/cli-core/src/commands/init/frameworks/helpers.ts +++ b/packages/cli-core/src/commands/init/frameworks/helpers.ts @@ -1,9 +1,12 @@ import { join } from "node:path"; +import { readdir } from "node:fs/promises"; import { parseModule } from "magicast"; +import { parseEnvFile, mergeEnvVars, serializeEnvFile } from "../../../lib/dotenv.js"; import type { FileAction, ProjectContext } from "./types.js"; export type AuthKind = "sign-in" | "sign-up"; type AuthSurface = "page" | "route"; +const AUTH_KINDS = ["sign-in", "sign-up"] as const satisfies readonly AuthKind[]; /** Clerk SDK packages that export JSX auth components (SignIn, SignUp). */ type JsxClerkPackage = "@clerk/nextjs" | "@clerk/react-router"; @@ -13,6 +16,20 @@ type AuthFileSpec = { kind: AuthKind; surface: AuthSurface; }; +type AuthWrapperMarkup = { + tailwind: string; + plain: string; +}; + +const HTML_AUTH_WRAPPER: AuthWrapperMarkup = { + tailwind: `
`, + plain: `
`, +}; + +const JSX_AUTH_WRAPPER: AuthWrapperMarkup = { + tailwind: `
`, + plain: `
`, +}; /** * Parse the major version from a semver-like string. @@ -41,6 +58,65 @@ export function jsxExt(ctx: Pick): "tsx" | "jsx" { return ctx.typescript ? "tsx" : "jsx"; } +export function hasTailwindStyles(ctx: Pick): boolean { + return Boolean(ctx.deps["tailwindcss"]); +} + +export function indentBlock(content: string, indent: string): string { + return content + .split("\n") + .map((line) => `${indent}${line}`) + .join("\n"); +} + +function authWrapper(markup: AuthWrapperMarkup, tailwind: boolean): string { + if (tailwind) return markup.tailwind; + return markup.plain; +} + +function renderCenteredAuthComponent( + component: string, + markup: AuthWrapperMarkup, + tailwind: boolean, +): string { + const wrapper = authWrapper(markup, tailwind); + return `${wrapper} + <${component} /> +
`; +} + +export function htmlAuthComponentMarkup(component: string, tailwind: boolean): string { + return renderCenteredAuthComponent(component, HTML_AUTH_WRAPPER, tailwind); +} + +export function jsxAuthComponentMarkup(component: string, tailwind: boolean): string { + return renderCenteredAuthComponent(component, JSX_AUTH_WRAPPER, tailwind); +} + +function buildAuthFileSpec( + kind: AuthKind, + options: { + path: (kind: AuthKind) => string; + content: (kind: AuthKind) => string; + surface: AuthSurface; + }, +): AuthFileSpec { + return { + path: options.path(kind), + content: options.content(kind), + kind, + surface: options.surface, + }; +} + +export function authFileSpecs(options: { + path: (kind: AuthKind) => string; + content: (kind: AuthKind) => string; + surface: AuthSurface; +}): readonly AuthFileSpec[] { + return AUTH_KINDS.map((kind) => buildAuthFileSpec(kind, options)); +} + /** Find the first existing file from a list of candidates relative to cwd. */ export async function findFirstFile(cwd: string, candidates: string[]): Promise { for (const candidate of candidates) { @@ -49,6 +125,24 @@ export async function findFirstFile(cwd: string, candidates: string[]): Promise< return null; } +export async function findFirstDirMatch( + cwd: string, + dir: string, + matcher: (entry: string) => T | null, +): Promise { + try { + const entries = await readdir(join(cwd, dir)); + for (const entry of entries) { + const match = matcher(entry); + if (match !== null) return match; + } + } catch { + return null; + } + + return null; +} + /** * Add an import to a file using magicast AST, with a string-prepend fallback. * Returns the modified source code. @@ -88,6 +182,49 @@ export function resolveNextjsMiddlewareBasename( return major >= 16 ? "proxy" : "middleware"; } +// ─── i18n Middleware Library Detection ──────────────────────────── + +/** + * Known Next.js i18n libraries that use middleware. + * Listed in priority order — the first match in deps wins. + * + * Libraries from https://nextjs.org/docs/app/guides/internationalization: + * next-intl, next-international, next-i18n-router, paraglide-next, next-intlayer + */ +type I18nMiddlewareLib = { + dep: string; + importFrom: string; + varName: string; +}; + +const I18N_MIDDLEWARE_LIBS: readonly I18nMiddlewareLib[] = [ + { dep: "next-intl", importFrom: "next-intl/middleware", varName: "intlMiddleware" }, + { + dep: "next-international", + importFrom: "next-international/middleware", + varName: "i18nMiddleware", + }, + { dep: "next-i18n-router", importFrom: "next-i18n-router", varName: "i18nMiddleware" }, + { + dep: "@inlang/paraglide-next", + importFrom: "@inlang/paraglide-next", + varName: "paraglideMiddleware", + }, + { dep: "next-intlayer", importFrom: "next-intlayer/middleware", varName: "intlayerMiddleware" }, +]; + +/** Detect which i18n middleware library is used based on project dependencies. */ +function detectI18nMiddlewareLib(deps: Record): I18nMiddlewareLib | null { + return I18N_MIDDLEWARE_LIBS.find((lib) => deps[lib.dep]) ?? null; +} + +/** Check if middleware content imports from a known i18n middleware package. */ +function detectI18nMiddlewareImport(content: string): I18nMiddlewareLib | null { + return I18N_MIDDLEWARE_LIBS.find((lib) => content.includes(lib.importFrom)) ?? null; +} + +// ─── Middleware Content Generation ──────────────────────────────── + /** Next.js clerkMiddleware with route protection and matcher config. */ export function nextjsMiddlewareContent(): string { return `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; @@ -100,7 +237,42 @@ ${nextjsMiddlewareConfig()} `; } -function nextjsPublicRouteMatcher(): string { +/** + * Generate composed Clerk + i18n middleware content. + * When routingImport is provided (e.g., next-intl routing config found), + * the middleware is fully configured. Otherwise, a placeholder setup is generated. + */ +function nextjsI18nMiddlewareContent(lib: I18nMiddlewareLib, routingImport: string | null): string { + const i18nImport = routingImport + ? `import createMiddleware from "${lib.importFrom}";\n${routingImport}` + : `import createMiddleware from "${lib.importFrom}";`; + + const setup = routingImport + ? `const ${lib.varName} = createMiddleware(routing);` + : `const ${lib.varName} = createMiddleware({\n locales: ["en"],\n defaultLocale: "en",\n});`; + + return `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; +${i18nImport} + +${setup} + +${nextjsPublicRouteMatcher(true)} + +${nextjsMiddlewareHandler(`${lib.varName}(request)`)} + +${nextjsMiddlewareConfig()} +`; +} + +function nextjsPublicRouteMatcher(i18n = false): string { + if (i18n) { + return `const isPublicRoute = createRouteMatcher([ + "/sign-in(.*)", + "/sign-up(.*)", + "/:locale/sign-in(.*)", + "/:locale/sign-up(.*)", +]);`; + } return `const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]);`; } @@ -128,14 +300,21 @@ export function authComponentName(kind: AuthKind): "SignIn" | "SignUp" { } /** Generate a JSX auth page component for a Clerk framework SDK that exports SignIn/SignUp. */ -export function jsxAuthPageContent(kind: AuthKind, clerkPackage: JsxClerkPackage): string { +export function jsxAuthPageContent( + kind: AuthKind, + clerkPackage: JsxClerkPackage, + tailwind: boolean, +): string { const component = authComponentName(kind); const pageName = component === "SignIn" ? "SignInPage" : "SignUpPage"; + const content = indentBlock(jsxAuthComponentMarkup(component, tailwind), " "); return `import { ${component} } from "${clerkPackage}"; export default function ${pageName}() { - return <${component} />; + return ( +${content} + ); } `; } @@ -147,12 +326,22 @@ export default function ${pageName}() { function renameDefaultMiddlewareExport(existing: string): string | null { const functionExportPattern = /export\s+default\s+(?:async\s+)?function(?:\s+\w+)?/; if (functionExportPattern.test(existing)) { - return existing.replace(functionExportPattern, "async function existingMiddleware"); + return existing.replace(functionExportPattern, "async function middleware"); } const arrowExportPattern = /export\s+default\s+(?:async\s+)?(\([^)]*\)\s*=>)/; if (arrowExportPattern.test(existing)) { - return existing.replace(arrowExportPattern, "const existingMiddleware = async $1"); + return existing.replace(arrowExportPattern, "const middleware = async $1"); + } + + // Expression: export default someIdentifier or export default someCall(...) + // Catches patterns like `export default wrapped` or `export default createMiddleware(routing)` + if (/export\s+default\s+/.test(existing)) { + // If already exporting a variable named `middleware`, just strip the export line + if (/export\s+default\s+middleware\s*[;\n]/.test(existing)) { + return existing.replace(/export\s+default\s+middleware\s*;?\s*\n?/, ""); + } + return existing.replace(/export\s+default\s+/, "const middleware = "); } return null; @@ -162,9 +351,9 @@ function hasMiddlewareConfigExport(existing: string): boolean { return /export\s+const\s+config\s*=/.test(existing); } -export function composeWithExistingMiddleware(existing: string): string | null { +export function composeWithExistingMiddleware(existing: string, i18n = false): string | null { const clerkImport = `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";\n`; - const routeMatcher = `\n${nextjsPublicRouteMatcher()}\n`; + const routeMatcher = `\n${nextjsPublicRouteMatcher(i18n)}\n`; const preamble = clerkImport + routeMatcher + "\n"; if (hasMiddlewareConfigExport(existing)) { @@ -181,14 +370,80 @@ export function composeWithExistingMiddleware(existing: string): string | null { return ( preamble + content + - `\n${nextjsMiddlewareHandler("existingMiddleware(request)")}\n\n${nextjsMiddlewareConfig()}\n` + `\n${nextjsMiddlewareHandler("middleware(request)")}\n\n${nextjsMiddlewareConfig()}\n` ); } +/** + * Compose Clerk middleware with an existing i18n middleware. + * + * Only handles the common i18n pattern `export default createMiddleware(...)` — + * a bare expression export. Function declarations and arrow functions are left + * to the general-purpose composer (via `composeWithExistingMiddleware`) because + * they typically represent user-customized middleware that already calls the + * i18n middleware internally. + * + * Also strips the existing `export const config` since Clerk's matcher replaces it. + */ +export function composeWithI18nMiddleware(existing: string): string | null { + const lib = detectI18nMiddlewareImport(existing); + if (!lib) return null; + + // Only handle expression exports (e.g., `export default createMiddleware(routing)`). + // Function declarations / arrow functions are handled by the general-purpose composer. + if (/export\s+default\s+(?:async\s+)?function/.test(existing)) return null; + if (/export\s+default\s+(?:async\s+)?\(/.test(existing)) return null; + + // Bail if the varName is already used (would create a duplicate declaration) + if (existing.includes(`const ${lib.varName}`)) return null; + + const clerkImport = `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";\n`; + + // Strip existing config export (Clerk's matcher replaces it) + let content = existing.replace(/\n*export\s+const\s+config\s*=[\s\S]*$/, ""); + + // Rename `export default ` to `const = ` + content = content.replace(/export\s+default\s+/, `const ${lib.varName} = `); + + // Verify the rename succeeded — if export default is still present, bail + if (/export\s+default\s+/.test(content)) return null; + + return ( + clerkImport + + content + + `\n\n${nextjsPublicRouteMatcher(true)}\n\n${nextjsMiddlewareHandler(`${lib.varName}(request)`)}\n\n${nextjsMiddlewareConfig()}\n` + ); +} + +/** + * Find a next-intl routing config file for importing in composed middleware. + * Returns an import statement like `import { routing } from "./i18n/routing"` or null. + */ +async function findI18nRoutingImport( + cwd: string, + srcDir: boolean, + lib: I18nMiddlewareLib, +): Promise { + if (lib.dep !== "next-intl") return null; + + const base = srcPrefix({ srcDir }); + const hasRoutingFile = await findFirstFile(cwd, [ + `${base}i18n/routing.ts`, + `${base}i18n/routing.js`, + ]); + + if (!hasRoutingFile) return null; + + // Middleware and routing are co-located under the same base (root or src/), + // so the relative import path is always the same regardless of srcDir. + return `import { routing } from "./i18n/routing";`; +} + /** * Scaffold Next.js middleware — shared between App Router and Pages Router. * Checks for existing middleware and returns skip/create/compose action accordingly. * When existing non-Clerk middleware is found, it composes rather than overwriting. + * When an i18n library is detected, generates composed Clerk + i18n middleware. */ export async function scaffoldNextjsMiddleware(ctx: { cwd: string; @@ -204,6 +459,18 @@ export async function scaffoldNextjsMiddleware(ctx: { const file = Bun.file(join(ctx.cwd, path)); if (!(await file.exists())) { + // Check for i18n library — generate composed middleware if detected + const i18nLib = detectI18nMiddlewareLib(ctx.deps ?? {}); + if (i18nLib) { + const routingImport = await findI18nRoutingImport(ctx.cwd, ctx.srcDir, i18nLib); + return { + path, + type: "create", + content: nextjsI18nMiddlewareContent(i18nLib, routingImport), + description: `Create Clerk middleware composed with ${i18nLib.dep}`, + }; + } + return { path, type: "create", @@ -218,7 +485,27 @@ export async function scaffoldNextjsMiddleware(ctx: { return { type: "skip", path, skipReason: "Already has Clerk middleware" }; } - const composedContent = composeWithExistingMiddleware(content); + // Try i18n-specific composition first (handles expression exports like `export default createMiddleware(...)`) + const i18nComposed = composeWithI18nMiddleware(content); + if (i18nComposed) { + return { + path, + type: "modify", + content: i18nComposed, + description: "Add clerkMiddleware composing with existing i18n middleware", + }; + } + + // For i18n middleware with function exports (user already composed their own middleware), + // strip the config export first — Clerk's matcher replaces it — then use the general composer. + const isI18nMiddleware = detectI18nMiddlewareImport(content) !== null; + const contentForComposition = + isI18nMiddleware && hasMiddlewareConfigExport(content) + ? content.replace(/\n*export\s+const\s+config\s*=[\s\S]*$/, "") + : content; + + // Fall through to general-purpose composition + const composedContent = composeWithExistingMiddleware(contentForComposition, isI18nMiddleware); if (!composedContent) { return { type: "skip", @@ -231,13 +518,69 @@ export async function scaffoldNextjsMiddleware(ctx: { path, type: "modify", content: composedContent, - description: "Add clerkMiddleware to existing middleware", + description: isI18nMiddleware + ? "Add clerkMiddleware wrapping existing i18n middleware" + : "Add clerkMiddleware to existing middleware", + }; +} + +/** + * Create a scaffold action that merges env vars into the project's env file. + * Skips if all vars are already present. + */ +export async function scaffoldEnvVars( + ctx: ProjectContext, + vars: Record, +): Promise { + const envPath = join(ctx.cwd, ctx.envFile); + const file = Bun.file(envPath); + const existing = (await file.exists()) ? await file.text() : ""; + + const lines = parseEnvFile(existing); + + const allPresent = Object.keys(vars).every((key) => + lines.some((l) => l.type === "entry" && l.key === key), + ); + if (allPresent) { + return { + type: "skip", + path: ctx.envFile, + skipReason: "Sign-in/sign-up route vars already set", + }; + } + + const merged = mergeEnvVars(lines, vars); + return { + path: ctx.envFile, + type: "modify", + content: serializeEnvFile(merged), + description: "Add sign-in/sign-up route env vars", }; } -/** Shared post-instruction for Next.js sign-in/sign-up env vars. Used by both App and Pages Router. */ -export const NEXTJS_SIGN_ROUTES_INSTRUCTION = - "Add to your .env.local: NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in, NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up, NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/, NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/"; +/** Sign-in/sign-up route env vars per framework prefix. */ +export const SIGN_ROUTE_ENV_VARS = { + nextjs: { + NEXT_PUBLIC_CLERK_SIGN_IN_URL: "/sign-in", + NEXT_PUBLIC_CLERK_SIGN_UP_URL: "/sign-up", + NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL: "/", + NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL: "/", + }, + vite: { + VITE_CLERK_SIGN_IN_URL: "/sign-in", + VITE_CLERK_SIGN_UP_URL: "/sign-up", + VITE_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL: "/", + VITE_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL: "/", + }, + astro: { + PUBLIC_CLERK_SIGN_IN_URL: "/sign-in", + PUBLIC_CLERK_SIGN_UP_URL: "/sign-up", + }, + nuxt: { + NUXT_PUBLIC_CLERK_SIGN_IN_URL: "/sign-in", + NUXT_PUBLIC_CLERK_SIGN_UP_URL: "/sign-up", + }, +} as const; /** * Generic helper for scaffolding a framework config file. From f956c0ebdd20a1c7346b5c99e52577b0bef0d8c6 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 23 Mar 2026 23:49:41 -0300 Subject: [PATCH 28/35] feat(init): detect i18n locale directory in Next.js context Add detectI18nLocaleDir() to identify App Router locale directories (e.g. [locale], [lang]) by checking for layout files inside dynamic segments. Populate i18nLocaleDir in enrichNextjsContext() so scaffolders can place auth pages inside locale-prefixed paths. --- .../init/frameworks/nextjs-context.ts | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts index 82e7748b3..2248633b2 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts @@ -54,9 +54,41 @@ async function detectLayoutPath( return findFirstFile(cwd, [`${base}app/layout.${ext}x`, `${base}app/layout.${ext}`]); } +/** + * Common i18n locale directory names used by next-intl and similar libraries. + * These are checked in order — "[locale]" is the most common convention. + */ +const I18N_DIR_NAMES = ["[locale]", "[lang]"] as const; + +/** + * Detect an i18n locale directory directly under the app folder. + * Returns the directory name (e.g., "[locale]") if found, null otherwise. + * + * A directory qualifies when it matches a known i18n segment name AND + * contains a layout file — confirming it's the routing root for localized pages, + * not an unrelated dynamic route. + */ +async function detectI18nLocaleDir( + cwd: string, + srcDir: boolean, + ext: string, +): Promise { + const base = srcPrefix({ srcDir }); + + for (const dirName of I18N_DIR_NAMES) { + const hasLayout = await findFirstFile(cwd, [ + `${base}app/${dirName}/layout.${ext}x`, + `${base}app/${dirName}/layout.${ext}`, + ]); + if (hasLayout) return dirName; + } + + return null; +} + /** * Enrich a ProjectContext with Next.js-specific fields: - * variant, layoutPath, middlewareBasename. + * variant, layoutPath, middlewareBasename, i18nLocaleDir. */ export async function enrichNextjsContext(ctx: ProjectContext): Promise { const ext = scriptExt(ctx); @@ -76,12 +108,13 @@ export async function enrichNextjsContext(ctx: ProjectContext): Promise { rootPagesDir, }); - ctx.layoutPath = await detectLayoutPath(ctx.cwd, ctx.variant, ctx.srcDir, ext); + const [layoutPath, middlewareBasename, i18nLocaleDir] = await Promise.all([ + detectLayoutPath(ctx.cwd, ctx.variant, ctx.srcDir, ext), + detectMiddlewareBasename(ctx.cwd, ctx.srcDir, ext, ctx.deps[ctx.framework.dep]), + ctx.variant === "app-router" ? detectI18nLocaleDir(ctx.cwd, ctx.srcDir, ext) : null, + ]); - ctx.middlewareBasename = await detectMiddlewareBasename( - ctx.cwd, - ctx.srcDir, - ext, - ctx.deps[ctx.framework.dep], - ); + ctx.layoutPath = layoutPath; + ctx.middlewareBasename = middlewareBasename; + if (i18nLocaleDir) ctx.i18nLocaleDir = i18nLocaleDir; } From e1c0c2c8ad6fb59aebe126aed54cb82e54d2b578 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 23 Mar 2026 23:49:46 -0300 Subject: [PATCH 29/35] feat(init): support framework override in context gathering Accept optional frameworkOverride parameter in gatherContext() to skip auto-detection when the user specifies --framework explicitly. --- packages/cli-core/src/commands/init/context.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/context.ts b/packages/cli-core/src/commands/init/context.ts index 94422a89b..566658663 100644 --- a/packages/cli-core/src/commands/init/context.ts +++ b/packages/cli-core/src/commands/init/context.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import { stat } from "node:fs/promises"; import { detectFramework, readDeps } from "../../lib/framework.js"; +import type { FrameworkInfo } from "../../lib/framework.js"; import type { ProjectContext } from "./frameworks/types.js"; export async function fileExists(path: string): Promise { @@ -34,8 +35,11 @@ async function detectPackageManager(cwd: string): Promise { - const framework = await detectFramework(cwd); +export async function gatherContext( + cwd: string, + frameworkOverride?: FrameworkInfo, +): Promise { + const framework = frameworkOverride ?? (await detectFramework(cwd)); if (!framework) return null; const typescript = await fileExists(join(cwd, "tsconfig.json")); From fca2e7caea28df5bfc0b21c0a2f202525e988354 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 23 Mar 2026 23:49:56 -0300 Subject: [PATCH 30/35] feat(init): wire --framework, --yes, and --prompt CLI options Register --framework , -y/--yes, and --prompt options on the init command. Wire them through to framework override resolution, preview-only plan display, and agent prompt output respectively. --- packages/cli-core/src/cli-program.ts | 8 ++- packages/cli-core/src/commands/init/index.ts | 53 ++++++++++++++++---- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 532e819c9..771c76cfc 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -45,7 +45,13 @@ export function createProgram() { } }); - program.command("init").description("Initialize Clerk in your project").action(init); + program + .command("init") + .description("Initialize Clerk in your project") + .option("--framework ", "Framework to set up (skips auto-detection)") + .option("--prompt", "Output a prompt for an AI agent to integrate Clerk") + .option("-y, --yes", "Skip confirmation prompts") + .action(init); const auth = program.command("auth").description("Manage authentication"); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index ceadc290f..5a37769e1 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -5,13 +5,14 @@ import { link } from "../link/index.js"; import { pull } from "../env/pull.js"; import { isAgent } from "../../mode.js"; import { dim, cyan, green, yellow, bold } from "../../lib/color.js"; -import { throwUserAbort } from "../../lib/errors.js"; +import { CliError, throwUserAbort } from "../../lib/errors.js"; +import { lookupFramework, FRAMEWORK_NAMES } from "../../lib/framework.js"; import { getToken } from "../../lib/credential-store.js"; import { resolveProfile } from "../../lib/config.js"; import { fetchUserInfo } from "../../lib/token-exchange.js"; import { gatherContext } from "./context.js"; import { scaffold, enrichProjectContext } from "./scaffold.js"; -import { previewAndConfirm } from "./preview.js"; +import { previewPlan, previewAndConfirm } from "./preview.js"; import { runFormatters } from "./format.js"; import { detectAuthLibraries, scanForIssues, printFindings } from "./scan.js"; import { buildAgentPrompt, GENERIC_AGENT_PROMPT, pmInstallCommand } from "./prompts/index.js"; @@ -124,20 +125,38 @@ async function getAuthenticatedEmail(): Promise { // Main entry point // --------------------------------------------------------------------------- -export async function init() { +interface InitOptions { + framework?: string; + yes?: boolean; + prompt?: boolean; +} + +export async function init(options: InitOptions = {}) { const cwd = process.cwd(); - const ctx = await gatherContext(cwd); + + // Resolve --framework override + let frameworkOverride; + if (options.framework) { + frameworkOverride = lookupFramework(options.framework); + if (!frameworkOverride) { + throw new CliError( + `Unknown framework "${options.framework}". Valid values: ${FRAMEWORK_NAMES.join(", ")}`, + ); + } + } + + const ctx = await gatherContext(cwd, frameworkOverride); // Populate framework-specific context (variant, layoutPath, middlewareBasename) if (ctx) await enrichProjectContext(ctx); - if (isAgent()) { + if (options.prompt || isAgent()) { console.log(ctx ? buildAgentPrompt(ctx) : GENERIC_AGENT_PROMPT); return; } await authenticateAndLink(cwd); - await detectAndInstall(cwd, ctx); + await detectAndInstall(cwd, ctx, options); } async function authenticateAndLink(cwd: string): Promise { @@ -162,7 +181,11 @@ async function authenticateAndLink(cwd: string): Promise { await link({ skipIfLinked: true }); } -async function detectAndInstall(cwd: string, ctx: ProjectContext | null): Promise { +async function detectAndInstall( + cwd: string, + ctx: ProjectContext | null, + options: InitOptions, +): Promise { if (!ctx) { console.log( `Could not detect a framework. Install the appropriate Clerk SDK manually: ${dim("https://clerk.com/docs")}`, @@ -185,10 +208,14 @@ async function detectAndInstall(cwd: string, ctx: ProjectContext | null): Promis } await pull({}); - await scaffoldAndWrite(cwd, ctx); + await scaffoldAndWrite(cwd, ctx, options); } -async function scaffoldAndWrite(cwd: string, ctx: ProjectContext): Promise { +async function scaffoldAndWrite( + cwd: string, + ctx: ProjectContext, + options: InitOptions, +): Promise { const plan = await scaffold(ctx); const hasChanges = plan.actions.some((a) => a.type !== "skip"); @@ -210,8 +237,12 @@ async function scaffoldAndWrite(cwd: string, ctx: ProjectContext): Promise console.log(dim("Consider committing first so you can review what clerk init creates.\n")); } - const proceed = await previewAndConfirm(plan); - if (!proceed) throwUserAbort(); + if (options.yes) { + previewPlan(plan); + } else { + const proceed = await previewAndConfirm(plan); + if (!proceed) throwUserAbort(); + } const writtenFiles = await writePlan(cwd, plan); await runFormatters(cwd, writtenFiles); From 0eeaf6dd8aabf709071488e3ddaf6ae86183d8b7 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 23 Mar 2026 23:50:02 -0300 Subject: [PATCH 31/35] refactor(init): migrate framework scaffolders to shared helpers Update all six framework scaffolders (Astro, Next.js App/Pages, Nuxt, React Router, TanStack Start) to use the shared helpers: - authFileSpecs() factory for sign-in/sign-up generation - scaffoldEnvVars() for .env file management - hasTailwindStyles() for consistent style detection - i18n-aware auth page placement using locale directory detection - htmlAuthComponentMarkup()/jsxAuthComponentMarkup() for rendering --- .../src/commands/init/frameworks/astro.ts | 66 +++++++++++++------ .../commands/init/frameworks/nextjs-app.ts | 36 +++++----- .../commands/init/frameworks/nextjs-pages.ts | 42 +++++++----- .../src/commands/init/frameworks/nuxt.ts | 57 ++++++++++------ .../commands/init/frameworks/react-router.ts | 61 +++++++++++------ .../init/frameworks/tanstack-start.ts | 61 +++++++++++------ 6 files changed, 209 insertions(+), 114 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/astro.ts b/packages/cli-core/src/commands/init/frameworks/astro.ts index 63cca461c..d242aeea3 100644 --- a/packages/cli-core/src/commands/init/frameworks/astro.ts +++ b/packages/cli-core/src/commands/init/frameworks/astro.ts @@ -2,10 +2,16 @@ import { join } from "node:path"; import { parseModule, builders } from "magicast"; import { authComponentName, + authFileSpecs, + findFirstFile, hasClerkImport, + hasTailwindStyles, + htmlAuthComponentMarkup, scaffoldAuthFiles, scaffoldConfigFile, + scaffoldEnvVars, scriptExt, + SIGN_ROUTE_ENV_VARS, } from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; @@ -16,14 +22,14 @@ export const onRequest = clerkMiddleware(); `; } -function authPageContent(kind: "sign-in" | "sign-up"): string { +function authPageContent(kind: "sign-in" | "sign-up", tailwind: boolean): string { const component = authComponentName(kind); return `--- import { ${component} } from "@clerk/astro/components"; --- -<${component} /> +${htmlAuthComponentMarkup(component, tailwind)} `; } @@ -113,6 +119,21 @@ async function scaffoldMiddleware(ctx: ProjectContext): Promise { }; } +/** Check if the Astro config contains an i18n configuration. */ +async function hasAstroI18n(cwd: string): Promise { + const configPath = await findFirstFile(cwd, [ + "astro.config.mjs", + "astro.config.ts", + "astro.config.js", + ]); + if (!configPath) return false; + + const content = await Bun.file(join(cwd, configPath)).text(); + // Strip comments before matching to avoid false positives on commented-out config + const stripped = content.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, ""); + return /\bi18n\s*:/.test(stripped); +} + export const astro: FrameworkScaffold = { name: "Astro", dep: "astro", @@ -121,30 +142,35 @@ export const astro: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "astro", async scaffold(ctx: ProjectContext): Promise { - const [configAction, middlewareAction, authActions] = await Promise.all([ + const tailwind = hasTailwindStyles(ctx); + const [configAction, middlewareAction, authActions, envAction, i18n] = await Promise.all([ scaffoldConfig(ctx), scaffoldMiddleware(ctx), - scaffoldAuthFiles(ctx.cwd, [ - { - path: "src/pages/sign-in.astro", - content: authPageContent("sign-in"), - kind: "sign-in", - surface: "page", - }, - { - path: "src/pages/sign-up.astro", - content: authPageContent("sign-up"), - kind: "sign-up", + scaffoldAuthFiles( + ctx.cwd, + authFileSpecs({ + path: (kind) => `src/pages/${kind}.astro`, + content: (kind) => authPageContent(kind, tailwind), surface: "page", - }, - ]), + }), + ), + scaffoldEnvVars(ctx, SIGN_ROUTE_ENV_VARS.astro), + hasAstroI18n(ctx.cwd), ]); + const postInstructions = [ + "Ensure your Astro config has `output: 'server'` and an SSR adapter (e.g., @astrojs/node)", + ]; + + if (i18n) { + postInstructions.push( + "Your project uses i18n routing — create sign-in/sign-up pages in each locale folder (e.g., src/pages/en/sign-in.astro)", + ); + } + return { - actions: [configAction, middlewareAction, ...authActions], - postInstructions: [ - "Ensure your Astro config has `output: 'server'` and an SSR adapter (e.g., @astrojs/node)", - ], + actions: [configAction, middlewareAction, ...authActions, envAction], + postInstructions, }; }, }; diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts index ef0a0a849..29acd90b5 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts @@ -1,11 +1,14 @@ import { join } from "node:path"; import { + authFileSpecs, + hasTailwindStyles, jsxAuthPageContent, jsxExt, - NEXTJS_SIGN_ROUTES_INSTRUCTION, safeAddImport, scaffoldAuthFiles, + scaffoldEnvVars, scaffoldNextjsMiddleware, + SIGN_ROUTE_ENV_VARS, srcPrefix, wrapBodyWithProvider, } from "./helpers.js"; @@ -54,24 +57,20 @@ async function scaffoldLayout(ctx: ProjectContext): Promise { } function authPagePath(ctx: ProjectContext, kind: "sign-in" | "sign-up"): string { - return `${srcPrefix(ctx)}app/${kind}/[[...${kind}]]/page.${jsxExt(ctx)}`; + const localeSegment = ctx.i18nLocaleDir ? `${ctx.i18nLocaleDir}/` : ""; + return `${srcPrefix(ctx)}app/${localeSegment}${kind}/[[...${kind}]]/page.${jsxExt(ctx)}`; } async function scaffoldAuthPages(ctx: ProjectContext): Promise { - return scaffoldAuthFiles(ctx.cwd, [ - { - path: authPagePath(ctx, "sign-in"), - content: jsxAuthPageContent("sign-in", "@clerk/nextjs"), - kind: "sign-in", + const tailwind = hasTailwindStyles(ctx); + return scaffoldAuthFiles( + ctx.cwd, + authFileSpecs({ + path: (kind) => authPagePath(ctx, kind), + content: (kind) => jsxAuthPageContent(kind, "@clerk/nextjs", tailwind), surface: "page", - }, - { - path: authPagePath(ctx, "sign-up"), - content: jsxAuthPageContent("sign-up", "@clerk/nextjs"), - kind: "sign-up", - surface: "page", - }, - ]); + }), + ); } export const nextjsApp: FrameworkScaffold = { @@ -85,15 +84,16 @@ export const nextjsApp: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "next" && ctx.variant !== "pages-router", async scaffold(ctx: ProjectContext): Promise { - const [middlewareAction, layoutAction, authActions] = await Promise.all([ + const [middlewareAction, layoutAction, authActions, envAction] = await Promise.all([ scaffoldNextjsMiddleware(ctx), scaffoldLayout(ctx), scaffoldAuthPages(ctx), + scaffoldEnvVars(ctx, SIGN_ROUTE_ENV_VARS.nextjs), ]); return { - actions: [middlewareAction, layoutAction, ...authActions], - postInstructions: [NEXTJS_SIGN_ROUTES_INSTRUCTION], + actions: [middlewareAction, layoutAction, ...authActions, envAction], + postInstructions: [], }; }, }; diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts index 12fd58bfc..afba9c7f5 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts @@ -1,11 +1,14 @@ import { join } from "node:path"; import { + authFileSpecs, + hasTailwindStyles, jsxAuthPageContent, jsxExt, - NEXTJS_SIGN_ROUTES_INSTRUCTION, safeAddImport, scaffoldAuthFiles, + scaffoldEnvVars, scaffoldNextjsMiddleware, + SIGN_ROUTE_ENV_VARS, srcPrefix, } from "./helpers.js"; import { enrichNextjsContext } from "./nextjs-context.js"; @@ -81,20 +84,15 @@ function authPagePath(ctx: ProjectContext, kind: "sign-in" | "sign-up"): string } async function scaffoldAuthPages(ctx: ProjectContext): Promise { - return scaffoldAuthFiles(ctx.cwd, [ - { - path: authPagePath(ctx, "sign-in"), - content: jsxAuthPageContent("sign-in", "@clerk/nextjs"), - kind: "sign-in", + const tailwind = hasTailwindStyles(ctx); + return scaffoldAuthFiles( + ctx.cwd, + authFileSpecs({ + path: (kind) => authPagePath(ctx, kind), + content: (kind) => jsxAuthPageContent(kind, "@clerk/nextjs", tailwind), surface: "page", - }, - { - path: authPagePath(ctx, "sign-up"), - content: jsxAuthPageContent("sign-up", "@clerk/nextjs"), - kind: "sign-up", - surface: "page", - }, - ]); + }), + ); } export const nextjsPages: FrameworkScaffold = { @@ -108,15 +106,25 @@ export const nextjsPages: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "next" && ctx.variant === "pages-router", async scaffold(ctx: ProjectContext): Promise { - const [middlewareAction, appAction, authActions] = await Promise.all([ + const [middlewareAction, appAction, authActions, envAction] = await Promise.all([ scaffoldNextjsMiddleware(ctx), scaffoldApp(ctx), scaffoldAuthPages(ctx), + scaffoldEnvVars(ctx, SIGN_ROUTE_ENV_VARS.nextjs), ]); + const postInstructions: string[] = []; + + const hasI18n = Boolean(ctx.deps["next-intl"] || ctx.deps["next-i18next"]); + if (hasI18n) { + postInstructions.push( + "Next.js Pages Router handles i18n routing automatically via next.config.js — no additional page placement needed for sign-in/sign-up", + ); + } + return { - actions: [middlewareAction, appAction, ...authActions], - postInstructions: [NEXTJS_SIGN_ROUTES_INSTRUCTION], + actions: [middlewareAction, appAction, ...authActions, envAction], + postInstructions, }; }, }; diff --git a/packages/cli-core/src/commands/init/frameworks/nuxt.ts b/packages/cli-core/src/commands/init/frameworks/nuxt.ts index 75753d050..26a39ffae 100644 --- a/packages/cli-core/src/commands/init/frameworks/nuxt.ts +++ b/packages/cli-core/src/commands/init/frameworks/nuxt.ts @@ -1,11 +1,22 @@ import { parseModule } from "magicast"; -import { authComponentName, scaffoldAuthFiles, scaffoldConfigFile } from "./helpers.js"; +import { + authComponentName, + authFileSpecs, + hasTailwindStyles, + htmlAuthComponentMarkup, + indentBlock, + scaffoldAuthFiles, + scaffoldConfigFile, + scaffoldEnvVars, + SIGN_ROUTE_ENV_VARS, +} from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; -function authPageContent(kind: "sign-in" | "sign-up"): string { +function authPageContent(kind: "sign-in" | "sign-up", tailwind: boolean): string { const component = authComponentName(kind); + const content = indentBlock(htmlAuthComponentMarkup(component, tailwind), " "); return ` `; } @@ -50,29 +61,33 @@ export const nuxt: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "nuxt", async scaffold(ctx: ProjectContext): Promise { - const [configAction, authActions] = await Promise.all([ + const tailwind = hasTailwindStyles(ctx); + const [configAction, authActions, envAction] = await Promise.all([ scaffoldConfig(ctx), - scaffoldAuthFiles(ctx.cwd, [ - { - path: "pages/sign-in.vue", - content: authPageContent("sign-in"), - kind: "sign-in", + scaffoldAuthFiles( + ctx.cwd, + authFileSpecs({ + path: (kind) => `pages/${kind}.vue`, + content: (kind) => authPageContent(kind, tailwind), surface: "page", - }, - { - path: "pages/sign-up.vue", - content: authPageContent("sign-up"), - kind: "sign-up", - surface: "page", - }, - ]), + }), + ), + scaffoldEnvVars(ctx, SIGN_ROUTE_ENV_VARS.nuxt), ]); + const postInstructions = [ + 'Use and components in your app.vue for conditional rendering (auto-imported)', + ]; + + if (ctx.deps["@nuxtjs/i18n"]) { + postInstructions.push( + "@nuxtjs/i18n handles locale-prefixed routing automatically — no additional page placement needed for sign-in/sign-up", + ); + } + return { - actions: [configAction, ...authActions], - postInstructions: [ - 'Use and components in your app.vue for conditional rendering (auto-imported)', - ], + actions: [configAction, ...authActions, envAction], + postInstructions, }; }, }; diff --git a/packages/cli-core/src/commands/init/frameworks/react-router.ts b/packages/cli-core/src/commands/init/frameworks/react-router.ts index 307018852..34e5a4dfd 100644 --- a/packages/cli-core/src/commands/init/frameworks/react-router.ts +++ b/packages/cli-core/src/commands/init/frameworks/react-router.ts @@ -1,13 +1,18 @@ import { join } from "node:path"; import { parseModule } from "magicast"; import { + authFileSpecs, + findFirstDirMatch, findFirstFile, + hasTailwindStyles, insertAfterLastImport, jsxAuthPageContent, jsxExt, safeAddImport, scaffoldAuthFiles, scaffoldConfigFile, + scaffoldEnvVars, + SIGN_ROUTE_ENV_VARS, } from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; @@ -103,25 +108,41 @@ function wrapOutletWithProvider(source: string, hasLoaderData: boolean): string ); } -function authRoutePath(ctx: ProjectContext, kind: "sign-in" | "sign-up"): string { - return `app/routes/${kind}.${jsxExt(ctx)}`; +/** + * Detect an i18n optional locale segment in existing React Router route files. + * React Router uses `($locale).` or `($lang).` prefix for optional locale params. + */ +function matchLocalePrefix(entry: string): string | null { + const match = entry.match(/^(\(\$(?:locale|lang)\))\./); + return match?.[1] ?? null; } -async function scaffoldAuthRoutes(ctx: ProjectContext): Promise { - return scaffoldAuthFiles(ctx.cwd, [ - { - path: authRoutePath(ctx, "sign-in"), - content: jsxAuthPageContent("sign-in", "@clerk/react-router"), - kind: "sign-in", - surface: "route", - }, - { - path: authRoutePath(ctx, "sign-up"), - content: jsxAuthPageContent("sign-up", "@clerk/react-router"), - kind: "sign-up", +async function detectLocalePrefix(cwd: string): Promise { + return findFirstDirMatch(cwd, "app/routes", matchLocalePrefix); +} + +function authRoutePath( + ctx: ProjectContext, + kind: "sign-in" | "sign-up", + localePrefix: string | null, +): string { + const prefix = localePrefix ? `${localePrefix}.` : ""; + return `app/routes/${prefix}${kind}.${jsxExt(ctx)}`; +} + +async function scaffoldAuthRoutes( + ctx: ProjectContext, + localePrefix: string | null, +): Promise { + const tailwind = hasTailwindStyles(ctx); + return scaffoldAuthFiles( + ctx.cwd, + authFileSpecs({ + path: (kind) => authRoutePath(ctx, kind, localePrefix), + content: (kind) => jsxAuthPageContent(kind, "@clerk/react-router", tailwind), surface: "route", - }, - ]); + }), + ); } async function scaffoldRoot(ctx: ProjectContext): Promise { @@ -209,14 +230,16 @@ export const reactRouter: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "react-router", async scaffold(ctx: ProjectContext): Promise { - const [configAction, rootResult, authActions] = await Promise.all([ + const [configAction, rootResult, localePrefix, envAction] = await Promise.all([ scaffoldConfig(ctx), scaffoldRoot(ctx), - scaffoldAuthRoutes(ctx), + detectLocalePrefix(ctx.cwd), + scaffoldEnvVars(ctx, SIGN_ROUTE_ENV_VARS.vite), ]); + const authActions = await scaffoldAuthRoutes(ctx, localePrefix); const rootAction = rootResult.action; - const actions = [configAction, rootAction, ...authActions].filter( + const actions = [configAction, rootAction, ...authActions, envAction].filter( (action): action is FileAction => action !== null, ); const postInstructions: string[] = []; diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts index af3f2ce50..724bbeb3d 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -1,11 +1,18 @@ import { join } from "node:path"; import { authComponentName, + authFileSpecs, findFirstFile, + findFirstDirMatch, + hasTailwindStyles, hasClerkImport, + indentBlock, + jsxAuthComponentMarkup, jsxExt, safeAddImport, scaffoldAuthFiles, + scaffoldEnvVars, + SIGN_ROUTE_ENV_VARS, wrapBodyWithProvider, } from "./helpers.js"; import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; @@ -30,8 +37,9 @@ const ROOT_ROUTE_CANDIDATES = [ "app/routes/__root.jsx", ] as const; -function authRouteContent(kind: "sign-in" | "sign-up"): string { +function authRouteContent(kind: "sign-in" | "sign-up", tailwind: boolean): string { const component = authComponentName(kind); + const content = indentBlock(jsxAuthComponentMarkup(component, tailwind), " "); return `import { ${component} } from "@clerk/tanstack-react-start"; import { createFileRoute } from "@tanstack/react-router"; @@ -41,7 +49,9 @@ export const Route = createFileRoute("/${kind}/$")({ }); function Page() { - return <${component} />; + return ( +${content} + ); } `; } @@ -64,32 +74,43 @@ async function detectBaseDir(ctx: ProjectContext): Promise { return baseDirFromPath(rootPath) ?? baseDirFromPath(startPath) ?? "src"; } +/** + * Detect a TanStack Router i18n locale directory in the routes folder. + * TanStack Router uses `{-$locale}` or `{-$lang}` for optional locale params. + */ +function matchLocaleDir(entry: string): string | null { + if (/^\{-\$(?:locale|lang)\}$/.test(entry)) return entry; + return null; +} + +async function detectLocaleDir(cwd: string, baseDir: TanstackBaseDir): Promise { + return findFirstDirMatch(cwd, `${baseDir}/routes`, matchLocaleDir); +} + function authRoutePath( ctx: ProjectContext, baseDir: TanstackBaseDir, kind: "sign-in" | "sign-up", + localeDir: string | null, ): string { - return `${baseDir}/routes/${kind}.$.${jsxExt(ctx)}`; + const localePart = localeDir ? `${localeDir}/` : ""; + return `${baseDir}/routes/${localePart}${kind}.$.${jsxExt(ctx)}`; } async function scaffoldAuthRoutes( ctx: ProjectContext, baseDir: TanstackBaseDir, + localeDir: string | null, ): Promise { - return scaffoldAuthFiles(ctx.cwd, [ - { - path: authRoutePath(ctx, baseDir, "sign-in"), - content: authRouteContent("sign-in"), - kind: "sign-in", - surface: "route", - }, - { - path: authRoutePath(ctx, baseDir, "sign-up"), - content: authRouteContent("sign-up"), - kind: "sign-up", + const tailwind = hasTailwindStyles(ctx); + return scaffoldAuthFiles( + ctx.cwd, + authFileSpecs({ + path: (kind) => authRoutePath(ctx, baseDir, kind, localeDir), + content: (kind) => authRouteContent(kind, tailwind), surface: "route", - }, - ]); + }), + ); } async function scaffoldStartServer(ctx: ProjectContext): Promise { @@ -151,14 +172,16 @@ export const tanstackStart: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "@tanstack/react-start", async scaffold(ctx: ProjectContext): Promise { - const [serverAction, rootAction, baseDir] = await Promise.all([ + const [serverAction, rootAction, baseDir, envAction] = await Promise.all([ scaffoldStartServer(ctx), scaffoldRoot(ctx), detectBaseDir(ctx), + scaffoldEnvVars(ctx, SIGN_ROUTE_ENV_VARS.vite), ]); - const authActions = await scaffoldAuthRoutes(ctx, baseDir); + const localeDir = await detectLocaleDir(ctx.cwd, baseDir); + const authActions = await scaffoldAuthRoutes(ctx, baseDir, localeDir); - const actions = [serverAction, rootAction, ...authActions].filter( + const actions = [serverAction, rootAction, ...authActions, envAction].filter( (action): action is FileAction => action !== null, ); const postInstructions: string[] = []; From ec1e31d3be7753a446dfc98f7e844f69fb0bd13d Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 23 Mar 2026 23:50:10 -0300 Subject: [PATCH 32/35] test(init): add i18n detection and locale routing tests Add tests for i18n support across frameworks: - Context: locale directory detection ([locale], [lang]) with layout file validation and src/ convention support - Next.js App Router: i18n auth page placement, composed Clerk + next-intl middleware, expression export handling, env var scaffolding - React Router: ($locale) prefix detection for auth routes - TanStack Start: {-$locale} directory detection for auth routes --- .../src/commands/init/context.test.ts | 97 ++++++++ .../init/frameworks/nextjs-app.test.ts | 220 +++++++++++++++++- .../init/frameworks/react-router.test.ts | 37 +++ .../init/frameworks/tanstack-start.test.ts | 24 ++ 4 files changed, 369 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/commands/init/context.test.ts b/packages/cli-core/src/commands/init/context.test.ts index acdea3bfd..a8b8fd32c 100644 --- a/packages/cli-core/src/commands/init/context.test.ts +++ b/packages/cli-core/src/commands/init/context.test.ts @@ -263,6 +263,103 @@ test("prefers existing middleware.ts over version detection", async () => { expect(ctx!.middlewareBasename).toBe("middleware"); }); +test("detects [locale] directory for i18n in App Router", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0", react: "19.0.0", "next-intl": "4.0.0" } }), + ); + await mkdir(join(tempDir, "app/[locale]"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "app/[locale]/layout.tsx"), ""); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); + + expect(ctx!.variant).toBe("app-router"); + expect(ctx!.i18nLocaleDir).toBe("[locale]"); +}); + +test("detects [lang] directory for i18n in App Router", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "app/[lang]"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "app/[lang]/layout.tsx"), "export default function() {}"); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); + + expect(ctx!.i18nLocaleDir).toBe("[lang]"); +}); + +test("does not set i18nLocaleDir when no locale directory exists", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); + + expect(ctx!.i18nLocaleDir).toBeUndefined(); +}); + +test("does not set i18nLocaleDir for [locale] without layout", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "app/[locale]"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + // No layout inside [locale] — could be a non-i18n dynamic route + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); + + expect(ctx!.i18nLocaleDir).toBeUndefined(); +}); + +test("detects i18n locale dir with src/ convention", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "src/app/[locale]"), { recursive: true }); + await Bun.write(join(tempDir, "src/app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "src/app/[locale]/layout.tsx"), ""); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); + + expect(ctx!.srcDir).toBe(true); + expect(ctx!.i18nLocaleDir).toBe("[locale]"); +}); + +test("does not set i18nLocaleDir for Pages Router", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0", react: "19.0.0" } }), + ); + await mkdir(join(tempDir, "pages"), { recursive: true }); + await Bun.write(join(tempDir, "pages/_app.tsx"), "export default function App() {}"); + await Bun.write(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await gatherContext(tempDir); + await enrichProjectContext(ctx!); + + expect(ctx!.variant).toBe("pages-router"); + expect(ctx!.i18nLocaleDir).toBeUndefined(); +}); + test("parseMajorVersion handles various formats", () => { expect(parseMajorVersion("15.0.0")).toBe(15); expect(parseMajorVersion("^16.1.0")).toBe(16); diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts index 5cebada85..f3b3936ec 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts @@ -53,12 +53,16 @@ test("scaffolds all 4 files for a fresh Next.js App Router project", async () => const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.actions).toHaveLength(4); + expect(plan.actions).toHaveLength(5); // Middleware expect(plan.actions[0]!.path).toBe("middleware.ts"); expect(plan.actions[0]!.type).toBe("create"); expect(plan.actions[0]!.type).not.toBe("skip"); + if (plan.actions[0]!.type === "create") { + // Non-i18n: should NOT have locale-prefixed patterns + expect(plan.actions[0]!.content).not.toContain("/:locale/"); + } // Layout expect(plan.actions[1]!.path).toBe("app/layout.tsx"); @@ -71,6 +75,10 @@ test("scaffolds all 4 files for a fresh Next.js App Router project", async () => // Sign-up expect(plan.actions[3]!.path).toBe("app/sign-up/[[...sign-up]]/page.tsx"); expect(plan.actions[3]!.type).toBe("create"); + + // Env vars + expect(plan.actions[4]!.path).toBe(".env.local"); + expect(plan.actions[4]!.type).toBe("modify"); }); test("skips middleware when already has Clerk", async () => { @@ -146,14 +154,22 @@ test("uses .jsx extension when typescript is false", async () => { expect(plan.actions[2]!.path).toBe("app/sign-in/[[...sign-in]]/page.jsx"); }); -test("adds post-instructions for sign-in/sign-up URLs", async () => { +test("writes sign-in/sign-up route env vars to env file", async () => { await mkdir(join(tempDir, "app"), { recursive: true }); await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.postInstructions.length).toBeGreaterThan(0); - expect(plan.postInstructions.some((i) => i.includes("NEXT_PUBLIC_CLERK_SIGN_IN_URL"))).toBe(true); + const envAction = plan.actions.find((a) => a.path === ".env.local"); + expect(envAction).toBeDefined(); + expect(envAction!.type).toBe("modify"); + if (envAction!.type === "modify") { + expect(envAction!.content).toContain("NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in"); + expect(envAction!.content).toContain("NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up"); + expect(envAction!.content).toContain("NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/"); + expect(envAction!.content).toContain("NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/"); + } + expect(plan.postInstructions).toHaveLength(0); }); test("returns skip action when no layout found", async () => { @@ -183,7 +199,7 @@ export default function middleware(request) { expect(plan.actions[0]!.type).not.toBe("skip"); }); -test("skips unsupported middleware export shapes", async () => { +test("composes with expression export middleware (variable default export)", async () => { await Bun.write( join(tempDir, "middleware.ts"), `const middleware = createMiddleware(); @@ -195,10 +211,14 @@ export default middleware; const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.actions[0]).toMatchObject({ - type: "skip", - skipReason: "Existing middleware uses an unsupported shape for automatic Clerk composition", - }); + expect(plan.actions[0]!.type).toBe("modify"); + if (plan.actions[0]!.type === "modify") { + // `export default middleware` is stripped (variable already named `middleware`) + expect(plan.actions[0]!.content).not.toContain("export default middleware"); + expect(plan.actions[0]!.content).toContain("const middleware = createMiddleware()"); + expect(plan.actions[0]!.content).toContain("clerkMiddleware"); + expect(plan.actions[0]!.content).toContain("middleware(request)"); + } }); test("skips middleware composition when config export already exists", async () => { @@ -269,3 +289,185 @@ test("uses src/proxy.ts when srcDir and middlewareBasename is proxy", async () = expect(plan.actions[0]!.path).toBe("src/proxy.ts"); }); + +test("places auth pages inside [locale] when i18n locale dir is set", async () => { + await mkdir(join(tempDir, "app/[locale]"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "app/[locale]/layout.tsx"), ""); + + const plan = await nextjsApp.scaffold(makeCtx({ i18nLocaleDir: "[locale]" })); + + expect(plan.actions[2]!.path).toBe("app/[locale]/sign-in/[[...sign-in]]/page.tsx"); + expect(plan.actions[3]!.path).toBe("app/[locale]/sign-up/[[...sign-up]]/page.tsx"); +}); + +test("places auth pages inside [lang] when i18n locale dir uses [lang]", async () => { + await mkdir(join(tempDir, "app/[lang]"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "app/[lang]/layout.tsx"), "export default function() {}"); + + const plan = await nextjsApp.scaffold(makeCtx({ i18nLocaleDir: "[lang]" })); + + expect(plan.actions[2]!.path).toBe("app/[lang]/sign-in/[[...sign-in]]/page.tsx"); + expect(plan.actions[3]!.path).toBe("app/[lang]/sign-up/[[...sign-up]]/page.tsx"); +}); + +test("places auth pages inside src/app/[locale] when srcDir and i18n", async () => { + await mkdir(join(tempDir, "src/app/[locale]"), { recursive: true }); + await Bun.write(join(tempDir, "src/app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "src/app/[locale]/layout.tsx"), "export default function() {}"); + + const plan = await nextjsApp.scaffold( + makeCtx({ srcDir: true, layoutPath: "src/app/layout.tsx", i18nLocaleDir: "[locale]" }), + ); + + expect(plan.actions[2]!.path).toBe("src/app/[locale]/sign-in/[[...sign-in]]/page.tsx"); + expect(plan.actions[3]!.path).toBe("src/app/[locale]/sign-up/[[...sign-up]]/page.tsx"); +}); + +test("skips i18n auth page when it already exists inside [locale]", async () => { + await mkdir(join(tempDir, "app/[locale]/sign-in/[[...sign-in]]"), { recursive: true }); + await Bun.write( + join(tempDir, "app/[locale]/sign-in/[[...sign-in]]/page.tsx"), + "export default function() {}", + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx({ i18nLocaleDir: "[locale]" })); + + expect(plan.actions[2]).toMatchObject({ + type: "skip", + skipReason: "Sign-in page already exists", + }); +}); + +test("creates composed Clerk + next-intl middleware when next-intl is a dep", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx({ deps: { "next-intl": "4.0.0" } })); + const mw = plan.actions[0]!; + + expect(mw.type).toBe("create"); + if (mw.type !== "create") throw new Error("Expected create action"); + expect(mw.content).toContain("next-intl/middleware"); + expect(mw.content).toContain("clerkMiddleware"); + expect(mw.content).toContain("intlMiddleware(request)"); + // i18n middleware should include locale-prefixed public routes + expect(mw.content).toContain("/:locale/sign-in(.*)"); + expect(mw.content).toContain("/:locale/sign-up(.*)"); +}); + +test("imports routing config in composed middleware when next-intl routing file exists", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await mkdir(join(tempDir, "i18n"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + await Bun.write(join(tempDir, "i18n/routing.ts"), "export const routing = {};"); + + const plan = await nextjsApp.scaffold(makeCtx({ deps: { "next-intl": "4.0.0" } })); + const mw = plan.actions[0]!; + + expect(mw.type).toBe("create"); + if (mw.type !== "create") throw new Error("Expected create action"); + expect(mw.content).toContain('import { routing } from "./i18n/routing"'); + expect(mw.content).toContain("createMiddleware(routing)"); +}); + +test("composes Clerk with existing next-intl expression middleware", async () => { + await Bun.write( + join(tempDir, "middleware.ts"), + `import createMiddleware from "next-intl/middleware"; +import { routing } from "./i18n/routing"; + +export default createMiddleware(routing); + +export const config = { + matcher: ["/((?!api|_next).*)"], +}; +`, + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx()); + const mw = plan.actions[0]!; + + expect(mw.type).toBe("modify"); + if (mw.type !== "modify") throw new Error("Expected modify action"); + expect(mw.content).toContain("@clerk/nextjs/server"); + expect(mw.content).toContain("const intlMiddleware = createMiddleware(routing)"); + expect(mw.content).toContain("intlMiddleware(request)"); + expect(mw.content).toContain("clerkMiddleware"); + // Should NOT have the old config + expect(mw.content).not.toContain('matcher: ["/((?!api|_next).*)"]'); +}); + +test("composes Clerk with existing i18n middleware that has a function export", async () => { + // This is the thayto.com pattern: user already composed their own middleware function + // that creates intlMiddleware internally and has a custom default export function. + await Bun.write( + join(tempDir, "middleware.ts"), + `import createMiddleware from "next-intl/middleware"; +import { routing } from "./i18n/routing"; +import { NextRequest, NextResponse } from "next/server"; + +const intlMiddleware = createMiddleware(routing); + +export default function middleware(request: NextRequest) { + const locale = detectLocale(request); + return intlMiddleware(request); +} + +export const config = { + matcher: ['/((?!api|_next|_vercel|socket\\.io|.*\\..*).*)'], +}; +`, + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx()); + const mw = plan.actions[0]!; + + expect(mw.type).toBe("modify"); + if (mw.type !== "modify") throw new Error("Expected modify action"); + expect(mw.content).toContain("@clerk/nextjs/server"); + expect(mw.content).toContain("clerkMiddleware"); + // Should rename the function to middleware, NOT create a duplicate intlMiddleware + expect(mw.content).toContain("async function middleware"); + expect(mw.content).toContain("middleware(request)"); + // Should NOT have duplicate variable names + expect(mw.content.match(/const intlMiddleware/g)?.length).toBe(1); + // Should include locale-prefixed public routes for i18n + expect(mw.content).toContain("/:locale/sign-in(.*)"); + expect(mw.content).toContain("/:locale/sign-up(.*)"); + // Should strip the old config and use Clerk's + expect(mw.content).not.toContain("socket\\.io"); +}); + +test("falls back to general composer when i18n middleware already defines the varName", async () => { + // Edge case: export default is an expression but the varName is already taken + await Bun.write( + join(tempDir, "middleware.ts"), + `import createMiddleware from "next-intl/middleware"; + +const intlMiddleware = createMiddleware({ locales: ["en"], defaultLocale: "en" }); +const wrapped = (req) => intlMiddleware(req); + +export default wrapped; +`, + ); + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx()); + const mw = plan.actions[0]!; + + expect(mw.type).toBe("modify"); + if (mw.type !== "modify") throw new Error("Expected modify action"); + // Should NOT create duplicate intlMiddleware; general composer renames export to `const middleware` + expect(mw.content.match(/const intlMiddleware/g)?.length).toBe(1); + expect(mw.content).toContain("const middleware = wrapped"); + expect(mw.content).toContain("middleware(request)"); +}); 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 a9cee0bb1..3eb00dd4e 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 @@ -66,6 +66,43 @@ export default function Root() { expect(rootAction.content).toContain(""); }); +test("prefixes auth routes with ($locale) when locale routes detected", async () => { + await mkdir(join(tempDir, "app/routes"), { recursive: true }); + // Create an existing route with ($locale) prefix to simulate i18n setup + await Bun.write(join(tempDir, "app/routes/($locale)._index.tsx"), "export default function() {}"); + await Bun.write( + join(tempDir, "app/root.tsx"), + `import { Outlet } from "react-router"; +export default function Root() { return ; } +`, + ); + + const plan = await reactRouter.scaffold(makeCtx()); + + expect(plan.actions.some((action) => action.path === "app/routes/($locale).sign-in.tsx")).toBe( + true, + ); + expect(plan.actions.some((action) => action.path === "app/routes/($locale).sign-up.tsx")).toBe( + true, + ); +}); + +test("does not prefix auth routes when no locale routes detected", async () => { + await mkdir(join(tempDir, "app/routes"), { recursive: true }); + await Bun.write(join(tempDir, "app/routes/_index.tsx"), "export default function() {}"); + await Bun.write( + join(tempDir, "app/root.tsx"), + `import { Outlet } from "react-router"; +export default function Root() { return ; } +`, + ); + + const plan = await reactRouter.scaffold(makeCtx()); + + expect(plan.actions.some((action) => action.path === "app/routes/sign-in.tsx")).toBe(true); + expect(plan.actions.some((action) => action.path === "app/routes/sign-up.tsx")).toBe(true); +}); + test("keeps an existing loader manual when rootAuthLoader is not present", async () => { await mkdir(join(tempDir, "app"), { recursive: true }); await Bun.write( diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts index be0ee3fc2..9041cf06a 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts @@ -52,3 +52,27 @@ export const start = createStart(() => { expect(plan.actions.some((action) => action.path === "app/routes/sign-up.$.tsx")).toBe(true); expect(plan.actions.some((action) => action.path === "src/routes/sign-in.$.tsx")).toBe(false); }); + +test("places auth routes inside {-$locale} when locale dir detected", async () => { + await mkdir(join(tempDir, "src/routes/{-$locale}"), { recursive: true }); + await Bun.write(join(tempDir, "src/routes/{-$locale}/index.tsx"), "export default function() {}"); + + const plan = await tanstackStart.scaffold(makeCtx()); + + expect(plan.actions.some((action) => action.path === "src/routes/{-$locale}/sign-in.$.tsx")).toBe( + true, + ); + expect(plan.actions.some((action) => action.path === "src/routes/{-$locale}/sign-up.$.tsx")).toBe( + true, + ); +}); + +test("does not use locale dir when none detected", async () => { + await mkdir(join(tempDir, "src/routes"), { recursive: true }); + await Bun.write(join(tempDir, "src/routes/index.tsx"), "export default function() {}"); + + const plan = await tanstackStart.scaffold(makeCtx()); + + expect(plan.actions.some((action) => action.path === "src/routes/sign-in.$.tsx")).toBe(true); + expect(plan.actions.some((action) => action.path === "src/routes/sign-up.$.tsx")).toBe(true); +}); From ceeb03783df614c27c9fc4d1e634c918d1d70fdc Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 23 Mar 2026 23:50:18 -0300 Subject: [PATCH 33/35] docs(init): document new CLI options and update help output Add --framework, --yes, and --prompt options to init command README with usage examples. Update root README help output with --verbose flag documentation. --- README.md | 3 +++ packages/cli-core/src/commands/init/README.md | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/README.md b/README.md index cad004315..42862cf64 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Options: -V, --version Display version --mode Force interaction mode (human or agent). Defaults to auto-detect based on TTY. + --verbose Show detailed error output -h, --help Display help for command Commands: @@ -35,7 +36,9 @@ Commands: deploy [options] Deploy your Clerk application (hidden) clerk init + --framework Framework to set up (skips auto-detection) --prompt Output a prompt for an AI agent to integrate Clerk + --yes Skip confirmation prompts clerk link --app Application ID to link (skips interactive picker) diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 650a784a7..d31368fb6 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -6,8 +6,20 @@ Initializes Clerk in a project by authenticating the user, linking a Clerk appli ```sh clerk init +clerk init --framework next +clerk init --prompt +clerk init -y +clerk init --yes ``` +## Options + +| Option | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--framework ` | Framework to set up (skips auto-detection). Valid values: `next`, `astro`, `nuxt`, `tanstack-start`, `react-router`, `vue`, `expo`, `react`, `express`, `fastify` | +| `--prompt` | Output a prompt for an AI agent to integrate Clerk, then exit | +| `-y, --yes` | Skip confirmation prompts | + ## Agent Mode When running in agent mode (`--mode agent` or non-TTY), outputs a framework-specific prompt with exact file paths and code snippets, then exits without modifying the project. From 0d9e7f25330e185a89889acb65acdde541edb831 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 24 Mar 2026 18:40:19 -0300 Subject: [PATCH 34/35] refactor(init): address PR review comments - Extract utility helpers from index.ts into heuristics.ts (jfoshee #18) - Extract text transformations into transformations.ts (jfoshee #19) - Fix wrapBodyWithProvider whitespace/formatting bug (jfoshee #20) - Refactor tests to use semantic path lookups instead of positional array indexing (jfoshee #13/#17) - Add doc comment to helpers.ts clarifying shared usage (jfoshee #15) - Update astro.md NEVER rules to start with "Never" (jfoshee #25) - Remove section banner comments from init command files --- .../src/commands/init/context.test.ts | 2 +- .../src/commands/init/frameworks/helpers.ts | 57 ++---- .../init/frameworks/nextjs-app.test.ts | 174 +++++++++++------- .../init/frameworks/transformations.ts | 70 +++++++ .../cli-core/src/commands/init/heuristics.ts | 107 +++++++++++ packages/cli-core/src/commands/init/index.ts | 126 ++----------- .../src/commands/init/prompts/astro.md | 2 +- .../src/commands/init/prompts/index.ts | 16 -- .../cli-core/src/commands/init/scan.test.ts | 8 - packages/cli-core/src/commands/init/scan.ts | 12 -- 10 files changed, 316 insertions(+), 258 deletions(-) create mode 100644 packages/cli-core/src/commands/init/frameworks/transformations.ts create mode 100644 packages/cli-core/src/commands/init/heuristics.ts diff --git a/packages/cli-core/src/commands/init/context.test.ts b/packages/cli-core/src/commands/init/context.test.ts index a8b8fd32c..14311389f 100644 --- a/packages/cli-core/src/commands/init/context.test.ts +++ b/packages/cli-core/src/commands/init/context.test.ts @@ -420,5 +420,5 @@ test("scaffold proceeds for Next.js 16 and uses proxy.ts", async () => { const plan = await scaffold(ctx!); expect(plan.actions.length).toBeGreaterThan(0); - expect(plan.actions[0]!.path).toBe("proxy.ts"); + expect(plan.actions.find((a) => a.path === "proxy.ts")).toBeDefined(); }); diff --git a/packages/cli-core/src/commands/init/frameworks/helpers.ts b/packages/cli-core/src/commands/init/frameworks/helpers.ts index ad27f594c..42796f1ae 100644 --- a/packages/cli-core/src/commands/init/frameworks/helpers.ts +++ b/packages/cli-core/src/commands/init/frameworks/helpers.ts @@ -1,8 +1,22 @@ +/** + * Shared helpers used by both framework scaffolders (app code) and their tests. + * Contains utilities for file detection, scaffolding patterns (auth pages, config files, + * env vars, middleware), and re-exports text transformations from `transformations.ts`. + */ import { join } from "node:path"; import { readdir } from "node:fs/promises"; -import { parseModule } from "magicast"; import { parseEnvFile, mergeEnvVars, serializeEnvFile } from "../../../lib/dotenv.js"; import type { FileAction, ProjectContext } from "./types.js"; +import { hasClerkImport, indentBlock } from "./transformations.js"; + +// Re-export text transformations so existing imports from helpers.ts keep working. +export { + hasClerkImport, + indentBlock, + safeAddImport, + insertAfterLastImport, + wrapBodyWithProvider, +} from "./transformations.js"; export type AuthKind = "sign-in" | "sign-up"; type AuthSurface = "page" | "route"; @@ -41,11 +55,6 @@ export function parseMajorVersion(version: string): number | null { return match ? parseInt(match[1]!, 10) : null; } -/** Check if file content already imports from a @clerk/ package. */ -export function hasClerkImport(content: string): boolean { - return content.includes("@clerk/"); -} - export function srcPrefix(ctx: Pick): string { return ctx.srcDir ? "src/" : ""; } @@ -62,13 +71,6 @@ export function hasTailwindStyles(ctx: Pick): boolean { return Boolean(ctx.deps["tailwindcss"]); } -export function indentBlock(content: string, indent: string): string { - return content - .split("\n") - .map((line) => `${indent}${line}`) - .join("\n"); -} - function authWrapper(markup: AuthWrapperMarkup, tailwind: boolean): string { if (tailwind) return markup.tailwind; return markup.plain; @@ -143,35 +145,6 @@ export async function findFirstDirMatch( return null; } -/** - * Add an import to a file using magicast AST, with a string-prepend fallback. - * Returns the modified source code. - */ -export function safeAddImport(content: string, source: string, imported: string): string { - try { - const mod = parseModule(content); - mod.imports.$add({ from: source, imported, local: imported }); - return mod.generate().code; - } catch { - return `import { ${imported} } from "${source}";\n${content}`; - } -} - -/** 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; - return source.slice(0, lineEnd + 1) + snippet + source.slice(lineEnd + 1); -} - -/** Wrap the contents of a `` tag with a provider component (e.g. ``). */ -export function wrapBodyWithProvider(content: string, provider: string): string { - let result = content.replace(/(]*>)(\s*)/, `$1$2<${provider}>\n`); - result = result.replace(/(\s*)(<\/body>)/, `\n$1$2`); - return result; -} - /** Resolve the middleware basename from a Next.js version string. >=16 uses proxy, <=15 uses middleware. */ export function resolveNextjsMiddlewareBasename( nextVersion: string | undefined, diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts index f3b3936ec..1b8cacecc 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { mkdtemp, rm, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { nextjsApp } from "./nextjs-app.ts"; -import type { ProjectContext } from "./types.ts"; +import type { FileAction, ProjectContext } from "./types.ts"; let tempDir: string; @@ -29,6 +29,16 @@ function makeCtx(overrides?: Partial): ProjectContext { }; } +/** Find a scaffold action by its exact path. Throws with a clear message if not found. */ +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-nextjs-app-")); }); @@ -37,7 +47,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); -test("scaffolds all 4 files for a fresh Next.js App Router project", async () => { +test("scaffolds all 5 actions for a fresh Next.js App Router project", async () => { await mkdir(join(tempDir, "app"), { recursive: true }); await Bun.write( join(tempDir, "app/layout.tsx"), @@ -56,29 +66,28 @@ test("scaffolds all 4 files for a fresh Next.js App Router project", async () => expect(plan.actions).toHaveLength(5); // Middleware - expect(plan.actions[0]!.path).toBe("middleware.ts"); - expect(plan.actions[0]!.type).toBe("create"); - expect(plan.actions[0]!.type).not.toBe("skip"); - if (plan.actions[0]!.type === "create") { + const mw = findAction(plan.actions, "middleware.ts"); + expect(mw.type).toBe("create"); + if (mw.type === "create") { // Non-i18n: should NOT have locale-prefixed patterns - expect(plan.actions[0]!.content).not.toContain("/:locale/"); + expect(mw.content).not.toContain("/:locale/"); } // Layout - expect(plan.actions[1]!.path).toBe("app/layout.tsx"); - expect(plan.actions[1]!.type).toBe("modify"); + const layout = findAction(plan.actions, "app/layout.tsx"); + expect(layout.type).toBe("modify"); // Sign-in - expect(plan.actions[2]!.path).toBe("app/sign-in/[[...sign-in]]/page.tsx"); - expect(plan.actions[2]!.type).toBe("create"); + const signIn = findAction(plan.actions, "app/sign-in/[[...sign-in]]/page.tsx"); + expect(signIn.type).toBe("create"); // Sign-up - expect(plan.actions[3]!.path).toBe("app/sign-up/[[...sign-up]]/page.tsx"); - expect(plan.actions[3]!.type).toBe("create"); + const signUp = findAction(plan.actions, "app/sign-up/[[...sign-up]]/page.tsx"); + expect(signUp.type).toBe("create"); // Env vars - expect(plan.actions[4]!.path).toBe(".env.local"); - expect(plan.actions[4]!.type).toBe("modify"); + const env = findAction(plan.actions, ".env.local"); + expect(env.type).toBe("modify"); }); test("skips middleware when already has Clerk", async () => { @@ -91,7 +100,7 @@ test("skips middleware when already has Clerk", async () => { const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.actions[0]).toMatchObject({ + expect(findAction(plan.actions, "middleware.ts")).toMatchObject({ type: "skip", skipReason: "Already has Clerk middleware", }); @@ -106,7 +115,7 @@ test("skips layout when already has ClerkProvider", async () => { const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.actions[1]).toMatchObject({ + expect(findAction(plan.actions, "app/layout.tsx")).toMatchObject({ type: "skip", skipReason: "Already has ClerkProvider", }); @@ -123,7 +132,7 @@ test("skips sign-in page when it already exists", async () => { const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.actions[2]).toMatchObject({ + expect(findAction(plan.actions, "app/sign-in/[[...sign-in]]/page.tsx")).toMatchObject({ type: "skip", skipReason: "Sign-in page already exists", }); @@ -137,9 +146,9 @@ test("uses src/ paths when srcDir is true", async () => { makeCtx({ srcDir: true, layoutPath: "src/app/layout.tsx" }), ); - expect(plan.actions[0]!.path).toBe("src/middleware.ts"); - expect(plan.actions[2]!.path).toBe("src/app/sign-in/[[...sign-in]]/page.tsx"); - expect(plan.actions[3]!.path).toBe("src/app/sign-up/[[...sign-up]]/page.tsx"); + findAction(plan.actions, "src/middleware.ts"); + findAction(plan.actions, "src/app/sign-in/[[...sign-in]]/page.tsx"); + findAction(plan.actions, "src/app/sign-up/[[...sign-up]]/page.tsx"); }); test("uses .jsx extension when typescript is false", async () => { @@ -150,8 +159,8 @@ test("uses .jsx extension when typescript is false", async () => { makeCtx({ typescript: false, layoutPath: "app/layout.jsx" }), ); - expect(plan.actions[0]!.path).toBe("middleware.js"); - expect(plan.actions[2]!.path).toBe("app/sign-in/[[...sign-in]]/page.jsx"); + findAction(plan.actions, "middleware.js"); + findAction(plan.actions, "app/sign-in/[[...sign-in]]/page.jsx"); }); test("writes sign-in/sign-up route env vars to env file", async () => { @@ -160,14 +169,13 @@ test("writes sign-in/sign-up route env vars to env file", async () => { const plan = await nextjsApp.scaffold(makeCtx()); - const envAction = plan.actions.find((a) => a.path === ".env.local"); - expect(envAction).toBeDefined(); - expect(envAction!.type).toBe("modify"); - if (envAction!.type === "modify") { - expect(envAction!.content).toContain("NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in"); - expect(envAction!.content).toContain("NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up"); - expect(envAction!.content).toContain("NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/"); - expect(envAction!.content).toContain("NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/"); + const envAction = findAction(plan.actions, ".env.local"); + expect(envAction.type).toBe("modify"); + if (envAction.type === "modify") { + expect(envAction.content).toContain("NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in"); + expect(envAction.content).toContain("NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up"); + expect(envAction.content).toContain("NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/"); + expect(envAction.content).toContain("NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/"); } expect(plan.postInstructions).toHaveLength(0); }); @@ -175,12 +183,53 @@ test("writes sign-in/sign-up route env vars to env file", async () => { test("returns skip action when no layout found", async () => { const plan = await nextjsApp.scaffold(makeCtx({ layoutPath: null })); - expect(plan.actions[1]).toMatchObject({ + // When layoutPath is null, the expected path is derived from the default convention + const layoutAction = findAction(plan.actions, "app/layout.tsx"); + expect(layoutAction).toMatchObject({ type: "skip", skipReason: "Layout file not found", }); }); +test("properly indents ClerkProvider wrapping in layout", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "app/layout.tsx"), + `export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} +`, + ); + + const plan = await nextjsApp.scaffold(makeCtx()); + const layout = findAction(plan.actions, "app/layout.tsx"); + + expect(layout.type).toBe("modify"); + if (layout.type === "modify") { + // ClerkProvider should be on its own line after , not inline + expect(layout.content).not.toContain(""); + expect(layout.content).not.toContain(""); + // Proper nesting: → → {children} → → + expect(layout.content).toContain(""); + expect(layout.content).toContain(""); + // {children} should be indented deeper than + const lines = layout.content.split("\n"); + const providerLine = lines.find((l) => l.includes("")); + const childrenLine = lines.find((l) => l.includes("{children}")); + expect(providerLine).toBeDefined(); + expect(childrenLine).toBeDefined(); + const providerIndent = providerLine!.search(/\S/); + const childrenIndent = childrenLine!.search(/\S/); + expect(childrenIndent).toBeGreaterThan(providerIndent); + } +}); + test("composes with existing non-Clerk middleware", async () => { await Bun.write( join(tempDir, "middleware.ts"), @@ -195,8 +244,8 @@ export default function middleware(request) { const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.actions[0]!.type).toBe("modify"); - expect(plan.actions[0]!.type).not.toBe("skip"); + const mw = findAction(plan.actions, "middleware.ts"); + expect(mw.type).toBe("modify"); }); test("composes with expression export middleware (variable default export)", async () => { @@ -211,13 +260,14 @@ export default middleware; const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.actions[0]!.type).toBe("modify"); - if (plan.actions[0]!.type === "modify") { + const mw = findAction(plan.actions, "middleware.ts"); + expect(mw.type).toBe("modify"); + if (mw.type === "modify") { // `export default middleware` is stripped (variable already named `middleware`) - expect(plan.actions[0]!.content).not.toContain("export default middleware"); - expect(plan.actions[0]!.content).toContain("const middleware = createMiddleware()"); - expect(plan.actions[0]!.content).toContain("clerkMiddleware"); - expect(plan.actions[0]!.content).toContain("middleware(request)"); + expect(mw.content).not.toContain("export default middleware"); + expect(mw.content).toContain("const middleware = createMiddleware()"); + expect(mw.content).toContain("clerkMiddleware"); + expect(mw.content).toContain("middleware(request)"); } }); @@ -238,7 +288,7 @@ export const config = { const plan = await nextjsApp.scaffold(makeCtx()); - expect(plan.actions[0]).toMatchObject({ + expect(findAction(plan.actions, "middleware.ts")).toMatchObject({ type: "skip", skipReason: "Existing middleware uses an unsupported shape for automatic Clerk composition", }); @@ -256,18 +306,16 @@ test("adds Clerk middleware once when existing middleware has no default export" await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); const plan = await nextjsApp.scaffold(makeCtx()); - const middlewareAction = plan.actions[0]; - - expect(middlewareAction).toBeDefined(); - expect(middlewareAction?.type).toBe("modify"); + const mw = findAction(plan.actions, "middleware.ts"); - if (middlewareAction?.type !== "modify") { + expect(mw.type).toBe("modify"); + if (mw.type !== "modify") { throw new Error("Expected middleware action to modify middleware.ts"); } - expect(middlewareAction.content.match(/@clerk\/nextjs\/server/g)?.length).toBe(1); - expect(middlewareAction.content.match(/const isPublicRoute/g)?.length).toBe(1); - expect(middlewareAction.content.match(/export const config/g)?.length).toBe(1); + expect(mw.content.match(/@clerk\/nextjs\/server/g)?.length).toBe(1); + expect(mw.content.match(/const isPublicRoute/g)?.length).toBe(1); + expect(mw.content.match(/export const config/g)?.length).toBe(1); }); test("uses proxy.ts when middlewareBasename is proxy", async () => { @@ -276,7 +324,7 @@ test("uses proxy.ts when middlewareBasename is proxy", async () => { const plan = await nextjsApp.scaffold(makeCtx({ middlewareBasename: "proxy" })); - expect(plan.actions[0]!.path).toBe("proxy.ts"); + findAction(plan.actions, "proxy.ts"); }); test("uses src/proxy.ts when srcDir and middlewareBasename is proxy", async () => { @@ -287,7 +335,7 @@ test("uses src/proxy.ts when srcDir and middlewareBasename is proxy", async () = makeCtx({ srcDir: true, layoutPath: "src/app/layout.tsx", middlewareBasename: "proxy" }), ); - expect(plan.actions[0]!.path).toBe("src/proxy.ts"); + findAction(plan.actions, "src/proxy.ts"); }); test("places auth pages inside [locale] when i18n locale dir is set", async () => { @@ -297,8 +345,8 @@ test("places auth pages inside [locale] when i18n locale dir is set", async () = const plan = await nextjsApp.scaffold(makeCtx({ i18nLocaleDir: "[locale]" })); - expect(plan.actions[2]!.path).toBe("app/[locale]/sign-in/[[...sign-in]]/page.tsx"); - expect(plan.actions[3]!.path).toBe("app/[locale]/sign-up/[[...sign-up]]/page.tsx"); + findAction(plan.actions, "app/[locale]/sign-in/[[...sign-in]]/page.tsx"); + findAction(plan.actions, "app/[locale]/sign-up/[[...sign-up]]/page.tsx"); }); test("places auth pages inside [lang] when i18n locale dir uses [lang]", async () => { @@ -308,8 +356,8 @@ test("places auth pages inside [lang] when i18n locale dir uses [lang]", async ( const plan = await nextjsApp.scaffold(makeCtx({ i18nLocaleDir: "[lang]" })); - expect(plan.actions[2]!.path).toBe("app/[lang]/sign-in/[[...sign-in]]/page.tsx"); - expect(plan.actions[3]!.path).toBe("app/[lang]/sign-up/[[...sign-up]]/page.tsx"); + findAction(plan.actions, "app/[lang]/sign-in/[[...sign-in]]/page.tsx"); + findAction(plan.actions, "app/[lang]/sign-up/[[...sign-up]]/page.tsx"); }); test("places auth pages inside src/app/[locale] when srcDir and i18n", async () => { @@ -321,8 +369,8 @@ test("places auth pages inside src/app/[locale] when srcDir and i18n", async () makeCtx({ srcDir: true, layoutPath: "src/app/layout.tsx", i18nLocaleDir: "[locale]" }), ); - expect(plan.actions[2]!.path).toBe("src/app/[locale]/sign-in/[[...sign-in]]/page.tsx"); - expect(plan.actions[3]!.path).toBe("src/app/[locale]/sign-up/[[...sign-up]]/page.tsx"); + findAction(plan.actions, "src/app/[locale]/sign-in/[[...sign-in]]/page.tsx"); + findAction(plan.actions, "src/app/[locale]/sign-up/[[...sign-up]]/page.tsx"); }); test("skips i18n auth page when it already exists inside [locale]", async () => { @@ -336,7 +384,7 @@ test("skips i18n auth page when it already exists inside [locale]", async () => const plan = await nextjsApp.scaffold(makeCtx({ i18nLocaleDir: "[locale]" })); - expect(plan.actions[2]).toMatchObject({ + expect(findAction(plan.actions, "app/[locale]/sign-in/[[...sign-in]]/page.tsx")).toMatchObject({ type: "skip", skipReason: "Sign-in page already exists", }); @@ -347,7 +395,7 @@ test("creates composed Clerk + next-intl middleware when next-intl is a dep", as await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); const plan = await nextjsApp.scaffold(makeCtx({ deps: { "next-intl": "4.0.0" } })); - const mw = plan.actions[0]!; + const mw = findAction(plan.actions, "middleware.ts"); expect(mw.type).toBe("create"); if (mw.type !== "create") throw new Error("Expected create action"); @@ -366,7 +414,7 @@ test("imports routing config in composed middleware when next-intl routing file await Bun.write(join(tempDir, "i18n/routing.ts"), "export const routing = {};"); const plan = await nextjsApp.scaffold(makeCtx({ deps: { "next-intl": "4.0.0" } })); - const mw = plan.actions[0]!; + const mw = findAction(plan.actions, "middleware.ts"); expect(mw.type).toBe("create"); if (mw.type !== "create") throw new Error("Expected create action"); @@ -391,7 +439,7 @@ export const config = { await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); const plan = await nextjsApp.scaffold(makeCtx()); - const mw = plan.actions[0]!; + const mw = findAction(plan.actions, "middleware.ts"); expect(mw.type).toBe("modify"); if (mw.type !== "modify") throw new Error("Expected modify action"); @@ -428,7 +476,7 @@ export const config = { await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); const plan = await nextjsApp.scaffold(makeCtx()); - const mw = plan.actions[0]!; + const mw = findAction(plan.actions, "middleware.ts"); expect(mw.type).toBe("modify"); if (mw.type !== "modify") throw new Error("Expected modify action"); @@ -462,7 +510,7 @@ export default wrapped; await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); const plan = await nextjsApp.scaffold(makeCtx()); - const mw = plan.actions[0]!; + const mw = findAction(plan.actions, "middleware.ts"); expect(mw.type).toBe("modify"); if (mw.type !== "modify") throw new Error("Expected modify action"); diff --git a/packages/cli-core/src/commands/init/frameworks/transformations.ts b/packages/cli-core/src/commands/init/frameworks/transformations.ts new file mode 100644 index 000000000..55bc10f1a --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/transformations.ts @@ -0,0 +1,70 @@ +/** + * Pure text/source-code transformation utilities. + * These functions take source code as input and return modified source code. + * Used by framework scaffolders for import injection, provider wrapping, and indentation. + */ +import { parseModule } from "magicast"; + +/** Check if file content already imports from a @clerk/ package. */ +export function hasClerkImport(content: string): boolean { + return content.includes("@clerk/"); +} + +export function indentBlock(content: string, indent: string): string { + return content + .split("\n") + .map((line) => `${indent}${line}`) + .join("\n"); +} + +/** + * Add an import to a file using magicast AST, with a string-prepend fallback. + * Returns the modified source code. + */ +export function safeAddImport(content: string, source: string, imported: string): string { + try { + const mod = parseModule(content); + mod.imports.$add({ from: source, imported, local: imported }); + return mod.generate().code; + } catch { + return `import { ${imported} } from "${source}";\n${content}`; + } +} + +/** 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; + return source.slice(0, lineEnd + 1) + snippet + source.slice(lineEnd + 1); +} + +/** Wrap the contents of a `` tag with a provider component (e.g. ``). */ +export function wrapBodyWithProvider(content: string, provider: string): string { + const bodyPattern = /^( *)(]*>)([\s\S]*?)(<\/body>)/m; + const match = bodyPattern.exec(content); + if (!match) return content; + + const [fullMatch, bodyIndent, openTag, inner, closeTag] = match; + const providerIndent = bodyIndent + " "; + const contentIndent = providerIndent + " "; + + const trimmedInner = inner.trim(); + const reindented = trimmedInner + .split("\n") + .map((line) => { + const stripped = line.trimStart(); + return stripped ? `${contentIndent}${stripped}` : ""; + }) + .join("\n"); + + const wrapped = [ + `${bodyIndent}${openTag}`, + `${providerIndent}<${provider}>`, + reindented, + `${providerIndent}`, + `${bodyIndent}${closeTag}`, + ].join("\n"); + + return content.replace(fullMatch!, wrapped); +} diff --git a/packages/cli-core/src/commands/init/heuristics.ts b/packages/cli-core/src/commands/init/heuristics.ts new file mode 100644 index 000000000..b7f1dd97d --- /dev/null +++ b/packages/cli-core/src/commands/init/heuristics.ts @@ -0,0 +1,107 @@ +import { join, dirname } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { dim, cyan, green, yellow, bold } from "../../lib/color.js"; +import { getToken } from "../../lib/credential-store.js"; +import { fetchUserInfo } from "../../lib/token-exchange.js"; +import { printFindings } from "./scan.js"; +import { pmInstallCommand } from "./prompts/index.js"; +import type { ProjectContext, ScaffoldPlan } from "./frameworks/types.js"; +import type { ScanFinding } from "./scan.js"; + +export async function installSdk(ctx: ProjectContext): Promise { + const addCmd = pmInstallCommand(ctx.packageManager); + console.log(`Installing ${cyan(ctx.framework.sdk)} for ${ctx.framework.name}...`); + + const proc = Bun.spawn(addCmd.split(" ").concat(ctx.framework.sdk), { + cwd: ctx.cwd, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + console.log( + yellow( + `Failed to install ${ctx.framework.sdk}. You can install it manually: ${addCmd} ${ctx.framework.sdk}`, + ), + ); + } +} + +export async function writePlan(cwd: string, plan: ScaffoldPlan): Promise { + const written: string[] = []; + + for (const action of plan.actions) { + if (action.type === "skip") continue; + + const fullPath = join(cwd, action.path); + + if (action.type === "create") { + await mkdir(dirname(fullPath), { recursive: true }); + } + + await Bun.write(fullPath, action.content); + written.push(action.path); + } + + return written; +} + +export async function checkGitDirty(cwd: string): Promise { + try { + const proc = Bun.spawn(["git", "status", "--porcelain"], { + cwd, + stdout: "pipe", + stderr: "ignore", + }); + const output = await new Response(proc.stdout).text(); + await proc.exited; + return output.trim().length > 0; + } catch { + return false; + } +} + +export function printOutro(plan: ScaffoldPlan, findings: ScanFinding[]): void { + const created = plan.actions.filter((a) => a.type === "create"); + const modified = plan.actions.filter((a) => a.type === "modify"); + const skipped = plan.actions.filter((a) => a.type === "skip"); + + console.log(bold(green("\n✓ Clerk has been set up in your project!\n"))); + + for (const a of created) { + console.log(` ${green("+")} ${a.path}`); + } + for (const a of modified) { + console.log(` ${yellow("~")} ${a.path}`); + } + for (const a of skipped) { + console.log(` ${dim("-")} ${dim(a.path)} ${dim(`(${a.skipReason})`)}`); + } + + if (plan.postInstructions.length > 0) { + console.log(dim("\nNext steps:")); + for (const instr of plan.postInstructions) { + console.log(dim(` • ${instr}`)); + } + } + + printFindings(findings); + + console.log(); +} + +/** + * Try to get the currently authenticated user's email without triggering login. + * Returns null if not authenticated or token is expired. + */ +export async function getAuthenticatedEmail(): Promise { + try { + const token = await getToken(); + if (!token) return null; + const userInfo = await fetchUserInfo(token); + return userInfo.email; + } catch { + return null; + } +} diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 5a37769e1..3e4563f0c 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -1,129 +1,25 @@ -import { join, dirname } from "node:path"; -import { mkdir } from "node:fs/promises"; import { login } from "../auth/login.js"; import { link } from "../link/index.js"; import { pull } from "../env/pull.js"; import { isAgent } from "../../mode.js"; -import { dim, cyan, green, yellow, bold } from "../../lib/color.js"; +import { dim, green, yellow, bold } from "../../lib/color.js"; import { CliError, throwUserAbort } from "../../lib/errors.js"; import { lookupFramework, FRAMEWORK_NAMES } from "../../lib/framework.js"; -import { getToken } from "../../lib/credential-store.js"; import { resolveProfile } from "../../lib/config.js"; -import { fetchUserInfo } from "../../lib/token-exchange.js"; import { gatherContext } from "./context.js"; import { scaffold, enrichProjectContext } from "./scaffold.js"; import { previewPlan, previewAndConfirm } from "./preview.js"; import { runFormatters } from "./format.js"; -import { detectAuthLibraries, scanForIssues, printFindings } from "./scan.js"; -import { buildAgentPrompt, GENERIC_AGENT_PROMPT, pmInstallCommand } from "./prompts/index.js"; -import type { ProjectContext, ScaffoldPlan } from "./frameworks/types.js"; -import type { ScanFinding } from "./scan.js"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -async function installSdk(ctx: ProjectContext): Promise { - const addCmd = pmInstallCommand(ctx.packageManager); - console.log(`Installing ${cyan(ctx.framework.sdk)} for ${ctx.framework.name}...`); - - const proc = Bun.spawn(addCmd.split(" ").concat(ctx.framework.sdk), { - cwd: ctx.cwd, - stdout: "inherit", - stderr: "inherit", - }); - const exitCode = await proc.exited; - - if (exitCode !== 0) { - console.log( - yellow( - `Failed to install ${ctx.framework.sdk}. You can install it manually: ${addCmd} ${ctx.framework.sdk}`, - ), - ); - } -} - -async function writePlan(cwd: string, plan: ScaffoldPlan): Promise { - const written: string[] = []; - - for (const action of plan.actions) { - if (action.type === "skip") continue; - - const fullPath = join(cwd, action.path); - - if (action.type === "create") { - await mkdir(dirname(fullPath), { recursive: true }); - } - - await Bun.write(fullPath, action.content); - written.push(action.path); - } - - return written; -} - -async function checkGitDirty(cwd: string): Promise { - try { - const proc = Bun.spawn(["git", "status", "--porcelain"], { - cwd, - stdout: "pipe", - stderr: "ignore", - }); - const output = await new Response(proc.stdout).text(); - await proc.exited; - return output.trim().length > 0; - } catch { - return false; - } -} - -function printOutro(plan: ScaffoldPlan, findings: ScanFinding[]): void { - const created = plan.actions.filter((a) => a.type === "create"); - const modified = plan.actions.filter((a) => a.type === "modify"); - const skipped = plan.actions.filter((a) => a.type === "skip"); - - console.log(bold(green("\n✓ Clerk has been set up in your project!\n"))); - - for (const a of created) { - console.log(` ${green("+")} ${a.path}`); - } - for (const a of modified) { - console.log(` ${yellow("~")} ${a.path}`); - } - for (const a of skipped) { - console.log(` ${dim("-")} ${dim(a.path)} ${dim(`(${a.skipReason})`)}`); - } - - if (plan.postInstructions.length > 0) { - console.log(dim("\nNext steps:")); - for (const instr of plan.postInstructions) { - console.log(dim(` • ${instr}`)); - } - } - - printFindings(findings); - - console.log(); -} - -/** - * Try to get the currently authenticated user's email without triggering login. - * Returns null if not authenticated or token is expired. - */ -async function getAuthenticatedEmail(): Promise { - try { - const token = await getToken(); - if (!token) return null; - const userInfo = await fetchUserInfo(token); - return userInfo.email; - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Main entry point -// --------------------------------------------------------------------------- +import { detectAuthLibraries, scanForIssues } from "./scan.js"; +import { buildAgentPrompt, GENERIC_AGENT_PROMPT } from "./prompts/index.js"; +import { + installSdk, + writePlan, + checkGitDirty, + printOutro, + getAuthenticatedEmail, +} from "./heuristics.js"; +import type { ProjectContext } from "./frameworks/types.js"; interface InitOptions { framework?: string; diff --git a/packages/cli-core/src/commands/init/prompts/astro.md b/packages/cli-core/src/commands/init/prompts/astro.md index 84de3f3d1..391160941 100644 --- a/packages/cli-core/src/commands/init/prompts/astro.md +++ b/packages/cli-core/src/commands/init/prompts/astro.md @@ -93,7 +93,7 @@ NEVER: import { authMiddleware } from '@clerk/astro' // WRONG — use clerkMiddleware // WRONG — use // WRONG — use -output: 'static' // WRONG — Clerk requires SSR +output: 'static' // WRONG — Clerk requires SSR ``` ## Verify Before Responding diff --git a/packages/cli-core/src/commands/init/prompts/index.ts b/packages/cli-core/src/commands/init/prompts/index.ts index ab9d9b129..65ddb5d4f 100644 --- a/packages/cli-core/src/commands/init/prompts/index.ts +++ b/packages/cli-core/src/commands/init/prompts/index.ts @@ -16,10 +16,6 @@ import expoMd from "./expo.md" with { type: "text" }; import expressMd from "./express.md" with { type: "text" }; import fastifyMd from "./fastify.md" with { type: "text" }; -// --------------------------------------------------------------------------- -// Template loading -// --------------------------------------------------------------------------- - const TEMPLATES = { generic: genericMd, "generic-fallback": genericFallbackMd, @@ -51,10 +47,6 @@ function interpolate(template: string, vars: Record): string { return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`); } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - const PM_COMMANDS = { bun: "bun add", yarn: "yarn add", @@ -98,10 +90,6 @@ const FRAMEWORK_PROMPTS: Record = { const DEFAULT_DOCS_URL = "https://clerk.com/docs"; -// --------------------------------------------------------------------------- -// Variable builders -// --------------------------------------------------------------------------- - type RequiredPromptVar = | "SDK" | "ENV_VAR" @@ -161,10 +149,6 @@ function resolveTemplate(ctx: ProjectContext): TemplateName { return FRAMEWORK_PROMPTS[ctx.framework.dep]?.template ?? "generic-fallback"; } -// --------------------------------------------------------------------------- -// Exports -// --------------------------------------------------------------------------- - export const GENERIC_AGENT_PROMPT = loadTemplate("generic"); export function buildAgentPrompt(ctx: ProjectContext): string { diff --git a/packages/cli-core/src/commands/init/scan.test.ts b/packages/cli-core/src/commands/init/scan.test.ts index 9730cbfb7..d06476698 100644 --- a/packages/cli-core/src/commands/init/scan.test.ts +++ b/packages/cli-core/src/commands/init/scan.test.ts @@ -4,10 +4,6 @@ import { mkdtemp, rm, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { detectAuthLibraries, scanForIssues } from "./scan.ts"; -// --------------------------------------------------------------------------- -// detectAuthLibraries -// --------------------------------------------------------------------------- - describe("detectAuthLibraries", () => { let consoleSpy: ReturnType; @@ -81,10 +77,6 @@ describe("detectAuthLibraries", () => { }); }); -// --------------------------------------------------------------------------- -// scanForIssues -// --------------------------------------------------------------------------- - describe("scanForIssues", () => { let tempDir: string; diff --git a/packages/cli-core/src/commands/init/scan.ts b/packages/cli-core/src/commands/init/scan.ts index 602df7e19..4dcc3d8a9 100644 --- a/packages/cli-core/src/commands/init/scan.ts +++ b/packages/cli-core/src/commands/init/scan.ts @@ -1,10 +1,6 @@ import { join } from "node:path"; import { yellow, dim, cyan } from "../../lib/color.js"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - type AuthLibraryScan = { packages: string[]; name: string; @@ -26,10 +22,6 @@ export type ScanFinding = { docsUrl?: string; }; -// --------------------------------------------------------------------------- -// Pre-scaffold: auth library detection -// --------------------------------------------------------------------------- - const AUTH_LIBRARY_SCANS: AuthLibraryScan[] = [ { packages: ["next-auth"], @@ -78,10 +70,6 @@ export function detectAuthLibraries(deps: Record): void { } } -// --------------------------------------------------------------------------- -// Post-scaffold: code scans -// --------------------------------------------------------------------------- - const CODE_SCANS: CodeScan[] = [ { pattern: "(?:NEXT_PUBLIC_)?CLERK_PUBLISHABLE_KEY\\s*=\\s*pk_", From 54a8e6138c5f4985013ce13fb9956f0ddd52a942 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 24 Mar 2026 18:40:31 -0300 Subject: [PATCH 35/35] test(init): add missing framework scaffolder tests Add test coverage for the 5 framework scaffolders that were missing tests (jfoshee #21/#22): - astro.test.ts (10 tests) - nuxt.test.ts (8 tests) - vue.test.ts (6 tests) - react.test.ts (7 tests) - nextjs-pages.test.ts (10 tests) All tests use semantic path-based lookups via findAction() helper. --- .../commands/init/frameworks/astro.test.ts | 260 ++++++++++++++++++ .../init/frameworks/nextjs-pages.test.ts | 199 ++++++++++++++ .../src/commands/init/frameworks/nuxt.test.ts | 183 ++++++++++++ .../commands/init/frameworks/react.test.ts | 176 ++++++++++++ .../src/commands/init/frameworks/vue.test.ts | 151 ++++++++++ 5 files changed, 969 insertions(+) create mode 100644 packages/cli-core/src/commands/init/frameworks/astro.test.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/nextjs-pages.test.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/nuxt.test.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/react.test.ts create mode 100644 packages/cli-core/src/commands/init/frameworks/vue.test.ts diff --git a/packages/cli-core/src/commands/init/frameworks/astro.test.ts b/packages/cli-core/src/commands/init/frameworks/astro.test.ts new file mode 100644 index 000000000..b7caf5f0e --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/astro.test.ts @@ -0,0 +1,260 @@ +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 { astro } from "./astro.ts"; +import type { FileAction, ProjectContext } from "./types.ts"; + +let tempDir: string; + +function makeCtx(overrides?: Partial): ProjectContext { + return { + cwd: tempDir, + framework: { + dep: "astro", + name: "Astro", + sdk: "@clerk/astro", + envVar: "PUBLIC_CLERK_PUBLISHABLE_KEY", + }, + typescript: true, + srcDir: false, + packageManager: "npm", + existingClerk: false, + deps: {}, + 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-astro-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +test("scaffolds all actions for a fresh Astro project", async () => { + await Bun.write( + join(tempDir, "astro.config.mjs"), + `import { defineConfig } from "astro/config"; + +export default defineConfig({ + integrations: [], +}); +`, + ); + + const plan = await astro.scaffold(makeCtx()); + + expect(plan.actions).toHaveLength(5); + + const config = findAction(plan.actions, "astro.config.mjs"); + expect(config.type).toBe("modify"); + if (config.type === "modify") { + expect(config.content).toContain("clerk"); + expect(config.content).toContain("@clerk/astro"); + } + + const mw = findAction(plan.actions, "src/middleware.ts"); + expect(mw.type).toBe("create"); + if (mw.type === "create") { + expect(mw.content).toContain("clerkMiddleware"); + expect(mw.content).toContain("onRequest"); + } + + const signIn = findAction(plan.actions, "src/pages/sign-in.astro"); + expect(signIn.type).toBe("create"); + if (signIn.type === "create") { + expect(signIn.content).toContain("SignIn"); + } + + const signUp = findAction(plan.actions, "src/pages/sign-up.astro"); + expect(signUp.type).toBe("create"); + + const env = findAction(plan.actions, ".env"); + expect(env.type).toBe("modify"); + if (env.type === "modify") { + expect(env.content).toContain("PUBLIC_CLERK_SIGN_IN_URL=/sign-in"); + expect(env.content).toContain("PUBLIC_CLERK_SIGN_UP_URL=/sign-up"); + } + + // Always includes SSR adapter post-instruction + expect(plan.postInstructions.some((i) => i.includes("output: 'server'"))).toBe(true); +}); + +test("skips config when @clerk/astro already present", async () => { + await Bun.write( + join(tempDir, "astro.config.mjs"), + `import { defineConfig } from "astro/config"; +import clerk from "@clerk/astro"; + +export default defineConfig({ + integrations: [clerk()], +}); +`, + ); + + const plan = await astro.scaffold(makeCtx()); + + expect(findAction(plan.actions, "astro.config.mjs")).toMatchObject({ + type: "skip", + skipReason: "Already has @clerk/astro integration", + }); +}); + +test("skips config when no config file found", async () => { + const plan = await astro.scaffold(makeCtx()); + + expect(findAction(plan.actions, "astro.config.mjs")).toMatchObject({ + type: "skip", + }); + const action = findAction(plan.actions, "astro.config.mjs"); + if (action.type === "skip") { + expect(action.skipReason).toContain("No Astro config file found"); + } +}); + +test("skips middleware when already has Clerk", async () => { + await Bun.write( + join(tempDir, "astro.config.mjs"), + `import { defineConfig } from "astro/config"; +export default defineConfig({ integrations: [] }); +`, + ); + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/middleware.ts"), + `import { clerkMiddleware } from "@clerk/astro/server"; +export const onRequest = clerkMiddleware(); +`, + ); + + const plan = await astro.scaffold(makeCtx()); + + expect(findAction(plan.actions, "src/middleware.ts")).toMatchObject({ + type: "skip", + skipReason: "Already has Clerk middleware", + }); +}); + +test("skips middleware when existing non-Clerk middleware found", async () => { + await Bun.write( + join(tempDir, "astro.config.mjs"), + `import { defineConfig } from "astro/config"; +export default defineConfig({ integrations: [] }); +`, + ); + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/middleware.ts"), + `export const onRequest = (context, next) => { + return next(); +}; +`, + ); + + const plan = await astro.scaffold(makeCtx()); + + expect(findAction(plan.actions, "src/middleware.ts")).toMatchObject({ + type: "skip", + skipReason: "Existing middleware found — add clerkMiddleware() manually", + }); +}); + +test("skips auth page when it already exists", async () => { + await Bun.write( + join(tempDir, "astro.config.mjs"), + `import { defineConfig } from "astro/config"; +export default defineConfig({ integrations: [] }); +`, + ); + await mkdir(join(tempDir, "src/pages"), { recursive: true }); + await Bun.write(join(tempDir, "src/pages/sign-in.astro"), "---\n---\n
existing
"); + + const plan = await astro.scaffold(makeCtx()); + + expect(findAction(plan.actions, "src/pages/sign-in.astro")).toMatchObject({ + type: "skip", + skipReason: "Sign-in page already exists", + }); +}); + +test("skips env vars when already set", async () => { + await Bun.write( + join(tempDir, "astro.config.mjs"), + `import { defineConfig } from "astro/config"; +export default defineConfig({ integrations: [] }); +`, + ); + await Bun.write( + join(tempDir, ".env"), + `PUBLIC_CLERK_SIGN_IN_URL=/sign-in\nPUBLIC_CLERK_SIGN_UP_URL=/sign-up\n`, + ); + + const plan = await astro.scaffold(makeCtx()); + + expect(findAction(plan.actions, ".env")).toMatchObject({ + type: "skip", + skipReason: "Sign-in/sign-up route vars already set", + }); +}); + +test("adds i18n post-instruction when i18n config detected", async () => { + await Bun.write( + join(tempDir, "astro.config.mjs"), + `import { defineConfig } from "astro/config"; + +export default defineConfig({ + integrations: [], + i18n: { + defaultLocale: "en", + locales: ["en", "es"], + }, +}); +`, + ); + + const plan = await astro.scaffold(makeCtx()); + + expect(plan.postInstructions.some((i) => i.includes("locale"))).toBe(true); +}); + +test("no i18n post-instruction without i18n config", async () => { + await Bun.write( + join(tempDir, "astro.config.mjs"), + `import { defineConfig } from "astro/config"; + +export default defineConfig({ + integrations: [], +}); +`, + ); + + const plan = await astro.scaffold(makeCtx()); + + // The only post-instruction should be about SSR, not i18n + expect(plan.postInstructions.some((i) => i.includes("locale"))).toBe(false); +}); + +test("uses .js extension when typescript is false", async () => { + await Bun.write( + join(tempDir, "astro.config.mjs"), + `import { defineConfig } from "astro/config"; +export default defineConfig({ integrations: [] }); +`, + ); + + const plan = await astro.scaffold(makeCtx({ typescript: false })); + + findAction(plan.actions, "src/middleware.js"); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-pages.test.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.test.ts new file mode 100644 index 000000000..aa36716d4 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.test.ts @@ -0,0 +1,199 @@ +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 { nextjsPages } from "./nextjs-pages.ts"; +import type { FileAction, ProjectContext } from "./types.ts"; + +let tempDir: string; + +function makeCtx(overrides?: Partial): ProjectContext { + return { + cwd: tempDir, + framework: { + dep: "next", + name: "Next.js", + sdk: "@clerk/nextjs", + envVar: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + }, + variant: "pages-router", + typescript: true, + srcDir: false, + packageManager: "npm", + existingClerk: false, + deps: {}, + layoutPath: null, + envFile: ".env.local", + middlewareBasename: "middleware", + ...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-nextjs-pages-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +test("scaffolds all actions for a fresh Next.js Pages Router project", async () => { + const plan = await nextjsPages.scaffold(makeCtx()); + + expect(plan.actions).toHaveLength(5); + + // Middleware + const mw = findAction(plan.actions, "middleware.ts"); + expect(mw.type).toBe("create"); + if (mw.type === "create") { + expect(mw.content).toContain("clerkMiddleware"); + expect(mw.content).toContain("createRouteMatcher"); + } + + // _app (created from template when no existing file) + const app = findAction(plan.actions, "pages/_app.tsx"); + expect(app.type).toBe("create"); + if (app.type === "create") { + expect(app.content).toContain("ClerkProvider"); + expect(app.content).toContain("AppProps"); + expect(app.content).toContain("pageProps"); + } + + // Auth pages + const signIn = findAction(plan.actions, "pages/sign-in/[[...sign-in]].tsx"); + expect(signIn.type).toBe("create"); + if (signIn.type === "create") { + expect(signIn.content).toContain("SignIn"); + } + + const signUp = findAction(plan.actions, "pages/sign-up/[[...sign-up]].tsx"); + expect(signUp.type).toBe("create"); + + // Env vars + const env = findAction(plan.actions, ".env.local"); + expect(env.type).toBe("modify"); + if (env.type === "modify") { + expect(env.content).toContain("NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in"); + expect(env.content).toContain("NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up"); + } +}); + +test("modifies existing _app by wrapping Component with ClerkProvider", async () => { + await mkdir(join(tempDir, "pages"), { recursive: true }); + await Bun.write( + join(tempDir, "pages/_app.tsx"), + `export default function MyApp({ Component, pageProps }) { + return ; +} +`, + ); + + const plan = await nextjsPages.scaffold(makeCtx({ layoutPath: "pages/_app.tsx" })); + + const app = findAction(plan.actions, "pages/_app.tsx"); + expect(app.type).toBe("modify"); + if (app.type === "modify") { + expect(app.content).toContain("ClerkProvider"); + expect(app.content).toContain("@clerk/nextjs"); + expect(app.content).toContain(" { + await mkdir(join(tempDir, "pages"), { recursive: true }); + await Bun.write( + join(tempDir, "pages/_app.tsx"), + `import { ClerkProvider } from "@clerk/nextjs"; +export default function MyApp({ Component, pageProps }) { + return ; +} +`, + ); + + const plan = await nextjsPages.scaffold(makeCtx({ layoutPath: "pages/_app.tsx" })); + + expect(findAction(plan.actions, "pages/_app.tsx")).toMatchObject({ + type: "skip", + skipReason: "Already has ClerkProvider", + }); +}); + +test("skips middleware when already has Clerk", async () => { + await Bun.write( + join(tempDir, "middleware.ts"), + `import { clerkMiddleware } from "@clerk/nextjs/server";\nexport default clerkMiddleware();`, + ); + + const plan = await nextjsPages.scaffold(makeCtx()); + + expect(findAction(plan.actions, "middleware.ts")).toMatchObject({ + type: "skip", + skipReason: "Already has Clerk middleware", + }); +}); + +test("skips auth page when it already exists", async () => { + await mkdir(join(tempDir, "pages/sign-in/[[...sign-in]]"), { recursive: true }); + await Bun.write( + join(tempDir, "pages/sign-in/[[...sign-in]].tsx"), + "export default function() {}", + ); + + const plan = await nextjsPages.scaffold(makeCtx()); + + expect(findAction(plan.actions, "pages/sign-in/[[...sign-in]].tsx")).toMatchObject({ + type: "skip", + skipReason: "Sign-in page already exists", + }); +}); + +test("adds i18n post-instruction when next-intl detected", async () => { + const plan = await nextjsPages.scaffold(makeCtx({ deps: { "next-intl": "3.0.0" } })); + + expect(plan.postInstructions.some((i) => i.includes("i18n"))).toBe(true); +}); + +test("adds i18n post-instruction when next-i18next detected", async () => { + const plan = await nextjsPages.scaffold(makeCtx({ deps: { "next-i18next": "14.0.0" } })); + + expect(plan.postInstructions.some((i) => i.includes("i18n"))).toBe(true); +}); + +test("no i18n instruction without i18n deps", async () => { + const plan = await nextjsPages.scaffold(makeCtx({ deps: {} })); + + expect(plan.postInstructions).toHaveLength(0); +}); + +test("uses .jsx extension when typescript is false", async () => { + const plan = await nextjsPages.scaffold(makeCtx({ typescript: false })); + + findAction(plan.actions, "middleware.js"); + + const app = findAction(plan.actions, "pages/_app.jsx"); + expect(app.type).toBe("create"); + if (app.type === "create") { + expect(app.content).not.toContain("AppProps"); + } + + findAction(plan.actions, "pages/sign-in/[[...sign-in]].jsx"); + findAction(plan.actions, "pages/sign-up/[[...sign-up]].jsx"); +}); + +test("uses src/ paths when srcDir is true", async () => { + const plan = await nextjsPages.scaffold(makeCtx({ srcDir: true })); + + findAction(plan.actions, "src/middleware.ts"); + findAction(plan.actions, "src/pages/_app.tsx"); + findAction(plan.actions, "src/pages/sign-in/[[...sign-in]].tsx"); + findAction(plan.actions, "src/pages/sign-up/[[...sign-up]].tsx"); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/nuxt.test.ts b/packages/cli-core/src/commands/init/frameworks/nuxt.test.ts new file mode 100644 index 000000000..87a98ccdc --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/nuxt.test.ts @@ -0,0 +1,183 @@ +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 { nuxt } from "./nuxt.ts"; +import type { FileAction, ProjectContext } from "./types.ts"; + +let tempDir: string; + +function makeCtx(overrides?: Partial): ProjectContext { + return { + cwd: tempDir, + framework: { + dep: "nuxt", + name: "Nuxt", + sdk: "@clerk/nuxt", + envVar: "NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + }, + typescript: true, + srcDir: false, + packageManager: "npm", + existingClerk: false, + deps: {}, + 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-nuxt-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +test("scaffolds all actions for a fresh Nuxt project", async () => { + await Bun.write( + join(tempDir, "nuxt.config.ts"), + `export default defineNuxtConfig({ + modules: [], +}); +`, + ); + + const plan = await nuxt.scaffold(makeCtx()); + + expect(plan.actions).toHaveLength(4); + + const config = findAction(plan.actions, "nuxt.config.ts"); + expect(config.type).toBe("modify"); + if (config.type === "modify") { + expect(config.content).toContain("@clerk/nuxt"); + } + + const signIn = findAction(plan.actions, "pages/sign-in.vue"); + expect(signIn.type).toBe("create"); + if (signIn.type === "create") { + expect(signIn.content).toContain("