diff --git a/packages/eve/src/setup/integrations/channels/setup.test.ts b/packages/eve/src/setup/integrations/channels/setup.test.ts index f5634f0b8..1e916d1ad 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", + ensureLinkedVercelProject: 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.ensureLinkedVercelProject).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.ensureLinkedVercelProject).toHaveBeenCalledWith({ + projectRoot: "/tmp/project", + prompter, + signal: undefined, + }); + expect(deps.ensureLinkedVercelProject.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.ensureLinkedVercelProject.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 cfa87ea6e..fc96e2fd7 100644 --- a/packages/eve/src/setup/integrations/channels/setup.ts +++ b/packages/eve/src/setup/integrations/channels/setup.ts @@ -11,13 +11,9 @@ import { createPromptCommandOutput, withPhase, type ChannelSetupLog } from "#set import { detectPackageManager, type PackageManagerKind } from "#setup/package-manager.js"; import { formatNodeEngineOverrideWarning } from "#setup/node-engine.js"; import { runPackageManagerInstall } from "#setup/primitives/pm/run.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"; @@ -28,6 +24,7 @@ import { type ProvisionSlackbotOptions, type ProvisionSlackbotResult, } from "../../slackbot.js"; +import { ensureLinkedVercelProject } from "../../vercel-project.js"; import { WizardCancelledError, type SetupBox } from "../../step.js"; /** State required by channel setup, kept narrow so the integration can move packages. */ @@ -139,8 +136,7 @@ export interface AddChannelsDeps { reconcileSlackUid: typeof reconcileSlackUid; detectPackageManager: typeof detectPackageManager; runPackageManagerInstall: typeof runPackageManagerInstall; - runVercel: typeof runVercel; - detectDeployment: typeof detectDeployment; + ensureLinkedVercelProject: typeof ensureLinkedVercelProject; } export interface AddChannelsOptions { @@ -177,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"; /** @@ -269,8 +264,7 @@ export function addChannels( reconcileSlackUid, detectPackageManager, runPackageManagerInstall, - runVercel, - detectDeployment, + ensureLinkedVercelProject, }; async function scaffoldSlackChannel( @@ -306,43 +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.", - }); - } - // No onOutput: `vercel link` (without --project) is interactive, so it must - // own the terminal. Piping its prompt through the rail renderer line-buffers - // the unterminated question and deadlocks the CLI waiting on hidden input. - log.message("Linking this directory to a Vercel project..."); - const link = () => deps.runVercel(["link"], { cwd: projectPath, signal }); - const linked = await (options.prompter.withInheritedStdio?.(link) ?? link()); - if (!linked) { - signal?.throwIfAborted(); - throw new Error("Vercel project linking failed. Slackbot creation did not start."); - } - 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."); - } - return project; - } - async function addWebChannelToPayload( log: ChannelSetupLog, state: Readonly, @@ -501,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.ensureLinkedVercelProject({ + projectRoot: 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" }; }