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
24 changes: 11 additions & 13 deletions packages/eve/src/setup/integrations/channels/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,8 @@ function createDeps() {
source: "default",
})),
runPackageManagerInstall: vi.fn<AddChannelsDeps["runPackageManagerInstall"]>(async () => true),
runVercel: vi.fn<AddChannelsDeps["runVercel"]>(async () => true),
detectDeployment: vi.fn<AddChannelsDeps["detectDeployment"]>(async () => ({
state: "linked",
ensureLinkedVercelProject: vi.fn<AddChannelsDeps["ensureLinkedVercelProject"]>(async () => ({
orgId: "team_demo",
projectId: "prj_demo",
})),
};
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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" });
Expand All @@ -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" };
Expand All @@ -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();
});
Expand Down
74 changes: 20 additions & 54 deletions packages/eve/src/setup/integrations/channels/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
/**
Expand Down Expand Up @@ -269,8 +264,7 @@ export function addChannels<State extends AddChannelsState = AddChannelsState>(
reconcileSlackUid,
detectPackageManager,
runPackageManagerInstall,
runVercel,
detectDeployment,
ensureLinkedVercelProject,
};

async function scaffoldSlackChannel(
Expand Down Expand Up @@ -306,43 +300,6 @@ export function addChannels<State extends AddChannelsState = AddChannelsState>(
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<ProjectResolution> {
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<AddChannelsState>,
Expand Down Expand Up @@ -501,13 +458,22 @@ export function addChannels<State extends AddChannelsState = AddChannelsState>(
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);
Expand Down
42 changes: 42 additions & 0 deletions packages/eve/src/setup/vercel-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createFakePrompter } from "#internal/testing/fake-prompter.js";
import { readProjectLink } from "./project-resolution.js";
import {
assertNewProjectNameAvailable,
ensureLinkedVercelProject,
getVercelAuthStatus,
linkProject,
pickNewProjectName,
Expand Down Expand Up @@ -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();
Expand Down
31 changes: 31 additions & 0 deletions packages/eve/src/setup/vercel-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NonNullable<Awaited<ReturnType<typeof readProjectLink>>>> {
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" };
}
Expand Down
Loading