Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion packages/cli-core/src/commands/init/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 () => {
Expand All @@ -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();
});
});
35 changes: 27 additions & 8 deletions packages/cli-core/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -91,16 +99,25 @@ async function resolveAuth(cwd: string): Promise<boolean> {
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<void> {
): 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})` : "";
Expand All @@ -115,28 +132,28 @@ 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<void> {
): 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) {
console.log(dim("\nNo files to scaffold, but:"));
for (const instr of plan.postInstructions) {
console.log(dim(` • ${instr}`));
}
return;
return { alreadySetUp: false };
}

if (await checkGitDirty(cwd)) {
Expand All @@ -159,4 +176,6 @@ async function scaffoldAndWrite(
scanForIssues(cwd, ctx.framework.dep),
);
printOutro(plan, findings);

return { alreadySetUp: false };
}