From baabc3ec058701238ac83ebf0a6c2d289d77f56a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 7 Apr 2026 08:47:11 -0600 Subject: [PATCH 1/2] refactor(init): short-circuit on fully-clean re-run When `clerk init` runs against a project where Clerk is already set up (supported framework, SDK installed, every scaffold action would be a skip, no postInstructions), the init flow used to print "Clerk is already set up" inside scaffoldAndWrite but kept running env pull, the skills prompt, and the outro. The message lied about init completing. This refactors detectAndInstall and scaffoldAndWrite to return an { alreadySetUp: boolean } flag. init() owns the green message and short-circuits the entire post-scaffold flow when alreadySetUp is true. The "framework detected but unsupported" path (Express/Fastify/Expo) still falls through because postInstructions are useful guidance the user should see every time. Adds a test that overrides the default null gatherContext mock with a stub Next.js context and asserts pull, printKeylessInfo, and installSkills are NOT called when scaffold returns an empty plan. --- .../cli-core/src/commands/init/index.test.ts | 25 +++++++++++++ packages/cli-core/src/commands/init/index.ts | 35 ++++++++++++++----- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 908a2cd0..46a94ea5 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -71,4 +71,29 @@ describe("init command", () => { expect(pullMod.pull).toHaveBeenCalled(); expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); }); + + test("short-circuits env pull and skills install when already set up", async () => { + setup({ email: "test@test.com" }); + + // Override the default null gatherContext with a minimal supported-framework + // context so detectAndInstall reaches the alreadySetUp path. + spies.find((s) => s === (context.gatherContext as unknown))?.mockRestore(); + spyOn(context, "gatherContext").mockResolvedValue({ + cwd: "/tmp/fake", + framework: { name: "Next.js", dep: "next", sdk: "@clerk/nextjs", publishableKeyEnv: "x" }, + deps: { next: "15.0.0" }, + packageManager: "bun", + typescript: true, + srcDir: false, + existingClerk: true, + } as never); + + await init({ yes: true }); + + // Link still runs (it's before detectAndInstall); env pull and skills are skipped. + expect(linkMod.link).toHaveBeenCalledWith({ skipIfLinked: true }); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + expect(skillsMod.installSkills).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 7bdfc5ba..e268c942 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -61,7 +61,15 @@ export async function init(options: InitOptions = {}) { await link({ skipIfLinked: true }); } - await detectAndInstall(cwd, ctx, options); + // Short-circuit on a fully-clean re-run so env pull / skills prompt don't + // execute when there's nothing to do. + const { alreadySetUp } = await detectAndInstall(cwd, ctx, options); + + if (alreadySetUp) { + console.log(green("\nClerk is already set up in this project.")); + outro("Done"); + return; + } bar(); if (authenticated) { @@ -91,16 +99,25 @@ async function resolveAuth(cwd: string): Promise { return true; } +/** + * Run framework detection, SDK install, and scaffolding. + * + * Returns `alreadySetUp: true` only when the project has a supported + * framework, the SDK is installed, all scaffold actions are skips, and there + * are no postInstructions — i.e. a fully-clean re-run. The "no framework" + * and "framework detected but unsupported" branches return `false` so the + * caller still pulls env keys and offers the skills install. + */ async function detectAndInstall( cwd: string, ctx: ProjectContext | null, options: InitOptions, -): Promise { +): Promise<{ alreadySetUp: boolean }> { if (!ctx) { console.log( `Could not detect a framework. Install the appropriate Clerk SDK manually: ${dim("https://clerk.com/docs")}`, ); - return; + return { alreadySetUp: false }; } const variantLabel = ctx.variant ? ` (${ctx.variant})` : ""; @@ -115,20 +132,20 @@ async function detectAndInstall( await installSdk(ctx); } - await scaffoldAndWrite(cwd, ctx, options); + return await scaffoldAndWrite(cwd, ctx, options); } async function scaffoldAndWrite( cwd: string, ctx: ProjectContext, options: InitOptions, -): Promise { +): Promise<{ alreadySetUp: boolean }> { const plan = await scaffold(ctx); const hasChanges = plan.actions.some((a) => a.type !== "skip"); + // Fully-clean re-run: signal to init() to skip env pull / skills install. if (!hasChanges && plan.postInstructions.length === 0) { - console.log(green("\nClerk is already set up in this project.")); - return; + return { alreadySetUp: true }; } if (!hasChanges) { @@ -136,7 +153,7 @@ async function scaffoldAndWrite( for (const instr of plan.postInstructions) { console.log(dim(` • ${instr}`)); } - return; + return { alreadySetUp: false }; } if (await checkGitDirty(cwd)) { @@ -159,4 +176,6 @@ async function scaffoldAndWrite( scanForIssues(cwd, ctx.framework.dep), ); printOutro(plan, findings); + + return { alreadySetUp: false }; } From 5782195d0cec5a39e6bd4839aaa479ff157223bf Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 7 Apr 2026 15:42:03 -0600 Subject: [PATCH 2/2] test(init): return gatherContext spy as named handle Addresses review nit on #117: the previous spies.find(...) lookup never matched (spy !== module export), making the mockRestore a no-op. Return gatherContextSpy from setup() so tests can call mockResolvedValueOnce directly. --- packages/cli-core/src/commands/init/index.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 46a94ea5..c166fdc0 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -27,12 +27,14 @@ describe("init command", () => { function setup(overrides: { email?: string | null } = {}) { const email = overrides.email ?? null; + const gatherContextSpy = spyOn(context, "gatherContext").mockResolvedValue(null); + spies = [ spyOn(console, "log").mockImplementation(() => {}), spyOn(mode, "isAgent").mockReturnValue(false), spyOn(config, "resolveProfile").mockResolvedValue(undefined), spyOn(frameworkMod, "lookupFramework").mockReturnValue(null), - spyOn(context, "gatherContext").mockResolvedValue(null), + gatherContextSpy, spyOn(scaffoldMod, "scaffold").mockResolvedValue({ actions: [], postInstructions: [] }), spyOn(scaffoldMod, "enrichProjectContext").mockResolvedValue(undefined), spyOn(previewMod, "previewPlan").mockReturnValue(undefined), @@ -50,6 +52,8 @@ describe("init command", () => { spyOn(linkMod, "link").mockResolvedValue(undefined), spyOn(pullMod, "pull").mockResolvedValue(undefined), ]; + + return { gatherContextSpy }; } test("defaults to keyless mode when not authenticated", async () => { @@ -73,12 +77,11 @@ describe("init command", () => { }); test("short-circuits env pull and skills install when already set up", async () => { - setup({ email: "test@test.com" }); + const { gatherContextSpy } = setup({ email: "test@test.com" }); // Override the default null gatherContext with a minimal supported-framework // context so detectAndInstall reaches the alreadySetUp path. - spies.find((s) => s === (context.gatherContext as unknown))?.mockRestore(); - spyOn(context, "gatherContext").mockResolvedValue({ + gatherContextSpy.mockResolvedValueOnce({ cwd: "/tmp/fake", framework: { name: "Next.js", dep: "next", sdk: "@clerk/nextjs", publishableKeyEnv: "x" }, deps: { next: "15.0.0" },