Skip to content
Closed
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: 1 addition & 4 deletions packages/eve/src/cli/commands/integration-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,7 @@ function addChannelsDeps(): AddChannelsDeps {
source: "default",
})),
runPackageManagerInstall: vi.fn(async () => true),
runVercel: vi.fn(async () => true),
detectDeployment: vi.fn<AddChannelsDeps["detectDeployment"]>(async () => ({
state: "unlinked",
})),
ensureVercelProject: vi.fn(async () => ({ orgId: "team-id", projectId: "project-id" })),
};
}

Expand Down
3 changes: 1 addition & 2 deletions packages/eve/src/setup/integrations/channels/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ describe("channel setup integrations", () => {
source: "default",
})),
runPackageManagerInstall: vi.fn(),
runVercel: vi.fn(),
detectDeployment: vi.fn(),
ensureVercelProject: vi.fn(),
},
});

Expand Down
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",
ensureVercelProject: vi.fn<AddChannelsDeps["ensureVercelProject"]>(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.ensureVercelProject).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.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" });
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.ensureVercelProject.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
86 changes: 18 additions & 68 deletions packages/eve/src/setup/integrations/channels/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
/**
Expand Down Expand Up @@ -273,8 +265,6 @@ export function addChannels<State extends AddChannelsState = AddChannelsState>(
detectPackageManager,
runPackageManagerInstall,
ensureVercelProject,
runVercel,
detectDeployment,
};

async function scaffoldSlackChannel(
Expand Down Expand Up @@ -310,55 +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.",
});
}
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<AddChannelsState>,
Expand Down Expand Up @@ -517,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.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);
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