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
5 changes: 5 additions & 0 deletions .changeset/fix-bootstrap-link-cwd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Fix link saving to wrong directory during bootstrap flow. When creating a new project via `clerk init`, the Clerk application link is now correctly saved to the new project directory instead of the parent directory.
48 changes: 44 additions & 4 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ describe("init", () => {
await init({ yes: true });

expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false });
expect(linkMod.link).toHaveBeenCalledWith({ skipIfLinked: true, app: undefined });
expect(linkMod.link).toHaveBeenCalledWith({
skipIfLinked: true,
app: undefined,
cwd: FAKE_CTX.cwd,
});
});

test("forwards --app to link when provided", async () => {
Expand All @@ -126,7 +130,11 @@ describe("init", () => {

await init({ yes: true, app: "app_abc" });

expect(linkMod.link).toHaveBeenCalledWith({ skipIfLinked: true, app: "app_abc" });
expect(linkMod.link).toHaveBeenCalledWith({
skipIfLinked: true,
app: "app_abc",
cwd: FAKE_CTX.cwd,
});
});

test("forwards --app to link when no profile exists", async () => {
Expand All @@ -136,7 +144,11 @@ describe("init", () => {

await init({ yes: true, app: "app_abc" });

expect(linkMod.link).toHaveBeenCalledWith({ skipIfLinked: true, app: "app_abc" });
expect(linkMod.link).toHaveBeenCalledWith({
skipIfLinked: true,
app: "app_abc",
cwd: FAKE_CTX.cwd,
});
});

test("agent mode runs existing-project flow without prompts", async () => {
Expand Down Expand Up @@ -571,7 +583,11 @@ describe("init", () => {

await init({ yes: true });

expect(linkMod.link).toHaveBeenCalledWith({ skipIfLinked: true });
expect(linkMod.link).toHaveBeenCalledWith({
skipIfLinked: true,
app: undefined,
cwd: "/tmp/fake",
});
expect(pullMod.pull).not.toHaveBeenCalled();
expect(heuristics.printKeylessInfo).not.toHaveBeenCalled();
expect(skillsMod.installSkills).not.toHaveBeenCalled();
Expand Down Expand Up @@ -646,4 +662,28 @@ describe("init", () => {

expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env" });
});

test("bootstrap passes project dir to link, not parent cwd", async () => {
Comment thread
rafa-thayto marked this conversation as resolved.
setup({ email: "test@test.com" });

const bootstrapCtx = {
...FAKE_CTX,
cwd: FAKE_BOOTSTRAP.projectDir,
existingClerk: false,
};

spyOn(context, "gatherContext").mockResolvedValueOnce(null).mockResolvedValueOnce(bootstrapCtx);
spyOn(scaffoldMod, "scaffold").mockResolvedValue({
actions: [{ type: "create", path: "app/layout.tsx", content: "", description: "" }],
postInstructions: [],
});

await init({ yes: true });

expect(linkMod.link).toHaveBeenCalledWith({
skipIfLinked: true,
app: undefined,
cwd: FAKE_BOOTSTRAP.projectDir,
});
});
});
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis
log.info(dim(label));
}

await link({ skipIfLinked: true, app });
await link({ skipIfLinked: true, app, cwd });
}

// --- Detect & install ---
Expand Down
9 changes: 5 additions & 4 deletions packages/cli-core/src/commands/link/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const CREATE_NEW_APP = "__create_new__";
interface LinkOptions {
app?: string;
skipIfLinked?: boolean;
cwd?: string;
}

function appLabel(app: Application): string {
Expand All @@ -56,10 +57,10 @@ export async function link(options: LinkOptions = {}): Promise<void> {
return;
}

const cwd = process.cwd();
const repoRoot = await getGitRepoRoot();
const normalizedRemote = await getGitNormalizedRemote();
const repoId = await getGitRepoIdentifier();
const cwd = options.cwd ?? process.cwd();
const repoRoot = await getGitRepoRoot(cwd);
const normalizedRemote = await getGitNormalizedRemote(cwd);
Comment thread
rafa-thayto marked this conversation as resolved.
const repoId = await getGitRepoIdentifier(cwd);
const profileKey = normalizedRemote ?? repoId ?? cwd;
const displayPath = repoRoot ?? cwd;

Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/lib/autolink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ export async function autolink(
`autolink: matched key from ${match.source} → app ${match.app.application_id} (${match.app.name ?? "unnamed"})`,
);

const normalizedRemote = await getGitNormalizedRemote();
const repoId = await getGitRepoIdentifier();
const normalizedRemote = await getGitNormalizedRemote(cwd);
const repoId = await getGitRepoIdentifier(cwd);
const profileKey = normalizedRemote ?? repoId ?? cwd;

const devInstance = match.app.instances.find((i) => i.environment_type === "development");
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export async function resolveProfile(cwd: string): Promise<
const config = await readConfig();

// Try normalized remote URL first (cross-clone matching)
const normalizedRemote = await getGitNormalizedRemote();
const normalizedRemote = await getGitNormalizedRemote(cwd);
if (normalizedRemote && config.profiles[normalizedRemote]) {
return {
path: normalizedRemote,
Expand All @@ -182,7 +182,7 @@ export async function resolveProfile(cwd: string): Promise<
const fallbackFields = normalizedRemote ? { availableRemote: normalizedRemote } : {};

// Try git repo identifier (shared across worktrees, backward compat)
const repoId = await getGitRepoIdentifier();
const repoId = await getGitRepoIdentifier(cwd);
if (repoId && config.profiles[repoId]) {
return {
path: repoId,
Expand Down
51 changes: 34 additions & 17 deletions packages/cli-core/src/lib/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,25 @@ interface GitRepoInfo {
normalizedRemote?: string;
}

let cached: GitRepoInfo | undefined | null = null;
const cache = new Map<string, GitRepoInfo | undefined>();

async function getGitRepoInfo(): Promise<GitRepoInfo | undefined> {
if (cached !== null) return cached ?? undefined;
async function getGitRepoInfo(cwd?: string): Promise<GitRepoInfo | undefined> {
const key = cwd ?? process.cwd();
if (cache.has(key)) return cache.get(key);

let result;
try {
result = await $`git rev-parse --show-toplevel --git-common-dir`.cwd(key).quiet().nothrow();
} catch {
// Directory doesn't exist or other error (e.g., ENOENT)
log.debug(`git: rev-parse threw (cwd=${key})`);
cache.set(key, undefined);
return undefined;
}

const result = await $`git rev-parse --show-toplevel --git-common-dir`.quiet().nothrow();
if (result.exitCode !== 0) {
log.debug("git: not a git repository (git rev-parse failed)");
cached = undefined;
cache.set(key, undefined);
return undefined;
}

Expand All @@ -26,37 +36,44 @@ async function getGitRepoInfo(): Promise<GitRepoInfo | undefined> {
const commonDir = lines[1];
if (!toplevel || !commonDir) {
log.debug("git: rev-parse returned no toplevel/commonDir");
cached = undefined;
cache.set(key, undefined);
return undefined;
}

// Fetch remote URL (non-blocking since rev-parse already succeeded)
const remoteResult = await $`git remote get-url origin`.quiet().nothrow();
const rawRemote = remoteResult.exitCode === 0 ? remoteResult.text().trim() : undefined;
let rawRemote: string | undefined;
try {
const remoteResult = await $`git remote get-url origin`.cwd(key).quiet().nothrow();
rawRemote = remoteResult.exitCode === 0 ? remoteResult.text().trim() : undefined;
} catch {
// Directory error or git command failed
rawRemote = undefined;
}

cached = {
const info = {
toplevel,
commonDir: resolve(toplevel, commonDir),
normalizedRemote: rawRemote ? normalizeGitRemoteUrl(rawRemote) : undefined,
};
cache.set(key, info);
log.debug(
`git: toplevel=${cached.toplevel}, commonDir=${cached.commonDir}, remote=${cached.normalizedRemote ?? "<none>"}`,
`git: toplevel=${info.toplevel}, commonDir=${info.commonDir}, remote=${info.normalizedRemote ?? "<none>"}`,
);
return cached;
return info;
}

export async function getGitRepoRoot(): Promise<string | undefined> {
const info = await getGitRepoInfo();
export async function getGitRepoRoot(cwd?: string): Promise<string | undefined> {
const info = await getGitRepoInfo(cwd);
return info?.toplevel;
}

export async function getGitRepoIdentifier(): Promise<string | undefined> {
const info = await getGitRepoInfo();
export async function getGitRepoIdentifier(cwd?: string): Promise<string | undefined> {
const info = await getGitRepoInfo(cwd);
return info?.commonDir;
}

export async function getGitNormalizedRemote(): Promise<string | undefined> {
const info = await getGitRepoInfo();
export async function getGitNormalizedRemote(cwd?: string): Promise<string | undefined> {
const info = await getGitRepoInfo(cwd);
return info?.normalizedRemote;
}

Expand Down
Loading