diff --git a/.changeset/env-pull-next.md b/.changeset/env-pull-next.md new file mode 100644 index 00000000..c0ee2db7 --- /dev/null +++ b/.changeset/env-pull-next.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Default `clerk env pull` to `.env.local` on Next.js projects with no existing env file, matching the framework's convention for local secrets. Projects that already have keys in `.env` continue to write there. diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index 1a1ebf90..9de8dcae 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -500,11 +500,11 @@ describe("env pull", () => { await runEnvPull(); - // Next.js prefers .env (gitignored by create-next-app via .env* pattern) - const content = await Bun.file(join(tempDir, ".env")).text(); + // Next.js prefers .env.local (always gitignored, per Next.js convention for secrets) + const content = await Bun.file(join(tempDir, ".env.local")).text(); expect(content).toContain("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_abc123"); - // Should NOT have created .env.local - expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); + // Should NOT have created .env + expect(await Bun.file(join(tempDir, ".env")).exists()).toBe(false); }); test("Next.js writes to existing .env.local if it already has Clerk keys", async () => { diff --git a/packages/cli-core/src/commands/init/context.test.ts b/packages/cli-core/src/commands/init/context.test.ts index eb3f4ba8..ebeea37b 100644 --- a/packages/cli-core/src/commands/init/context.test.ts +++ b/packages/cli-core/src/commands/init/context.test.ts @@ -206,7 +206,7 @@ test("Next.js: uses .env when only .env exists", async () => { expect(ctx!.envFile).toBe(".env"); }); -test("Next.js: uses .env when neither .env nor .env.local exists", async () => { +test("Next.js: uses .env.local when neither .env nor .env.local exists", async () => { await Bun.write( join(tempDir, "package.json"), JSON.stringify({ dependencies: { next: "15.0.0" } }), @@ -214,7 +214,7 @@ test("Next.js: uses .env when neither .env nor .env.local exists", async () => { const ctx = await gatherContext(tempDir); - expect(ctx!.envFile).toBe(".env"); + expect(ctx!.envFile).toBe(".env.local"); }); test("Next.js: uses .env.local when both .env and .env.local exist (keep existing setup)", async () => { diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 4efcf8a7..2f05a203 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -650,14 +650,14 @@ describe("init", () => { name: "Next.js", sdk: "@clerk/nextjs", envVar: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", - envFile: ".env" as const, + envFile: ".env.local" as const, }, typescript: true, srcDir: false, packageManager: "npm" as const, existingClerk: false, deps: { next: "15.0.0" }, - envFile: ".env", + envFile: ".env.local", }; gatherContextSpy.mockResolvedValue(mockCtx); @@ -668,7 +668,7 @@ describe("init", () => { await init({ yes: true }); - expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: mockCtx.cwd }); + expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env.local", cwd: mockCtx.cwd }); }); test("bootstrap passes project dir to link, not parent cwd", async () => { diff --git a/packages/cli-core/src/lib/framework.ts b/packages/cli-core/src/lib/framework.ts index f2b5ed86..7bdebe3f 100644 --- a/packages/cli-core/src/lib/framework.ts +++ b/packages/cli-core/src/lib/framework.ts @@ -15,8 +15,9 @@ export interface FrameworkInfo { envVar: string; /** Override for secret key env var name. Defaults to CLERK_SECRET_KEY when omitted. */ secretKeyEnvVar?: string; - /** Preferred env file for secrets. Frameworks that gitignore `.env` use it - * directly; Vite-based frameworks use `.env.local` since `.env` is tracked. */ + /** Preferred env file for secrets when the project has none yet. Frameworks + * with a `.env.local` convention use it (always gitignored, per-machine + * overrides); frameworks without that convention fall back to `.env`. */ envFile: ".env" | ".env.local"; /** When true, the framework's Clerk SDK supports keyless mode (auto-generated * temporary dev keys). Frameworks without keyless support require API keys @@ -31,7 +32,7 @@ export const FRAMEWORK_MAP: FrameworkInfo[] = [ name: "Next.js", sdk: "@clerk/nextjs", envVar: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", - envFile: ".env", + envFile: ".env.local", supportsKeyless: true, }, { diff --git a/packages/cli-core/src/lib/keyless.test.ts b/packages/cli-core/src/lib/keyless.test.ts index edc55496..b8da3885 100644 --- a/packages/cli-core/src/lib/keyless.test.ts +++ b/packages/cli-core/src/lib/keyless.test.ts @@ -160,8 +160,8 @@ describe("writeKeysToEnvFile", () => { }), ); - // Next.js declares envFile: ".env" in FRAMEWORK_MAP - const content = await Bun.file(join(tempDir, ".env")).text(); + // Next.js declares envFile: ".env.local" in FRAMEWORK_MAP + const content = await Bun.file(join(tempDir, ".env.local")).text(); expect(content).toContain("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_next"); expect(content).toContain("CLERK_SECRET_KEY=sk_test_next"); }); diff --git a/packages/cli-core/src/test/integration/deploy-to-prod.test.ts b/packages/cli-core/src/test/integration/deploy-to-prod.test.ts index 41ea9f46..9edcbdb2 100644 --- a/packages/cli-core/src/test/integration/deploy-to-prod.test.ts +++ b/packages/cli-core/src/test/integration/deploy-to-prod.test.ts @@ -39,7 +39,7 @@ test.each([{ mode: "human" }, { mode: "agent" }])( // Pull dev env (default) await clerk("--mode", mode, "env", "pull"); - const devEnv = parseEnvFile(await Bun.file(join(h.tempDir, ".env")).text(), ".env"); + const devEnv = parseEnvFile(await Bun.file(join(h.tempDir, ".env.local")).text(), ".env.local"); expect(devEnv.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBe(devInstance.publishable_key); expect(devEnv.get("CLERK_SECRET_KEY")).toBe(devInstance.secret_key); @@ -53,7 +53,10 @@ test.each([{ mode: "human" }, { mode: "agent" }])( expect(prodEnv.get("CLERK_SECRET_KEY")).toBe(prodInstance.secret_key); // Dev file not overwritten - const devEnvAfter = parseEnvFile(await Bun.file(join(h.tempDir, ".env")).text(), ".env"); + const devEnvAfter = parseEnvFile( + await Bun.file(join(h.tempDir, ".env.local")).text(), + ".env.local", + ); expect(devEnvAfter.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBe(devInstance.publishable_key); // Config pull targets prod instance diff --git a/packages/cli-core/src/test/integration/error-recovery.test.ts b/packages/cli-core/src/test/integration/error-recovery.test.ts index a381581a..9d2b76fc 100644 --- a/packages/cli-core/src/test/integration/error-recovery.test.ts +++ b/packages/cli-core/src/test/integration/error-recovery.test.ts @@ -60,7 +60,7 @@ describe("Recover from errors gracefully", () => { [`/applications/${MOCK_APP.application_id}`]: MOCK_APP, }); await clerk("--mode", "human", "env", "pull"); - const env = parseEnvFile(await Bun.file(join(h.tempDir, ".env")).text(), ".env"); + const env = parseEnvFile(await Bun.file(join(h.tempDir, ".env.local")).text(), ".env.local"); expect(env.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBe(devInstance.publishable_key); expect(env.get("CLERK_SECRET_KEY")).toBe(devInstance.secret_key); }); diff --git a/packages/cli-core/src/test/integration/onboard.test.ts b/packages/cli-core/src/test/integration/onboard.test.ts index 2656a0c3..864f4910 100644 --- a/packages/cli-core/src/test/integration/onboard.test.ts +++ b/packages/cli-core/src/test/integration/onboard.test.ts @@ -26,7 +26,7 @@ test.each([ framework: "Next.js", deps: { next: "15.0.0" }, expectedKey: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", - envFile: ".env", + envFile: ".env.local", }, { framework: "React/Vite", diff --git a/packages/cli-core/src/test/integration/switch-apps.test.ts b/packages/cli-core/src/test/integration/switch-apps.test.ts index 68873ff5..2a0118bc 100644 --- a/packages/cli-core/src/test/integration/switch-apps.test.ts +++ b/packages/cli-core/src/test/integration/switch-apps.test.ts @@ -42,7 +42,7 @@ test("re-link from one app to another", async () => { // Pull env for App A await clerk("--mode", "human", "env", "pull"); - let env = parseEnvFile(await Bun.file(join(h.tempDir, ".env")).text(), ".env"); + let env = parseEnvFile(await Bun.file(join(h.tempDir, ".env.local")).text(), ".env.local"); expect(env.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBe(appADev.publishable_key); // Unlink @@ -61,7 +61,7 @@ test("re-link from one app to another", async () => { // Pull env for App B — should overwrite App A's values, not append await clerk("--mode", "human", "env", "pull"); - env = parseEnvFile(await Bun.file(join(h.tempDir, ".env")).text(), ".env"); + env = parseEnvFile(await Bun.file(join(h.tempDir, ".env.local")).text(), ".env.local"); expect(env.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBe(appBDev.publishable_key); expect(env.get("CLERK_SECRET_KEY")).toBe(appBDev.secret_key); });