diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 908a2cd0..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 () => { @@ -71,4 +75,28 @@ 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 () => { + const { gatherContextSpy } = setup({ email: "test@test.com" }); + + // Override the default null gatherContext with a minimal supported-framework + // context so detectAndInstall reaches the alreadySetUp path. + gatherContextSpy.mockResolvedValueOnce({ + 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 }; }