diff --git a/.changeset/repo-level-vercel-links.md b/.changeset/repo-level-vercel-links.md new file mode 100644 index 000000000..36c290449 --- /dev/null +++ b/.changeset/repo-level-vercel-links.md @@ -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. diff --git a/packages/eve/src/cli/commands/link.integration.test.ts b/packages/eve/src/cli/commands/link.integration.test.ts index c22e62d04..76bcdb76f 100644 --- a/packages/eve/src/cli/commands/link.integration.test.ts +++ b/packages/eve/src/cli/commands/link.integration.test.ts @@ -52,7 +52,7 @@ function createFlowDeps(): Partial { detectProjectResolution: vi.fn( async () => ({ kind: "unresolved" }), ), - pathExists: vi.fn(async () => false), + readProjectLink: vi.fn(async () => undefined), validateTeam: vi.fn(async () => {}), resolveTeam: vi.fn(async () => "acme"), pickTeam: vi.fn(async () => "acme"), diff --git a/packages/eve/src/cli/dev/tui/setup-issues.integration.test.ts b/packages/eve/src/cli/dev/tui/setup-issues.integration.test.ts index e048b4a92..5ce6977c0 100644 --- a/packages/eve/src/cli/dev/tui/setup-issues.integration.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-issues.integration.test.ts @@ -50,10 +50,14 @@ const DISCONNECTED_GATEWAY_INFO: AgentInfoResult = { workspace: { resourceRoot: null, rootEntries: [] }, }; -async function linkedAppRoot(): Promise { +async function linkedAppRoot(kind: "project" | "repo" = "project"): Promise { 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; } @@ -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({ diff --git a/packages/eve/src/cli/dev/tui/setup-issues.ts b/packages/eve/src/cli/dev/tui/setup-issues.ts index 44bc0510f..0ab7861c5 100644 --- a/packages/eve/src/cli/dev/tui/setup-issues.ts +++ b/packages/eve/src/cli/dev/tui/setup-issues.ts @@ -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 { @@ -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; @@ -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", @@ -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" }]; } diff --git a/packages/eve/src/internal/vercel/project-link.integration.test.ts b/packages/eve/src/internal/vercel/project-link.integration.test.ts new file mode 100644 index 000000000..7f85101ab --- /dev/null +++ b/packages/eve/src/internal/vercel/project-link.integration.test.ts @@ -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 { + const root = await mkdtemp(join(tmpdir(), "eve-project-link-")); + roots.push(root); + return root; +} + +async function writeVercelFile(root: string, name: string, contents: unknown): Promise { + 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(); + }); +}); diff --git a/packages/eve/src/internal/vercel/project-link.test.ts b/packages/eve/src/internal/vercel/project-link.test.ts new file mode 100644 index 000000000..bdf942e7f --- /dev/null +++ b/packages/eve/src/internal/vercel/project-link.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { resolveRepoLinkProject, type VercelRepoLink } from "./project-link.js"; + +function repoLink(overrides: Partial = {}): 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(); + }); +}); diff --git a/packages/eve/src/internal/vercel/project-link.ts b/packages/eve/src/internal/vercel/project-link.ts index da1ecd6e5..824182402 100644 --- a/packages/eve/src/internal/vercel/project-link.ts +++ b/packages/eve/src/internal/vercel/project-link.ts @@ -1,5 +1,6 @@ import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { homedir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; import { z } from "#compiled/zod/index.js"; @@ -9,18 +10,119 @@ export const VercelProjectLinkSchema = z.object({ projectName: z.string().min(1).optional(), }); -/** Validated Vercel owner and project identifiers from `.vercel/project.json`. */ +/** Validated Vercel owner and project identifiers from local link metadata. */ export type VercelProjectLink = z.infer; -/** Reads a validated Vercel project link without mutating local project state. */ +const VercelRepoLinkProjectSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).optional(), + directory: z.string().min(1), + orgId: z.string().min(1).optional(), +}); + +/** + * The repo-level link the Vercel CLI writes to `/.vercel/repo.json` + * when a project is linked "by git" (a git remote matched to a git-connected + * Vercel project). That flow never writes a folder-level `project.json`. + * `orgId` lives per project or at the top level, depending on CLI version. + */ +export const VercelRepoLinkSchema = z.object({ + orgId: z.string().min(1).optional(), + projects: z.array(VercelRepoLinkProjectSchema), +}); + +export type VercelRepoLink = z.infer; + +async function readJson(filePath: string): Promise { + return JSON.parse(await readFile(filePath, "utf8")) as unknown; +} + +function isMissingPathError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ); +} + +/** + * Selects the repo-link project that owns `relativePath` (POSIX-style, `"."` + * for the repo root itself), mirroring the Vercel CLI's own matching: a + * project matches when its `directory` is `"."`, equals the path, or is an + * ancestor of it; the deepest directory wins. Returns `undefined` when nothing + * matches, when the deepest directory is shared by several projects (the CLI + * prompts in that case; a silent read cannot), or when no org id is resolvable. + */ +export function resolveRepoLinkProject( + repoLink: VercelRepoLink, + relativePath: string, +): VercelProjectLink | undefined { + const matches = repoLink.projects + .filter( + (project) => + project.directory === "." || + relativePath === project.directory || + relativePath.startsWith(`${project.directory}/`), + ) + .sort( + (a, b) => + (b.directory === "." ? 0 : b.directory.split("/").length) - + (a.directory === "." ? 0 : a.directory.split("/").length), + ); + const deepest = matches[0]; + if (deepest === undefined) return undefined; + if (matches.some((match) => match !== deepest && match.directory === deepest.directory)) { + return undefined; + } + const orgId = deepest.orgId ?? repoLink.orgId; + if (orgId === undefined) return undefined; + const link: VercelProjectLink = { projectId: deepest.id, orgId }; + if (deepest.name !== undefined) link.projectName = deepest.name; + return link; +} + +/** + * Walks up from `projectPath` to the first directory holding + * `.vercel/repo.json` (the CLI's repo root), stopping — like the CLI — before + * the home directory or at the filesystem root. Any read or validation failure + * resolves to "no link". + */ +async function readVercelRepoLink(projectPath: string): Promise { + const home = homedir(); + for (let current = resolve(projectPath); current !== home; current = dirname(current)) { + let raw: unknown; + try { + raw = await readJson(join(current, ".vercel", "repo.json")); + } catch (error) { + if (!isMissingPathError(error)) return undefined; + if (dirname(current) === current) break; + continue; + } + const parsed = VercelRepoLinkSchema.safeParse(raw); + if (!parsed.success) return undefined; + const relativePath = relative(current, resolve(projectPath)).split(sep).join("/") || "."; + return resolveRepoLinkProject(parsed.data, relativePath); + } + return undefined; +} + +/** + * Reads a validated Vercel project link without mutating local project state. + * + * Prefers the folder-level `.vercel/project.json`; when absent, falls back to + * the repo-level `.vercel/repo.json` the CLI writes for git-linked projects, + * resolving the entry whose `directory` owns `projectPath`. + */ export async function readVercelProjectLink( projectPath: string, ): Promise { try { - const raw = await readFile(join(projectPath, ".vercel", "project.json"), "utf8"); - const parsed = VercelProjectLinkSchema.safeParse(JSON.parse(raw)); + const parsed = VercelProjectLinkSchema.safeParse( + await readJson(join(projectPath, ".vercel", "project.json")), + ); return parsed.success ? parsed.data : undefined; - } catch { - return undefined; + } catch (error) { + if (!isMissingPathError(error)) return undefined; } + return await readVercelRepoLink(projectPath); } diff --git a/packages/eve/src/setup/boxes/add-channels.test.ts b/packages/eve/src/setup/boxes/add-channels.test.ts index 520a00e80..31e86a944 100644 --- a/packages/eve/src/setup/boxes/add-channels.test.ts +++ b/packages/eve/src/setup/boxes/add-channels.test.ts @@ -416,6 +416,26 @@ describe("addChannels box", () => { expect(deps.provisionSlackbot).not.toHaveBeenCalled(); }); + it("fails with recovery guidance when the link finishes but no link is detected", async () => { + const deps = createDeps(); + deps.detectDeployment.mockResolvedValue({ state: "unlinked" }); + const state = resolvedState(["slack"]); + state.project = { kind: "unresolved" }; + state.vercelProject = { kind: "none" }; + const box = makeBox({ + prompter: createPrompter(), + presetCreateSlackbot: true, + ensureLinkedProject: "interactive-vercel-link", + deps, + }); + + await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( + "`vercel link` finished, but no usable project link was detected in this directory.", + ); + expect(deps.runVercel).toHaveBeenCalledWith(["link"], { cwd: "/tmp/project" }); + expect(deps.provisionSlackbot).not.toHaveBeenCalled(); + }); + it("scaffolds the exact connector UID without a second patch step", async () => { const deps = createDeps(); deps.provisionSlackbot.mockResolvedValue({ diff --git a/packages/eve/src/setup/boxes/add-channels.ts b/packages/eve/src/setup/boxes/add-channels.ts index 8d7c8db85..03071de44 100644 --- a/packages/eve/src/setup/boxes/add-channels.ts +++ b/packages/eve/src/setup/boxes/add-channels.ts @@ -298,8 +298,10 @@ export function addChannels( /** * The {@link AddChannelsOptions.ensureLinkedProject} fallback: link the - * directory interactively, then re-detect the on-disk resolution. The copy - * and command shape are the dissolved engine's, byte for byte. + * directory interactively, then re-detect the on-disk resolution. A link + * that finishes without a detectable on-disk link (e.g. an ambiguous + * repo-level link) fails with recovery guidance instead of claiming the + * link command itself failed. */ async function linkProjectForSlackbot( log: ChannelSetupLog, @@ -326,7 +328,11 @@ export function addChannels( const deployment = await deps.detectDeployment(projectPath, { signal }); const project = mergeProjectResolution(current, projectResolutionFromDeployment(deployment)); if (!isProjectResolved(project)) { - throw new Error("Vercel project linking failed. Slackbot creation did not start."); + throw new Error( + "`vercel link` finished, but no usable project link was detected in this directory. " + + 'Run `vercel link` and pick your project via "Search all projects", then re-run ' + + "`eve channels add slack`. Slackbot creation did not start.", + ); } return project; } diff --git a/packages/eve/src/setup/boxes/deploy-project.test.ts b/packages/eve/src/setup/boxes/deploy-project.test.ts index 6f59287e3..fe3789a72 100644 --- a/packages/eve/src/setup/boxes/deploy-project.test.ts +++ b/packages/eve/src/setup/boxes/deploy-project.test.ts @@ -154,6 +154,20 @@ describe("deployProject box", () => { } }); + it("explains how to recover when linking finishes without a usable link", async () => { + const deps = createDeps(); + deps.detectDeployment.mockResolvedValue({ state: "unlinked" }); + const box = deployProject({ prompter: createPrompter(), deps }); + const state = pendingState(); + state.project = { kind: "unresolved" }; + + await expect(runInteractive([box], state, silentSink)).rejects.toThrow( + "`vercel link` finished, but no usable project link was detected in this directory.", + ); + expect(deps.runVercel).toHaveBeenCalledWith(["link"], { cwd: "/tmp/project" }); + expect(deps.runPackageManagerInstall).not.toHaveBeenCalled(); + }); + it("installs dependencies after linking and before deploying", async () => { const deps = createDeps(); const box = deployProject({ prompter: createPrompter(), deps }); diff --git a/packages/eve/src/setup/boxes/deploy-project.ts b/packages/eve/src/setup/boxes/deploy-project.ts index b50343b71..6fc5b92fd 100644 --- a/packages/eve/src/setup/boxes/deploy-project.ts +++ b/packages/eve/src/setup/boxes/deploy-project.ts @@ -137,7 +137,11 @@ export function deployProject( projectResolutionFromDeployment(await deps.detectDeployment(projectPath, { signal })), ); if (!isProjectResolved(project)) { - throw new Error("Vercel project linking failed. Deployment did not start."); + throw new Error( + "`vercel link` finished, but no usable project link was detected in this directory. " + + 'Run `vercel link` and pick your project via "Search all projects", then re-run ' + + "the deploy. Deployment did not start.", + ); } } diff --git a/packages/eve/src/setup/boxes/resolve-provisioning.test.ts b/packages/eve/src/setup/boxes/resolve-provisioning.test.ts index 1c99b4942..48bb93de1 100644 --- a/packages/eve/src/setup/boxes/resolve-provisioning.test.ts +++ b/packages/eve/src/setup/boxes/resolve-provisioning.test.ts @@ -45,7 +45,7 @@ function fakeDeps(): ResolveProvisioningDeps { kind: "unresolved", }), ), - pathExists: vi.fn(async () => true), + readProjectLink: vi.fn(async () => undefined), validateTeam: vi.fn(async () => {}), resolveTeam: vi.fn(async () => "team"), pickTeam: vi.fn(async () => "team"), @@ -347,6 +347,7 @@ describe("resolveProvisioning box", () => { it("adopts a detected on-disk link with a logged-in CLI, asking nothing", async () => { const deps = fakeDeps(); + deps.readProjectLink = vi.fn(async () => ({ projectId: "prj_demo", orgId: "team_demo" })); deps.detectProjectResolution = vi.fn( async (): Promise => ({ kind: "linked", projectId: "prj_demo" }), ); @@ -383,6 +384,7 @@ describe("resolveProvisioning box", () => { it("asks the question tree when the linked directory has no CLI login", async () => { const deps = fakeDeps(); + deps.readProjectLink = vi.fn(async () => ({ projectId: "prj_demo", orgId: "team_demo" })); deps.detectProjectResolution = vi.fn( async (): Promise => ({ kind: "linked", projectId: "prj_demo" }), ); diff --git a/packages/eve/src/setup/boxes/resolve-provisioning.ts b/packages/eve/src/setup/boxes/resolve-provisioning.ts index 6704893dd..f11a21e63 100644 --- a/packages/eve/src/setup/boxes/resolve-provisioning.ts +++ b/packages/eve/src/setup/boxes/resolve-provisioning.ts @@ -1,14 +1,14 @@ -import { join, resolve } from "node:path"; +import { resolve } from "node:path"; import { byokProviderEnvVar } from "#setup/scaffold/index.js"; import { whimsyFor } from "#setup/cli/index.js"; import { select, text, type Asker } from "../ask.js"; -import { pathExists } from "../path-exists.js"; import { detectProjectResolution, isProjectResolved, mergeProjectResolution, + readProjectLink, type ProjectResolution, } from "../project-resolution.js"; import type { Prompter } from "../prompter.js"; @@ -40,7 +40,7 @@ export interface ResolveProvisioningDeps { requireAuth: typeof requireAuth; isVercelAuthenticated: typeof isVercelAuthenticated; detectProjectResolution: typeof detectProjectResolution; - pathExists: typeof pathExists; + readProjectLink: typeof readProjectLink; validateTeam: typeof validateTeam; resolveTeam: typeof resolveTeam; pickTeam: typeof pickTeam; @@ -150,7 +150,7 @@ export function resolveProvisioning( requireAuth, isVercelAuthenticated, detectProjectResolution, - pathExists, + readProjectLink, validateTeam, resolveTeam, pickTeam, @@ -169,7 +169,8 @@ export function resolveProvisioning( * are required — a link without a login cannot mint a token, so that run * falls back to the question tree (whose Vercel branch enforces login). * The network reaches (production-alias read, `whoami`) run behind a - * spinner, gated on the link file so the common unlinked case stays silent. + * spinner, gated on validated link metadata so the common unlinked case + * stays silent. */ async function detectAdoptableLink( state: Readonly, @@ -177,7 +178,7 @@ export function resolveProvisioning( ): Promise { if (state.projectPath.kind !== "resolved") return undefined; const path = state.projectPath.path; - if (!(await deps.pathExists(join(path, ".vercel", "project.json")))) return undefined; + if ((await deps.readProjectLink(path)) === undefined) return undefined; return withSpinner(options.prompter, whimsyFor("project-detect"), async () => { const detected = await deps.detectProjectResolution(path, { signal }); if (!isProjectResolved(detected)) return undefined; diff --git a/packages/eve/src/setup/flows/deploy.test.ts b/packages/eve/src/setup/flows/deploy.test.ts index 047cfcf80..7571eddad 100644 --- a/packages/eve/src/setup/flows/deploy.test.ts +++ b/packages/eve/src/setup/flows/deploy.test.ts @@ -42,7 +42,7 @@ function createProvisioningDeps() { detectProjectResolution: vi.fn( async () => ({ kind: "unresolved" }), ), - pathExists: vi.fn(async () => false), + readProjectLink: vi.fn(async () => undefined), validateTeam: vi.fn(async () => {}), resolveTeam: vi.fn(async () => "acme"), pickTeam: vi.fn(async () => "acme"), diff --git a/packages/eve/src/setup/flows/link.test.ts b/packages/eve/src/setup/flows/link.test.ts index fb2742f96..49e01952a 100644 --- a/packages/eve/src/setup/flows/link.test.ts +++ b/packages/eve/src/setup/flows/link.test.ts @@ -22,7 +22,7 @@ function createBoxDeps() { detectProjectResolution: vi.fn( async () => ({ kind: "unresolved" }), ), - pathExists: vi.fn(async () => false), + readProjectLink: vi.fn(async () => undefined), validateTeam: vi.fn(async () => {}), resolveTeam: vi.fn(async () => "acme"), pickTeam: vi.fn(async () => "acme"), @@ -248,10 +248,13 @@ describe("runLinkFlow", () => { }, }); const boxDeps = createBoxDeps(); - // Prime every adoption precondition (link file present, resolvable, + // Prime every adoption precondition (valid link present, resolvable, // logged in): if detection ran, it would re-adopt the current link and // the pickers below would never be reached. - boxDeps.resolveProvisioning.pathExists.mockResolvedValue(true); + boxDeps.resolveProvisioning.readProjectLink.mockResolvedValue({ + projectId: "prj_current", + orgId: "team_current", + }); boxDeps.resolveProvisioning.detectProjectResolution.mockResolvedValue({ kind: "linked", projectId: "prj_current", diff --git a/packages/eve/src/setup/project-resolution.ts b/packages/eve/src/setup/project-resolution.ts index fcfcfffd0..4b2eaf232 100644 --- a/packages/eve/src/setup/project-resolution.ts +++ b/packages/eve/src/setup/project-resolution.ts @@ -166,7 +166,7 @@ async function fetchVercelName( /** * Resolves a linked project's human-readable name and team for the dashboard - * status bar, from local `.vercel/project.json` plus the Vercel API. A + * status bar, from local Vercel link metadata plus the Vercel API. A * personal-account project (a non-`team_` org) carries no team, so `teamName` * is absent; the project name falls back to its id if the API call fails. * @@ -220,8 +220,8 @@ export function projectResolutionFromDeployment(deployment: DeploymentInfo): Pro } /** - * Side-effect-free fact gathering after a link: reads `.vercel/project.json` - * to resolve the project. The on-disk link is the single source of truth. + * Side-effect-free fact gathering after a link. The on-disk link is the single + * source of truth. */ export async function detectProjectResolution( projectRoot: string, diff --git a/packages/eve/src/setup/slack-connect-lifecycle.ts b/packages/eve/src/setup/slack-connect-lifecycle.ts index 598a80200..de4fea5f5 100644 --- a/packages/eve/src/setup/slack-connect-lifecycle.ts +++ b/packages/eve/src/setup/slack-connect-lifecycle.ts @@ -1,10 +1,7 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; - import { SLACK_CHANNEL_DEFAULT_ROUTE } from "#setup/scaffold/index.js"; import { createPromptCommandOutput, type ChannelSetupLog } from "#setup/cli/index.js"; import { captureVercel, runVercel } from "#setup/primitives/run-vercel.js"; -import { z } from "zod"; +import { readVercelProjectLink, type VercelProjectLink } from "#internal/vercel/project-link.js"; import { parseSlackConnectorDetails, @@ -19,11 +16,6 @@ import { export const CONNECT_LOOKUP_TIMEOUT_MS = 60_000; export const CONNECT_MUTATION_TIMEOUT_MS = 2 * 60_000; -const VercelProjectLinkSchema = z.object({ - projectId: z.string().min(1), - orgId: z.string().min(1), -}); - /** Connect subprocess operations needed to inventory and remove Slack connectors. */ export interface SlackConnectLifecycleDeps { captureVercel: typeof captureVercel; @@ -79,17 +71,11 @@ export async function listSlackConnectors( } /** Project and team identifiers from a valid on-disk Vercel link. */ -export type VercelProjectLink = z.infer; +export type { VercelProjectLink }; -/** Reads the linked Vercel project and team ids from `.vercel/project.json`. */ +/** Reads the linked Vercel project and team ids from the on-disk Vercel link. */ export async function readProjectLink(projectRoot: string): Promise { - try { - const raw = await readFile(join(projectRoot, ".vercel", "project.json"), "utf8"); - const parsed = VercelProjectLinkSchema.safeParse(JSON.parse(raw)); - return parsed.success ? parsed.data : undefined; - } catch { - return undefined; - } + return await readVercelProjectLink(projectRoot); } export type SlackConnectorLookup = diff --git a/packages/eve/src/setup/slackbot.ts b/packages/eve/src/setup/slackbot.ts index 2afc398d0..e4e084cad 100644 --- a/packages/eve/src/setup/slackbot.ts +++ b/packages/eve/src/setup/slackbot.ts @@ -358,7 +358,7 @@ export async function provisionSlackbot( } if (projectId === undefined && existing.connectorUids.size > 0) { log.warning( - "Could not verify which Slack connectors belong to this Vercel project, so eve did not create another one. Restore `.vercel/project.json`, then try again.", + "Could not verify which Slack connectors belong to this Vercel project, so eve did not create another one. Restore a valid Vercel project link, then try again.", ); return { state: "connector-lookup-failed" }; }