From 5c6c3ec74459223c29a407a7016996b6b4853260 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Wed, 29 Jul 2026 21:09:13 +0000 Subject: [PATCH] refactor(eve): share interactive Vercel linking Signed-off-by: owenkephart --- .../cli/commands/integration-setup.test.ts | 5 +- .../setup/integrations/channels/index.test.ts | 3 +- .../setup/integrations/channels/setup.test.ts | 24 +++--- .../src/setup/integrations/channels/setup.ts | 86 ++++--------------- packages/eve/src/setup/vercel-project.test.ts | 42 +++++++++ packages/eve/src/setup/vercel-project.ts | 31 +++++++ 6 files changed, 104 insertions(+), 87 deletions(-) diff --git a/packages/eve/src/cli/commands/integration-setup.test.ts b/packages/eve/src/cli/commands/integration-setup.test.ts index 82b4a0e77..9ba0c9684 100644 --- a/packages/eve/src/cli/commands/integration-setup.test.ts +++ b/packages/eve/src/cli/commands/integration-setup.test.ts @@ -36,10 +36,7 @@ function addChannelsDeps(): AddChannelsDeps { source: "default", })), runPackageManagerInstall: vi.fn(async () => true), - runVercel: vi.fn(async () => true), - detectDeployment: vi.fn(async () => ({ - state: "unlinked", - })), + ensureVercelProject: vi.fn(async () => ({ orgId: "team-id", projectId: "project-id" })), }; } diff --git a/packages/eve/src/setup/integrations/channels/index.test.ts b/packages/eve/src/setup/integrations/channels/index.test.ts index 5e84be3cf..aea89318e 100644 --- a/packages/eve/src/setup/integrations/channels/index.test.ts +++ b/packages/eve/src/setup/integrations/channels/index.test.ts @@ -61,8 +61,7 @@ describe("channel setup integrations", () => { source: "default", })), runPackageManagerInstall: vi.fn(), - runVercel: vi.fn(), - detectDeployment: vi.fn(), + ensureVercelProject: vi.fn(), }, }); diff --git a/packages/eve/src/setup/integrations/channels/setup.test.ts b/packages/eve/src/setup/integrations/channels/setup.test.ts index f5634f0b8..99a3acce5 100644 --- a/packages/eve/src/setup/integrations/channels/setup.test.ts +++ b/packages/eve/src/setup/integrations/channels/setup.test.ts @@ -91,9 +91,8 @@ function createDeps() { source: "default", })), runPackageManagerInstall: vi.fn(async () => true), - runVercel: vi.fn(async () => true), - detectDeployment: vi.fn(async () => ({ - state: "linked", + ensureVercelProject: vi.fn(async () => ({ + orgId: "team_demo", projectId: "prj_demo", })), }; @@ -161,7 +160,7 @@ describe("addChannels box", () => { ); expect(deps.provisionSlackbot).not.toHaveBeenCalled(); - expect(deps.runVercel).not.toHaveBeenCalled(); + expect(deps.ensureVercelProject).not.toHaveBeenCalled(); expect(deps.runPackageManagerInstall).not.toHaveBeenCalled(); expect(deps.ensureChannel).toHaveBeenCalledWith({ projectRoot: "/tmp/project", @@ -358,7 +357,6 @@ describe("addChannels box", () => { state.project = { kind: "unresolved" }; state.vercelProject = { kind: "none" }; const prompter = createPrompter(); - prompter.withInheritedStdio = vi.fn((task) => task()); const box = makeBox({ prompter, presetCreateSlackbot: true, @@ -368,14 +366,14 @@ describe("addChannels box", () => { const result = await runInteractive([box], state, silentSink, snapshot); - // The engine's exact fallback: a bare interactive `vercel link` with NO - // onOutput, then a fresh deployment detection. - expect(prompter.withInheritedStdio).toHaveBeenCalledOnce(); - expect(deps.runVercel).toHaveBeenCalledWith(["link"], { cwd: "/tmp/project" }); - expect(deps.runVercel.mock.invocationCallOrder[0]).toBeLessThan( + expect(deps.ensureVercelProject).toHaveBeenCalledWith({ + appRoot: "/tmp/project", + prompter, + signal: undefined, + }); + expect(deps.ensureVercelProject.mock.invocationCallOrder[0]).toBeLessThan( deps.provisionSlackbot.mock.invocationCallOrder[0]!, ); - expect(deps.detectDeployment).toHaveBeenCalledWith("/tmp/project", { signal: undefined }); expect(result.kind).toBe("done"); if (result.kind === "done") { expect(result.state.project).toEqual({ kind: "linked", projectId: "prj_demo" }); @@ -385,7 +383,7 @@ describe("addChannels box", () => { it("fails the link fallback with the engine's copy when `vercel link` fails", async () => { const deps = createDeps(); - deps.runVercel.mockResolvedValue(false); + deps.ensureVercelProject.mockRejectedValue(new Error("Vercel project linking failed.")); const state = resolvedState(["slack"]); state.project = { kind: "unresolved" }; state.vercelProject = { kind: "none" }; @@ -397,7 +395,7 @@ describe("addChannels box", () => { }); await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - "Vercel project linking failed. Slackbot creation did not start.", + "Vercel project linking failed.", ); expect(deps.provisionSlackbot).not.toHaveBeenCalled(); }); diff --git a/packages/eve/src/setup/integrations/channels/setup.ts b/packages/eve/src/setup/integrations/channels/setup.ts index 9bf1ae882..ea7c516ee 100644 --- a/packages/eve/src/setup/integrations/channels/setup.ts +++ b/packages/eve/src/setup/integrations/channels/setup.ts @@ -12,13 +12,9 @@ import { detectPackageManager, type PackageManagerKind } from "#setup/package-ma import { formatNodeEngineOverrideWarning } from "#setup/node-engine.js"; import { runPackageManagerInstall } from "#setup/primitives/pm/run.js"; import { ensureVercelProject } from "#setup/flows/ensure-vercel-project.js"; -import { runVercel } from "#setup/primitives/run-vercel.js"; - import { - detectDeployment, isProjectResolved, mergeProjectResolution, - projectResolutionFromDeployment, type ProjectResolution, } from "../../project-resolution.js"; import type { Asker } from "../../ask.js"; @@ -140,10 +136,7 @@ export interface AddChannelsDeps { reconcileSlackUid: typeof reconcileSlackUid; detectPackageManager: typeof detectPackageManager; runPackageManagerInstall: typeof runPackageManagerInstall; - /** Parent-rendered project flow; legacy test seams may omit it. */ - ensureVercelProject?: typeof ensureVercelProject; - runVercel: typeof runVercel; - detectDeployment: typeof detectDeployment; + ensureVercelProject: typeof ensureVercelProject; } export interface AddChannelsOptions { @@ -180,9 +173,8 @@ export interface AddChannelsOptions { configureVercelServices?: boolean; /** * Opt-in fallback when Slack is chosen interactively but `state.project` is - * unresolved: run the interactive bare `vercel link` before provisioning the - * slackbot. The Slack integration sets this so Vercel Connect setup can link - * an unlinked project before provisioning. + * unresolved: link the project before provisioning the slackbot. The Slack + * integration sets this so Vercel Connect setup can link an unlinked project. */ ensureLinkedProject?: "interactive-vercel-link"; /** @@ -273,8 +265,6 @@ export function addChannels( detectPackageManager, runPackageManagerInstall, ensureVercelProject, - runVercel, - detectDeployment, }; async function scaffoldSlackChannel( @@ -310,55 +300,6 @@ export function addChannels( return wroteExactConnectorUid; } - /** - * 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. - */ - async function linkProjectForSlackbot( - log: ChannelSetupLog, - projectPath: string, - current: ProjectResolution, - headless: boolean, - signal?: AbortSignal, - ): Promise { - if (headless) { - throw new HumanActionRequiredError({ - kind: "vercel-link", - command: "vercel link", - reason: "Slackbot creation needs this directory linked to a Vercel project.", - }); - } - log.message("Linking this directory to a Vercel project..."); - let project: ProjectResolution; - if (deps.ensureVercelProject !== undefined) { - const linked = await deps.ensureVercelProject({ - appRoot: projectPath, - prompter: options.prompter, - signal, - }); - project = mergeProjectResolution(current, { kind: "linked", projectId: linked.projectId }); - } else if (deps.runVercel !== runVercel) { - const link = () => deps.runVercel(["link"], { cwd: projectPath, signal }); - const linked = await (options.prompter.withInheritedStdio?.(link) ?? link()); - if (!linked) - throw new Error("Vercel project linking failed. Slackbot creation did not start."); - const deployment = await deps.detectDeployment(projectPath, { signal }); - project = mergeProjectResolution(current, projectResolutionFromDeployment(deployment)); - } else { - const linked = await ensureVercelProject({ - appRoot: projectPath, - prompter: options.prompter, - signal, - }); - project = mergeProjectResolution(current, { kind: "linked", projectId: linked.projectId }); - } - if (!isProjectResolved(project)) { - throw new Error("Vercel project linking failed. Slackbot creation did not start."); - } - return project; - } - async function addWebChannelToPayload( log: ChannelSetupLog, state: Readonly, @@ -517,13 +458,22 @@ export function addChannels( if (!isProjectResolved(payload.project)) { // Only reachable with the ensureLinkedProject seam; without it the gate // above already required a resolved project. - payload.project = await linkProjectForSlackbot( - log, - projectPath, - payload.project, - input.headless, + if (input.headless) { + throw new HumanActionRequiredError({ + kind: "vercel-link", + command: "vercel link", + reason: "Slackbot creation needs this directory linked to a Vercel project.", + }); + } + const linked = await deps.ensureVercelProject({ + appRoot: projectPath, + prompter: options.prompter, signal, - ); + }); + payload.project = mergeProjectResolution(payload.project, { + kind: "linked", + projectId: linked.projectId, + }); } const slackbot = await provisionSlackbotWithControls(log, projectPath, slug, signal); diff --git a/packages/eve/src/setup/vercel-project.test.ts b/packages/eve/src/setup/vercel-project.test.ts index 2319627e3..75981b71a 100644 --- a/packages/eve/src/setup/vercel-project.test.ts +++ b/packages/eve/src/setup/vercel-project.test.ts @@ -9,6 +9,7 @@ import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import { readProjectLink } from "./project-resolution.js"; import { assertNewProjectNameAvailable, + ensureLinkedVercelProject, getVercelAuthStatus, linkProject, pickNewProjectName, @@ -570,6 +571,47 @@ describe("resolveProjectByNameOrId", () => { }); }); +describe("ensureLinkedVercelProject", () => { + it("returns the existing project link without invoking the CLI", async () => { + mockedReadProjectLink.mockResolvedValue({ orgId: "team_a", projectId: "prj_a" }); + const { prompter } = createFakePrompter(); + + await expect( + ensureLinkedVercelProject({ projectRoot: "/tmp/eve-agent", prompter }), + ).resolves.toEqual({ orgId: "team_a", projectId: "prj_a" }); + + expect(mockedRunVercel).not.toHaveBeenCalled(); + }); + + it("links interactively and reads the resulting project link", async () => { + mockedReadProjectLink + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ orgId: "team_a", projectId: "prj_a" }); + const { prompter } = createFakePrompter(); + prompter.withInheritedStdio = vi.fn((task) => task()); + + await expect( + ensureLinkedVercelProject({ projectRoot: "/tmp/eve-agent", prompter }), + ).resolves.toEqual({ orgId: "team_a", projectId: "prj_a" }); + + expect(prompter.withInheritedStdio).toHaveBeenCalledOnce(); + expect(mockedRunVercel).toHaveBeenCalledWith(["link"], { + cwd: "/tmp/eve-agent", + signal: undefined, + }); + }); + + it("fails when the interactive link does not complete", async () => { + mockedReadProjectLink.mockResolvedValue(undefined); + mockedRunVercel.mockResolvedValue(false); + const { prompter } = createFakePrompter(); + + await expect( + ensureLinkedVercelProject({ projectRoot: "/tmp/eve-agent", prompter }), + ).rejects.toThrow("Vercel project linking failed."); + }); +}); + describe("linkProject", () => { it("links a resolved existing project through `vercel link`", async () => { const { prompter } = createFakePrompter(); diff --git a/packages/eve/src/setup/vercel-project.ts b/packages/eve/src/setup/vercel-project.ts index c075d8441..178512920 100644 --- a/packages/eve/src/setup/vercel-project.ts +++ b/packages/eve/src/setup/vercel-project.ts @@ -56,6 +56,37 @@ export interface PickTeamOptions extends VercelProjectOperationOptions { export interface LinkProjectOperationOptions extends CreatedProjectFrameworkOptions {} +/** Effects used to ensure an interactive Vercel project link. */ +export interface EnsureLinkedVercelProjectDeps { + readProjectLink: typeof readProjectLink; + runVercel: typeof runVercel; +} + +/** + * Returns the existing Vercel project link or creates one through the Vercel + * CLI's interactive flow. The CLI owns team and project selection. + */ +export async function ensureLinkedVercelProject(input: { + projectRoot: string; + prompter: Prompter; + signal?: AbortSignal; + deps?: EnsureLinkedVercelProjectDeps; +}): Promise>>> { + const deps = input.deps ?? { readProjectLink, runVercel }; + const existing = await deps.readProjectLink(input.projectRoot); + if (existing !== undefined) return existing; + + const link = () => deps.runVercel(["link"], { cwd: input.projectRoot, signal: input.signal }); + const linked = await (input.prompter.withInheritedStdio?.(link) ?? link()); + if (!linked) { + input.signal?.throwIfAborted(); + throw new Error("Vercel project linking failed."); + } + const project = await deps.readProjectLink(input.projectRoot); + if (project === undefined) throw new Error("Vercel project linking failed."); + return project; +} + export function unresolvedProject(): ProjectResolution { return { kind: "unresolved" }; }