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/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=="], 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" } } 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/README.md b/packages/cli-core/src/commands/init/README.md index 26595b289..d31368fb6 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -1,39 +1,140 @@ # 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 ```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. + ## 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 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..14311389f --- /dev/null +++ b/packages/cli-core/src/commands/init/context.test.ts @@ -0,0 +1,424 @@ +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 } from "./context.ts"; +import { enrichProjectContext, scaffold } from "./scaffold.ts"; +import { parseMajorVersion } from "./frameworks/helpers.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); + await enrichProjectContext(ctx!); + + 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); + await enrichProjectContext(ctx!); + + 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); + await enrichProjectContext(ctx!); + + 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); + await enrichProjectContext(ctx!); + + 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); + await enrichProjectContext(ctx!); + + 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); + await enrichProjectContext(ctx!); + + 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); + await enrichProjectContext(ctx!); + + 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); + await enrichProjectContext(ctx!); + + 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); + await enrichProjectContext(ctx!); + + 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); + await enrichProjectContext(ctx!); + + // 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); + await enrichProjectContext(ctx!); + + // Even though version is 16 (would normally pick proxy), middleware.ts exists + 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); + 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.find((a) => a.path === "proxy.ts")).toBeDefined(); +}); 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..566658663 --- /dev/null +++ b/packages/cli-core/src/commands/init/context.ts @@ -0,0 +1,76 @@ +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 { + return Bun.file(path).exists(); +} + +export 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"; + +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")); + + 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 packageManager = await detectPackageManager(cwd); + + const deps = await readDeps(cwd); + const existingClerk = deps ? Object.keys(deps).some((d) => d.startsWith("@clerk/")) : false; + + const envFile = (await fileExists(join(cwd, ".env.local"))) ? ".env.local" : ".env"; + + return { + cwd, + framework, + typescript, + srcDir, + packageManager, + existingClerk, + deps: deps ?? {}, + envFile, + }; +} 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..76d5774df --- /dev/null +++ b/packages/cli-core/src/commands/init/format.ts @@ -0,0 +1,35 @@ +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; + + for (const formatter of FORMATTERS) { + if (!(formatter.pkg in deps)) continue; + + const proc = Bun.spawn(formatter.args(files), { + cwd, + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; + } +} 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/astro.ts b/packages/cli-core/src/commands/init/frameworks/astro.ts new file mode 100644 index 000000000..d242aeea3 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/astro.ts @@ -0,0 +1,176 @@ +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"; + +function middlewareContent(): string { + return `import { clerkMiddleware } from "@clerk/astro/server"; + +export const onRequest = clerkMiddleware(); +`; +} + +function authPageContent(kind: "sign-in" | "sign-up", tailwind: boolean): string { + const component = authComponentName(kind); + + return `--- +import { ${component} } from "@clerk/astro/components"; +--- + +${htmlAuthComponentMarkup(component, tailwind)} +`; +} + +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 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(), "); + } + 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)); +} + +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", + }, + }); +} + +async function scaffoldMiddleware(ctx: ProjectContext): Promise { + const ext = scriptExt(ctx); + const path = `src/middleware.${ext}`; + const file = Bun.file(join(ctx.cwd, path)); + + if (!(await file.exists())) { + return { + path, + 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, + skipReason: "Existing middleware found — add clerkMiddleware() manually", + }; +} + +/** 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", + minMajorVersion: 3, + + matches: (ctx) => ctx.framework.dep === "astro", + + async scaffold(ctx: ProjectContext): Promise { + const tailwind = hasTailwindStyles(ctx); + const [configAction, middlewareAction, authActions, envAction, i18n] = await Promise.all([ + scaffoldConfig(ctx), + scaffoldMiddleware(ctx), + 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, envAction], + 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..42796f1ae --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/helpers.ts @@ -0,0 +1,625 @@ +/** + * 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 { 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"; +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"; +type AuthFileSpec = { + path: string; + content: string; + 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. + * 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; +} + +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"; +} + +export function hasTailwindStyles(ctx: Pick): boolean { + return Boolean(ctx.deps["tailwindcss"]); +} + +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) { + if (await Bun.file(join(cwd, candidate)).exists()) return candidate; + } + 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; +} + +/** 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"; +} + +// ─── 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"; + +${nextjsPublicRouteMatcher()} + +${nextjsMiddlewareHandler()} + +${nextjsMiddlewareConfig()} +`; +} + +/** + * 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(.*)"]);`; +} + +function nextjsMiddlewareHandler(returnStatement = ""): string { + const returnLine = returnStatement ? `\n return ${returnStatement};` : ""; + + return `export default clerkMiddleware(async (auth, request) => { + if (!isPublicRoute(request)) { + await auth.protect(); + }${returnLine} +});`; +} + +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)(.*)", + ], +};`; +} + +export function authComponentName(kind: AuthKind): "SignIn" | "SignUp" { + return kind === "sign-in" ? "SignIn" : "SignUp"; +} + +/** Generate a JSX auth page component for a Clerk framework SDK that exports SignIn/SignUp. */ +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 ( +${content} + ); +} +`; +} + +/** + * Compose Clerk middleware with existing non-Clerk middleware. + * Renames the existing default export and wraps it inside clerkMiddleware. + */ +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 middleware"); + } + + const arrowExportPattern = /export\s+default\s+(?:async\s+)?(\([^)]*\)\s*=>)/; + if (arrowExportPattern.test(existing)) { + 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; +} + +function hasMiddlewareConfigExport(existing: string): boolean { + return /export\s+const\s+config\s*=/.test(existing); +} + +export function composeWithExistingMiddleware(existing: string, i18n = false): string | null { + const clerkImport = `import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";\n`; + const routeMatcher = `\n${nextjsPublicRouteMatcher(i18n)}\n`; + const preamble = clerkImport + routeMatcher + "\n"; + + if (hasMiddlewareConfigExport(existing)) { + return null; + } + + if (!/export\s+default\s+/.test(existing)) { + return `${preamble}${existing}\n${nextjsMiddlewareHandler()}\n\n${nextjsMiddlewareConfig()}\n`; + } + + const content = renameDefaultMiddlewareExport(existing); + if (!content) return null; + + return ( + preamble + + content + + `\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; + srcDir: boolean; + typescript: boolean; + deps?: Record; + middlewareBasename?: "proxy" | "middleware"; +}): Promise { + 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)); + + 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", + 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" }; + } + + // 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", + path, + skipReason: "Existing middleware uses an unsupported shape for automatic Clerk composition", + }; + } + + return { + path, + type: "modify", + content: composedContent, + 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", + }; +} + +/** 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. + * 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 scaffoldAuthFile( + cwd: string, + path: string, + content: string, + kind: AuthKind, + surface: AuthSurface, +): Promise { + 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 = authComponentName(kind); + return { + path, + type: "create", + content, + 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)), + ); +} 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..1b8cacecc --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts @@ -0,0 +1,521 @@ +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 { 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: "app-router", + typescript: true, + srcDir: false, + packageManager: "npm", + existingClerk: false, + deps: {}, + layoutPath: "app/layout.tsx", + envFile: ".env.local", + middlewareBasename: "middleware", + ...overrides, + }; +} + +/** 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-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +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"), + `export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +`, + ); + + const plan = await nextjsApp.scaffold(makeCtx()); + + expect(plan.actions).toHaveLength(5); + + // Middleware + 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(mw.content).not.toContain("/:locale/"); + } + + // Layout + const layout = findAction(plan.actions, "app/layout.tsx"); + expect(layout.type).toBe("modify"); + + // Sign-in + const signIn = findAction(plan.actions, "app/sign-in/[[...sign-in]]/page.tsx"); + expect(signIn.type).toBe("create"); + + // Sign-up + const signUp = findAction(plan.actions, "app/sign-up/[[...sign-up]]/page.tsx"); + expect(signUp.type).toBe("create"); + + // Env vars + const env = findAction(plan.actions, ".env.local"); + expect(env.type).toBe("modify"); +}); + +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(findAction(plan.actions, "middleware.ts")).toMatchObject({ + type: "skip", + skipReason: "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(findAction(plan.actions, "app/layout.tsx")).toMatchObject({ + type: "skip", + skipReason: "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(findAction(plan.actions, "app/sign-in/[[...sign-in]]/page.tsx")).toMatchObject({ + type: "skip", + skipReason: "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" }), + ); + + 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 () => { + 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" }), + ); + + 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 () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx()); + + 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); +}); + +test("returns skip action when no layout found", async () => { + const plan = await nextjsApp.scaffold(makeCtx({ layoutPath: null })); + + // 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"), + `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()); + + const mw = findAction(plan.actions, "middleware.ts"); + expect(mw.type).toBe("modify"); +}); + +test("composes with expression export middleware (variable default export)", 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()); + + 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(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)"); + } +}); + +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(findAction(plan.actions, "middleware.ts")).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 mw = findAction(plan.actions, "middleware.ts"); + + expect(mw.type).toBe("modify"); + if (mw.type !== "modify") { + throw new Error("Expected middleware action to modify middleware.ts"); + } + + 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 () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/layout.tsx"), "{children}"); + + const plan = await nextjsApp.scaffold(makeCtx({ middlewareBasename: "proxy" })); + + findAction(plan.actions, "proxy.ts"); +}); + +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" }), + ); + + findAction(plan.actions, "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]" })); + + 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 () => { + 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]" })); + + 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 () => { + 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]" }), + ); + + 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 () => { + 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(findAction(plan.actions, "app/[locale]/sign-in/[[...sign-in]]/page.tsx")).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 = findAction(plan.actions, "middleware.ts"); + + 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 = findAction(plan.actions, "middleware.ts"); + + 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 = findAction(plan.actions, "middleware.ts"); + + 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 = findAction(plan.actions, "middleware.ts"); + + 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 = findAction(plan.actions, "middleware.ts"); + + 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/nextjs-app.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts new file mode 100644 index 000000000..29acd90b5 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.ts @@ -0,0 +1,99 @@ +import { join } from "node:path"; +import { + authFileSpecs, + hasTailwindStyles, + jsxAuthPageContent, + jsxExt, + safeAddImport, + scaffoldAuthFiles, + scaffoldEnvVars, + scaffoldNextjsMiddleware, + SIGN_ROUTE_ENV_VARS, + 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 = srcPrefix(ctx); + const jsx = jsxExt(ctx); + 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 { type: "skip", path: ctx.layoutPath, skipReason: "Layout file not found" }; + } + + const content = await file.text(); + + if (content.includes("ClerkProvider")) { + return { type: "skip", path: ctx.layoutPath, skipReason: "Already has ClerkProvider" }; + } + + let newContent = safeAddImport(content, "@clerk/nextjs", "ClerkProvider"); + + // 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(" { + const tailwind = hasTailwindStyles(ctx); + return scaffoldAuthFiles( + ctx.cwd, + authFileSpecs({ + path: (kind) => authPagePath(ctx, kind), + content: (kind) => jsxAuthPageContent(kind, "@clerk/nextjs", tailwind), + surface: "page", + }), + ); +} + +export const nextjsApp: FrameworkScaffold = { + name: "Next.js (App Router)", + dep: "next", + variant: "app-router", + minMajorVersion: 13, + + enrichContext: enrichNextjsContext, + + matches: (ctx) => ctx.framework.dep === "next" && ctx.variant !== "pages-router", + + async scaffold(ctx: ProjectContext): Promise { + 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, envAction], + postInstructions: [], + }; + }, +}; 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..2248633b2 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-context.ts @@ -0,0 +1,120 @@ +import { join } from "node:path"; +import { fileExists, dirExists } from "../context.js"; +import { findFirstFile, resolveNextjsMiddlewareBasename, scriptExt, srcPrefix } 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 = srcPrefix({ srcDir }); + + // 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 = srcPrefix({ srcDir }); + + 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}`]); +} + +/** + * 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, i18nLocaleDir. + */ +export async function enrichNextjsContext(ctx: ProjectContext): Promise { + const ext = scriptExt(ctx); + + 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, + }); + + 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.layoutPath = layoutPath; + ctx.middlewareBasename = middlewareBasename; + if (i18nLocaleDir) ctx.i18nLocaleDir = i18nLocaleDir; +} 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/nextjs-pages.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts new file mode 100644 index 000000000..afba9c7f5 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-pages.ts @@ -0,0 +1,130 @@ +import { join } from "node:path"; +import { + authFileSpecs, + hasTailwindStyles, + jsxAuthPageContent, + jsxExt, + safeAddImport, + scaffoldAuthFiles, + scaffoldEnvVars, + scaffoldNextjsMiddleware, + SIGN_ROUTE_ENV_VARS, + srcPrefix, +} from "./helpers.js"; +import { enrichNextjsContext } from "./nextjs-context.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 = srcPrefix(ctx); + const ext = jsxExt(ctx); + const path = `${base}pages/_app.${ext}`; + const file = Bun.file(join(ctx.cwd, path)); + + if (!(await file.exists())) { + return { + path, + 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: "modify", + content: newContent, + description: "Add ClerkProvider import and wrap Component", + }; +} + +function authPagePath(ctx: ProjectContext, kind: "sign-in" | "sign-up"): string { + return `${srcPrefix(ctx)}pages/${kind}/[[...${kind}]].${jsxExt(ctx)}`; +} + +async function scaffoldAuthPages(ctx: ProjectContext): Promise { + const tailwind = hasTailwindStyles(ctx); + return scaffoldAuthFiles( + ctx.cwd, + authFileSpecs({ + path: (kind) => authPagePath(ctx, kind), + content: (kind) => jsxAuthPageContent(kind, "@clerk/nextjs", tailwind), + surface: "page", + }), + ); +} + +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 [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, envAction], + postInstructions, + }; + }, +}; 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("