Skip to content
Open
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/repo-level-vercel-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Detect Vercel projects linked at the repository level (`.vercel/repo.json`, written by the Vercel CLI's "linked by git" flow). Previously eve only read `.vercel/project.json`, so `eve channels add slack` on a git-linked project failed with "Vercel project linking failed" even though `vercel link` succeeded; the link-fallback error now also explains how to recover when no on-disk link can be detected.
2 changes: 1 addition & 1 deletion packages/eve/src/cli/commands/link.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function createFlowDeps(): Partial<LinkFlowDeps> {
detectProjectResolution: vi.fn<ResolveProvisioningDeps["detectProjectResolution"]>(
async () => ({ kind: "unresolved" }),
),
pathExists: vi.fn<ResolveProvisioningDeps["pathExists"]>(async () => false),
readProjectLink: vi.fn<ResolveProvisioningDeps["readProjectLink"]>(async () => undefined),
validateTeam: vi.fn<ResolveProvisioningDeps["validateTeam"]>(async () => {}),
resolveTeam: vi.fn<ResolveProvisioningDeps["resolveTeam"]>(async () => "acme"),
pickTeam: vi.fn<ResolveProvisioningDeps["pickTeam"]>(async () => "acme"),
Expand Down
16 changes: 14 additions & 2 deletions packages/eve/src/cli/dev/tui/setup-issues.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,14 @@ const DISCONNECTED_GATEWAY_INFO: AgentInfoResult = {
workspace: { resourceRoot: null, rootEntries: [] },
};

async function linkedAppRoot(): Promise<string> {
async function linkedAppRoot(kind: "project" | "repo" = "project"): Promise<string> {
const appRoot = await mkdtemp(join(tmpdir(), "eve-boot-detect-"));
await mkdir(join(appRoot, ".vercel"), { recursive: true });
await writeFile(join(appRoot, ".vercel", "project.json"), "{}", "utf8");
const link =
kind === "project"
? { projectId: "prj_demo", orgId: "team_demo" }
: { projects: [{ id: "prj_demo", directory: ".", orgId: "team_demo" }] };
await writeFile(join(appRoot, ".vercel", `${kind}.json`), JSON.stringify(link), "utf8");
return appRoot;
}

Expand All @@ -76,6 +80,14 @@ describe("BOOT_DETECTIONS against a real directory", () => {
]);
});

it("recognizes a repository-level project link", async () => {
const appRoot = await linkedAppRoot("repo");

await expect(detectSetupIssues({ appRoot, env: {} })).resolves.toEqual([
{ kind: "attention", label: "AI Gateway credentials missing", command: "/model" },
]);
});

it("diagnoses a linked project with disconnected model access", async () => {
const appRoot = await linkedAppRoot();
const issues = await detectSetupIssues({
Expand Down
10 changes: 4 additions & 6 deletions packages/eve/src/cli/dev/tui/setup-issues.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { join } from "node:path";

import type { AgentInfoResult } from "#client/index.js";
import { hasEnvValue, resolveGatewayCredential } from "#internal/resolve-model-endpoint-status.js";
import { pathExists } from "#setup/path-exists.js";
import { readVercelProjectLink } from "#internal/vercel/project-link.js";

/** One boot-time setup problem the TUI can point at a fixing command. */
export interface SetupIssue {
Expand All @@ -27,7 +25,7 @@ export interface BootDetectionContext {
/**
* One installation-state check run at TUI boot, before the user hits the
* failure mid-conversation. Detections must stay cheap and local (env reads,
* a single fs stat) — they run between the header and the first prompt.
* local link metadata) — they run between the header and the first prompt.
*/
export interface BootDetection {
id: string;
Expand Down Expand Up @@ -131,7 +129,7 @@ const modelProvider: BootDetection = {
if (access.kind === "gateway") {
if (access.runtime.status === "connected") return [];
if (access.runtime.status === "disconnected") {
const linked = await pathExists(join(appRoot, ".vercel", "project.json"));
const linked = (await readVercelProjectLink(appRoot)) !== undefined;
return [
{
kind: "attention",
Expand All @@ -142,7 +140,7 @@ const modelProvider: BootDetection = {
}
}

const linked = await pathExists(join(appRoot, ".vercel", "project.json"));
const linked = (await readVercelProjectLink(appRoot)) !== undefined;
if (linked) {
return [{ kind: "attention", label: "AI Gateway credentials missing", command: "/model" }];
}
Expand Down
118 changes: 118 additions & 0 deletions packages/eve/src/internal/vercel/project-link.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterEach, describe, expect, it } from "vitest";

import { readVercelProjectLink } from "./project-link.js";

const roots: string[] = [];

async function createRepoRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "eve-project-link-"));
roots.push(root);
return root;
}

async function writeVercelFile(root: string, name: string, contents: unknown): Promise<void> {
await mkdir(join(root, ".vercel"), { recursive: true });
await writeFile(
join(root, ".vercel", name),
typeof contents === "string" ? contents : JSON.stringify(contents),
);
}

afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});

describe("readVercelProjectLink", () => {
it("reads a folder-level project.json", async () => {
const root = await createRepoRoot();
await writeVercelFile(root, "project.json", { projectId: "prj_a", orgId: "team_a" });

expect(await readVercelProjectLink(root)).toEqual({ projectId: "prj_a", orgId: "team_a" });
});

it("prefers project.json when a repo.json also exists", async () => {
const root = await createRepoRoot();
await writeVercelFile(root, "project.json", { projectId: "prj_folder", orgId: "team_folder" });
await writeVercelFile(root, "repo.json", {
orgId: "team_repo",
projects: [{ id: "prj_repo", directory: "." }],
});

expect(await readVercelProjectLink(root)).toEqual({
projectId: "prj_folder",
orgId: "team_folder",
});
});

it("falls back to a repo.json in the same directory", async () => {
const root = await createRepoRoot();
await writeVercelFile(root, "repo.json", {
orgId: "team_repo",
remoteName: "origin",
projects: [{ id: "prj_repo", name: "my-agent", directory: "." }],
});

expect(await readVercelProjectLink(root)).toEqual({
projectId: "prj_repo",
orgId: "team_repo",
projectName: "my-agent",
});
});

it("resolves a subdirectory project from an ancestor repo.json", async () => {
const root = await createRepoRoot();
await writeVercelFile(root, "repo.json", {
projects: [
{ id: "prj_root", directory: ".", orgId: "team_a" },
{ id: "prj_agent", directory: "apps/agent", orgId: "team_a" },
],
});
const projectPath = join(root, "apps", "agent");
await mkdir(projectPath, { recursive: true });

expect(await readVercelProjectLink(projectPath)).toEqual({
projectId: "prj_agent",
orgId: "team_a",
});
});

it("does not fall back to repo.json when project.json is invalid", async () => {
const root = await createRepoRoot();
await writeVercelFile(root, "project.json", "{ not json");
await writeVercelFile(root, "repo.json", {
orgId: "team_repo",
projects: [{ id: "prj_repo", directory: "." }],
});

expect(await readVercelProjectLink(root)).toBeUndefined();
});

it("returns undefined for a malformed repo.json", async () => {
const root = await createRepoRoot();
await writeVercelFile(root, "repo.json", { projects: "nope" });

expect(await readVercelProjectLink(root)).toBeUndefined();
});

it("does not skip a malformed repo.json for an ancestor link", async () => {
const root = await createRepoRoot();
await writeVercelFile(root, "repo.json", {
orgId: "team_parent",
projects: [{ id: "prj_parent", directory: "." }],
});
const projectPath = join(root, "apps", "agent");
await writeVercelFile(projectPath, "repo.json", "{ not json");

expect(await readVercelProjectLink(projectPath)).toBeUndefined();
});

it("returns undefined when the directory has no link at all", async () => {
const root = await createRepoRoot();

expect(await readVercelProjectLink(root)).toBeUndefined();
});
});
88 changes: 88 additions & 0 deletions packages/eve/src/internal/vercel/project-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";

import { resolveRepoLinkProject, type VercelRepoLink } from "./project-link.js";

function repoLink(overrides: Partial<VercelRepoLink> = {}): VercelRepoLink {
return {
orgId: "team_top",
projects: [{ id: "prj_root", name: "root-app", directory: "." }],
...overrides,
};
}

describe("resolveRepoLinkProject", () => {
it("resolves a root-directory project for the repo root itself", () => {
expect(resolveRepoLinkProject(repoLink(), ".")).toEqual({
projectId: "prj_root",
orgId: "team_top",
projectName: "root-app",
});
});

it("prefers the deepest matching directory over the repo root", () => {
const link = repoLink({
projects: [
{ id: "prj_root", directory: "." },
{ id: "prj_agent", directory: "apps/agent", orgId: "team_agent" },
],
});
expect(resolveRepoLinkProject(link, "apps/agent/src")).toEqual({
projectId: "prj_agent",
orgId: "team_agent",
});
});

it("prefers a top-level directory over the repo root", () => {
const link = repoLink({
projects: [
{ id: "prj_root", directory: "." },
{ id: "prj_web", directory: "web" },
],
});
expect(resolveRepoLinkProject(link, "web")).toEqual({
projectId: "prj_web",
orgId: "team_top",
});
});

it("matches a directory prefix only on whole path segments", () => {
const link = repoLink({
projects: [{ id: "prj_agent", directory: "apps/agent" }],
});
expect(resolveRepoLinkProject(link, "apps/agent-two")).toBeUndefined();
});

it("falls back to the top-level orgId when the project has none", () => {
const link = repoLink({
projects: [{ id: "prj_agent", directory: "apps/agent" }],
});
expect(resolveRepoLinkProject(link, "apps/agent")).toEqual({
projectId: "prj_agent",
orgId: "team_top",
});
});

it("returns undefined when no orgId is resolvable", () => {
const link: VercelRepoLink = {
projects: [{ id: "prj_agent", directory: "apps/agent" }],
};
expect(resolveRepoLinkProject(link, "apps/agent")).toBeUndefined();
});

it("returns undefined when the deepest directory is claimed by several projects", () => {
const link = repoLink({
projects: [
{ id: "prj_one", directory: "apps/agent" },
{ id: "prj_two", directory: "apps/agent" },
],
});
expect(resolveRepoLinkProject(link, "apps/agent")).toBeUndefined();
});

it("returns undefined when nothing matches the path", () => {
const link = repoLink({
projects: [{ id: "prj_agent", directory: "apps/agent" }],
});
expect(resolveRepoLinkProject(link, "apps/other")).toBeUndefined();
});
});
Loading
Loading