From 62db1774aa83137494bc469c843587d6589b8675 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:14:21 -0700 Subject: [PATCH 1/6] refactor(eve): remove channel setup selection state Signed-off-by: owenkephart --- .../cli/commands/integration-setup.test.ts | 2 +- .../eve/src/cli/commands/integration-setup.ts | 10 +- .../setup/boxes/resolve-provisioning.test.ts | 38 ----- .../src/setup/boxes/resolve-provisioning.ts | 8 - .../runner.ts => channel-scaffold-runner.ts} | 10 +- ...setup.test.ts => channel-scaffold.test.ts} | 152 ++++++++---------- .../setup.ts => channel-scaffold.ts} | 25 ++- .../index.test.ts => registry.test.ts} | 14 +- .../{channels/index.ts => registry.ts} | 8 +- .../{channels => shared}/environment.test.ts | 0 .../{channels => shared}/environment.ts | 0 .../{channels => shared}/ui.test.ts | 0 .../integrations/{channels => shared}/ui.ts | 0 .../{channels/slack.ts => slack/setup.ts} | 14 +- .../integrations/{channels => }/types.ts | 11 +- .../{channels/web.ts => web/setup.ts} | 5 +- packages/eve/src/setup/state.ts | 4 - 17 files changed, 117 insertions(+), 184 deletions(-) rename packages/eve/src/setup/integrations/{channels/runner.ts => channel-scaffold-runner.ts} (82%) rename packages/eve/src/setup/integrations/{channels/setup.test.ts => channel-scaffold.test.ts} (88%) rename packages/eve/src/setup/integrations/{channels/setup.ts => channel-scaffold.ts} (97%) rename packages/eve/src/setup/integrations/{channels/index.test.ts => registry.test.ts} (85%) rename packages/eve/src/setup/integrations/{channels/index.ts => registry.ts} (74%) rename packages/eve/src/setup/integrations/{channels => shared}/environment.test.ts (100%) rename packages/eve/src/setup/integrations/{channels => shared}/environment.ts (100%) rename packages/eve/src/setup/integrations/{channels => shared}/ui.test.ts (100%) rename packages/eve/src/setup/integrations/{channels => shared}/ui.ts (100%) rename packages/eve/src/setup/integrations/{channels/slack.ts => slack/setup.ts} (89%) rename packages/eve/src/setup/integrations/{channels => }/types.ts (81%) rename packages/eve/src/setup/integrations/{channels/web.ts => web/setup.ts} (70%) diff --git a/packages/eve/src/cli/commands/integration-setup.test.ts b/packages/eve/src/cli/commands/integration-setup.test.ts index 9ba0c9684..e5f8e3e93 100644 --- a/packages/eve/src/cli/commands/integration-setup.test.ts +++ b/packages/eve/src/cli/commands/integration-setup.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import type { AddChannelsDeps } from "#setup/integrations/channels/setup.js"; +import type { AddChannelsDeps } from "#setup/integrations/channel-scaffold.js"; import { deriveSlackConnectorSlug } from "#setup/scaffold/index.js"; import { runIntegrationSetupCommand } from "./integration-setup.js"; diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index e5626ca88..31f2fac54 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -1,13 +1,10 @@ import { interactiveAsker } from "#setup/ask.js"; -import type { AddChannelsDeps } from "#setup/integrations/channels/setup.js"; +import type { AddChannelsDeps } from "#setup/integrations/channel-scaffold.js"; import { channelSetupEnvironment, describeChannelSetupEnvironment, -} from "#setup/integrations/channels/environment.js"; -import { - channelSetupIntegration, - createChannelSetupUi, -} from "#setup/integrations/channels/index.js"; +} from "#setup/integrations/shared/environment.js"; +import { channelSetupIntegration, createChannelSetupUi } from "#setup/integrations/registry.js"; import { detectDeployment, projectResolutionFromDeployment } from "#setup/project-resolution.js"; import { createPrompter, type Prompter } from "#setup/prompter.js"; import { createRegistrySetupClient } from "#setup/registry-setup-client.js"; @@ -75,7 +72,6 @@ export async function runIntegrationSetupCommand( ...createDefaultSetupState(), project, projectPath: { kind: "resolved", inPlace: true, path: appRoot }, - channelSelection: [channelKind], }, ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), presetCreateSlackbot: options.yes ? true : undefined, diff --git a/packages/eve/src/setup/boxes/resolve-provisioning.test.ts b/packages/eve/src/setup/boxes/resolve-provisioning.test.ts index 1c99b4942..2d65e901e 100644 --- a/packages/eve/src/setup/boxes/resolve-provisioning.test.ts +++ b/packages/eve/src/setup/boxes/resolve-provisioning.test.ts @@ -408,29 +408,6 @@ describe("resolveProvisioning box", () => { expect(deps.requireAuth).toHaveBeenCalled(); }); - it("resolves to Vercel without asking when Slack was selected earlier", async () => { - const deps = fakeDeps(); - // Only the project sub-question is asked; the where-to-run select would - // consume a value the prompter does not have, so reaching it throws. - const prompter = createPrompter({ selectValues: ["new"] }); - const box = makeBox({ - prompter, - targetDirectory: "/tmp/parent", - mode: { headless: false }, - deps, - }); - const state: SetupState = { ...stateWithAgentName("my-agent"), channelSelection: ["slack"] }; - - const result = await runInteractive([box], state, silentSink); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.vercelProject).toEqual({ kind: "new", project: "my-agent", team: "team" }); - expect(prompter.log.info).toHaveBeenCalledWith( - "Slack needs a public URL, so your agent will run on Vercel.", - ); - }); - it("resolves to Vercel without asking when a Connect-backed connection was selected", async () => { const deps = fakeDeps(); const prompter = createPrompter({ selectValues: ["new"] }); @@ -484,21 +461,6 @@ describe("resolveProvisioning box", () => { expect(provider?.hint).toBe("OPENAI_API_KEY"); }); - it("headless: refuses --skip-vercel against a Slack selection before any effect", async () => { - const deps = fakeDeps(); - const box = makeBox({ - prompter: createPrompter(), - targetDirectory: "/tmp/parent", - mode: { headless: true, project: { skipVercel: true }, aiGateway: {} }, - deps, - }); - const state: SetupState = { ...stateWithAgentName("my-agent"), channelSelection: ["slack"] }; - - await expect(runHeadless([box], state, silentSink)).rejects.toThrow( - "Slack requires a Vercel project. Remove --skip-vercel to add Slack.", - ); - }); - it("headless: refuses --skip-vercel against a Connect-backed connection selection", async () => { const deps = fakeDeps(); const box = makeBox({ diff --git a/packages/eve/src/setup/boxes/resolve-provisioning.ts b/packages/eve/src/setup/boxes/resolve-provisioning.ts index 6704893dd..64764e3f4 100644 --- a/packages/eve/src/setup/boxes/resolve-provisioning.ts +++ b/packages/eve/src/setup/boxes/resolve-provisioning.ts @@ -105,13 +105,11 @@ export interface ResolvedProvisioning { * flags. */ interface VercelDemands { - slack: boolean; connectSlugs: string[]; } function vercelDemands(state: Readonly): VercelDemands { return { - slack: state.channelSelection.includes("slack"), connectSlugs: state.connectionSelection .filter((plan) => plan.entry.auth?.kind === "connect") .map((plan) => plan.slug), @@ -127,9 +125,6 @@ function connectClause(connectSlugs: readonly string[]): string { /** The reasons behind a forced Vercel resolution; empty when free to choose. */ function vercelRequirements(demands: VercelDemands): string[] { const reasons: string[] = []; - if (demands.slack) { - reasons.push("Slack needs a public URL"); - } if (demands.connectSlugs.length > 0) { reasons.push(connectClause(demands.connectSlugs)); } @@ -438,9 +433,6 @@ export function resolveProvisioning( // resolves to Vercel for those selections. if (plans.vercelProject.kind === "none") { const demands = vercelDemands(state); - if (demands.slack) { - throw new Error("Slack requires a Vercel project. Remove --skip-vercel to add Slack."); - } if (demands.connectSlugs.length > 0) { throw new Error( `${connectClause(demands.connectSlugs)}, which needs a Vercel project. Remove --skip-vercel to add ${demands.connectSlugs.length === 1 ? "it" : "them"}.`, diff --git a/packages/eve/src/setup/integrations/channels/runner.ts b/packages/eve/src/setup/integrations/channel-scaffold-runner.ts similarity index 82% rename from packages/eve/src/setup/integrations/channels/runner.ts rename to packages/eve/src/setup/integrations/channel-scaffold-runner.ts index ffeccea90..81a6b73b8 100644 --- a/packages/eve/src/setup/integrations/channels/runner.ts +++ b/packages/eve/src/setup/integrations/channel-scaffold-runner.ts @@ -1,13 +1,14 @@ -import { addChannels } from "./setup.js"; +import { addChannels } from "./channel-scaffold.js"; import type { ChannelSetupContext, ChannelSetupResult } from "./types.js"; -import { runInteractive } from "../../runner.js"; -import type { AddChannelsState } from "./setup.js"; -import type { OutputSink } from "../../step.js"; +import { runInteractive } from "../runner.js"; +import type { AddChannelsState } from "./channel-scaffold.js"; +import type { OutputSink } from "../step.js"; /** Runs the shared scaffold box with decisions supplied by a channel integration. */ export async function runChannelSetup( context: ChannelSetupContext, options: { + kind: "slack" | "web"; configureVercelServices?: boolean; slackCredentials?: "vercel-connect" | "environment"; ensureLinkedProject?: "interactive-vercel-link"; @@ -16,6 +17,7 @@ export async function runChannelSetup( const box = addChannels({ asker: context.ui.asker, prompter: context.ui.prompter, + kind: options.kind, headless: context.headless, presetCreateSlackbot: context.presetCreateSlackbot, force: context.force, diff --git a/packages/eve/src/setup/integrations/channels/setup.test.ts b/packages/eve/src/setup/integrations/channel-scaffold.test.ts similarity index 88% rename from packages/eve/src/setup/integrations/channels/setup.test.ts rename to packages/eve/src/setup/integrations/channel-scaffold.test.ts index 99a3acce5..a00e32f5b 100644 --- a/packages/eve/src/setup/integrations/channels/setup.test.ts +++ b/packages/eve/src/setup/integrations/channel-scaffold.test.ts @@ -5,12 +5,12 @@ import { normalizeSlackConnectorSlug } from "#setup/scaffold/index.js"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import { headlessAsker, interactiveAsker } from "../../ask.js"; -import type { Prompter } from "../../prompter.js"; -import { createDefaultSetupState, snapshotSetupState, type SetupState } from "../../state.js"; -import type { OutputSink } from "../../step.js"; -import { runHeadless, runInteractive } from "../../runner.js"; -import { addChannels, type AddChannelsDeps, type AddChannelsOptions } from "./setup.js"; +import { headlessAsker, interactiveAsker } from "../ask.js"; +import type { Prompter } from "../prompter.js"; +import { createDefaultSetupState, snapshotSetupState, type SetupState } from "../state.js"; +import type { OutputSink } from "../step.js"; +import { runHeadless, runInteractive } from "../runner.js"; +import { addChannels, type AddChannelsDeps, type AddChannelsOptions } from "./channel-scaffold.js"; const silentSink: OutputSink = { write: () => {} }; const snapshot = { snapshot: snapshotSetupState }; @@ -28,11 +28,14 @@ function createPrompter(): Prompter { * {@link interactiveAsker} exactly as the old direct `prompter.select` did. */ function makeBox( - options: Omit & { headless?: boolean }, + options: Omit & { kind?: "slack" | "web" } & { + headless?: boolean; + }, ): ReturnType> { const headless = options.headless ?? false; return addChannels({ ...options, + kind: options.kind ?? "web", asker: headless ? headlessAsker() : interactiveAsker(options.prompter), headless, }); @@ -98,11 +101,10 @@ function createDeps() { }; } -function resolvedState(channelSelection: SetupState["channelSelection"] = ["web"]): SetupState { +function resolvedState(): SetupState { return { ...createDefaultSetupState(), agentName: "my-agent", - channelSelection, vercelProject: { kind: "new", project: "my-agent", team: "team" }, project: { kind: "linked", projectId: "prj_demo" }, projectPath: { kind: "resolved", inPlace: false, path: "/tmp/project" }, @@ -114,7 +116,6 @@ function noVercelState(): SetupState { return { ...createDefaultSetupState(), agentName: "my-agent", - channelSelection: ["web"], projectPath: { kind: "resolved", inPlace: false, path: "/tmp/project" }, }; } @@ -123,6 +124,7 @@ describe("addChannels box", () => { it("rejects Slack headlessly with a plain error before any effect", async () => { const deps = createDeps(); const box = makeBox({ + kind: "slack", prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, // A preset answer must NOT rescue headless Slack: the Connect create flow @@ -132,7 +134,7 @@ describe("addChannels box", () => { deps, }); - const run = runHeadless([box], resolvedState(["web", "slack"]), silentSink, snapshot); + const run = runHeadless([box], resolvedState(), silentSink, snapshot); await expect(run).rejects.toThrow( "Slack setup is interactive. Run `eve add channel/slack` from an interactive terminal.", @@ -146,18 +148,14 @@ describe("addChannels box", () => { it("uses portable Slack credentials without provisioning when Vercel is unavailable", async () => { const deps = createDeps(); const box = makeBox({ + kind: "slack", prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, slackCredentials: "environment", deps, }); - const next = await runHeadless( - [box], - { ...noVercelState(), channelSelection: ["slack"] }, - silentSink, - snapshot, - ); + const next = await runHeadless([box], noVercelState(), silentSink, snapshot); expect(deps.provisionSlackbot).not.toHaveBeenCalled(); expect(deps.ensureVercelProject).not.toHaveBeenCalled(); @@ -284,25 +282,18 @@ describe("addChannels box", () => { expect("webPackageVersions" in ensureOptions).toBe(false); }); - it("threads force to both channel scaffolds", async () => { + it("threads force to the selected channel scaffold", async () => { const deps = createDeps(); const box = makeBox({ + kind: "slack", prompter: createPrompter(), presetCreateSlackbot: true, force: true, deps, }); - const result = await runInteractive( - [box], - resolvedState(["web", "slack"]), - silentSink, - snapshot, - ); + const result = await runInteractive([box], resolvedState(), silentSink, snapshot); - expect(deps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "web", force: true }), - ); expect(deps.ensureChannel).toHaveBeenCalledWith( expect.objectContaining({ kind: "slack", force: true }), ); @@ -335,29 +326,14 @@ describe("addChannels box", () => { ); }); - it("no-ops cleanly on an empty channel selection", async () => { - const deps = createDeps(); - // The fake prompter throws on any prompt, so reaching the end also proves - // the slackbot question is not asked for an empty selection. - const box = makeBox({ prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, deps }); - - const next = await runInteractive([box], resolvedState([]), silentSink, snapshot); - - expect(deps.ensureChannel).not.toHaveBeenCalled(); - expect(deps.provisionSlackbot).not.toHaveBeenCalled(); - expect(next.kind).toBe("done"); - if (next.kind === "done") { - expect(next.state.channels).toEqual([]); - } - }); - it("links an unresolved project before provisioning when the link seam is set", async () => { const deps = createDeps(); - const state = resolvedState(["slack"]); + const state = resolvedState(); state.project = { kind: "unresolved" }; state.vercelProject = { kind: "none" }; const prompter = createPrompter(); const box = makeBox({ + kind: "slack", prompter, presetCreateSlackbot: true, ensureLinkedProject: "interactive-vercel-link", @@ -384,10 +360,11 @@ describe("addChannels box", () => { it("fails the link fallback with the engine's copy when `vercel link` fails", async () => { const deps = createDeps(); deps.ensureVercelProject.mockRejectedValue(new Error("Vercel project linking failed.")); - const state = resolvedState(["slack"]); + const state = resolvedState(); state.project = { kind: "unresolved" }; state.vercelProject = { kind: "none" }; const box = makeBox({ + kind: "slack", prompter: createPrompter(), presetCreateSlackbot: true, ensureLinkedProject: "interactive-vercel-link", @@ -409,9 +386,9 @@ describe("addChannels box", () => { workspaceName: "Vercel", }); const prompter = createFakePrompter({ single: () => "yes" }).prompter; - const box = makeBox({ prompter, evePackage: TEST_EVE_PACKAGE, deps }); + const box = makeBox({ kind: "slack", prompter, evePackage: TEST_EVE_PACKAGE, deps }); - const result = await runInteractive([box], resolvedState(["slack"]), silentSink, snapshot); + const result = await runInteractive([box], resolvedState(), silentSink, snapshot); expect(deps.provisionSlackbot.mock.invocationCallOrder[0]).toBeLessThan( deps.ensureChannel.mock.invocationCallOrder[0]!, @@ -450,9 +427,14 @@ describe("addChannels box", () => { expect(selected).toEqual({ uid: "slack/operations", id: "scl_operations" }); return { state: "attached", connectorUid: "slack/operations" }; }); - const box = makeBox({ prompter: fake.prompter, evePackage: TEST_EVE_PACKAGE, deps }); + const box = makeBox({ + kind: "slack", + prompter: fake.prompter, + evePackage: TEST_EVE_PACKAGE, + deps, + }); - await runInteractive([box], resolvedState(["slack"]), silentSink, snapshot); + await runInteractive([box], resolvedState(), silentSink, snapshot); expect(fake.selectMessages).toEqual(["Which Slack app would you like to use?"]); expect(pickerOptions).toEqual( @@ -473,13 +455,14 @@ describe("addChannels box", () => { // proves no question was asked. const fake = createFakePrompter(); const box = makeBox({ + kind: "slack", prompter: fake.prompter, evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, deps, }); - const result = await runInteractive([box], resolvedState(["slack"]), silentSink, snapshot); + const result = await runInteractive([box], resolvedState(), silentSink, snapshot); expect(fake.selectMessages).toEqual([]); expect(deps.provisionSlackbot).toHaveBeenCalledOnce(); @@ -494,12 +477,13 @@ describe("addChannels box", () => { })); const prompter = { ...createPrompter(), awaitChoice }; const box = makeBox({ + kind: "slack", prompter, presetCreateSlackbot: true, deps, }); - await runInteractive([box], resolvedState(["slack"]), silentSink, snapshot); + await runInteractive([box], resolvedState(), silentSink, snapshot); expect(deps.provisionSlackbot).toHaveBeenCalledWith( prompter.log, @@ -522,7 +506,11 @@ describe("addChannels box", () => { packageJsonUpdated: [], }); const prompter = createPrompter(); - const box = makeBox({ prompter, evePackage: TEST_EVE_PACKAGE, deps }); + const box = makeBox({ + prompter, + evePackage: TEST_EVE_PACKAGE, + deps, + }); const next = await runHeadless([box], resolvedState(), silentSink, snapshot); @@ -542,6 +530,7 @@ describe("addChannels box", () => { }); const prompter = createFakePrompter({ single: () => "yes" }).prompter; const box = makeBox({ + kind: "slack", prompter, evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, @@ -549,24 +538,18 @@ describe("addChannels box", () => { deps, }); - const result = await runInteractive( - [box], - resolvedState(["web", "slack"]), - silentSink, - snapshot, - ); + const result = await runInteractive([box], resolvedState(), silentSink, snapshot); expect(result.kind).toBe("done"); if (result.kind !== "done") return; - // Web survives; Slack records nothing, so a later add starts clean. - expect(result.state.channels).toEqual(["web"]); + // Slack records nothing, so a later add starts clean. + expect(result.state.channels).toEqual([]); expect(result.state.slackScaffolded).toBe(false); expect(result.state.slackbotCreated).toBe(false); expect(prompter.log.warning).toHaveBeenCalledWith( "Slackbot creation failed. Continuing without Slack — add it later with `eve add channel/slack`.", ); - // The slack channel scaffold never ran (only web's). - expect(deps.ensureChannel).toHaveBeenCalledTimes(1); + expect(deps.ensureChannel).not.toHaveBeenCalled(); expect(deps.reconcileSlackUid).not.toHaveBeenCalled(); }); @@ -577,12 +560,13 @@ describe("addChannels box", () => { }); const prompter = createPrompter(); const box = makeBox({ + kind: "slack", prompter, presetCreateSlackbot: true, deps, }); - const result = await runInteractive([box], resolvedState(["slack"]), silentSink, snapshot); + const result = await runInteractive([box], resolvedState(), silentSink, snapshot); expect(result).toEqual({ kind: "cancelled" }); expect(prompter.log.error).not.toHaveBeenCalled(); @@ -597,14 +581,13 @@ describe("addChannels box", () => { }); const prompter = createPrompter(); const box = makeBox({ + kind: "slack", prompter, presetCreateSlackbot: true, deps, }); - await expect( - runInteractive([box], resolvedState(["slack"]), silentSink, snapshot), - ).rejects.toThrow( + await expect(runInteractive([box], resolvedState(), silentSink, snapshot)).rejects.toThrow( "The abandoned Slack connector could not be removed. Slack channel was not added.", ); expect(prompter.log.error).toHaveBeenCalledWith( @@ -621,6 +604,7 @@ describe("addChannels box", () => { }); const prompter = createFakePrompter({ single: () => "yes" }).prompter; const box = makeBox({ + kind: "slack", prompter, evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, @@ -628,7 +612,7 @@ describe("addChannels box", () => { deps, }); - const result = await runInteractive([box], resolvedState(["slack"]), silentSink, snapshot); + const result = await runInteractive([box], resolvedState(), silentSink, snapshot); expect(result.kind).toBe("done"); if (result.kind !== "done") return; @@ -649,6 +633,7 @@ describe("addChannels box", () => { }); const prompter = createFakePrompter({ single: () => "yes" }).prompter; const box = makeBox({ + kind: "slack", prompter, evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, @@ -656,7 +641,7 @@ describe("addChannels box", () => { deps, }); - const result = await runInteractive([box], resolvedState(["slack"]), silentSink, snapshot); + const result = await runInteractive([box], resolvedState(), silentSink, snapshot); expect(result.kind).toBe("done"); if (result.kind !== "done") return; @@ -672,8 +657,9 @@ describe("addChannels box", () => { deps.provisionSlackbot.mockResolvedValue({ state: "not-installed", }); - const state = resolvedState(["slack"]); + const state = resolvedState(); const box = makeBox({ + kind: "slack", prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, @@ -697,6 +683,7 @@ describe("addChannels box", () => { }); const prompter = createFakePrompter({ single: () => "yes" }).prompter; const box = makeBox({ + kind: "slack", prompter, evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, @@ -704,7 +691,7 @@ describe("addChannels box", () => { deps, }); - const result = await runInteractive([box], resolvedState(["slack"]), silentSink, snapshot); + const result = await runInteractive([box], resolvedState(), silentSink, snapshot); expect(result.kind).toBe("done"); if (result.kind !== "done") return; @@ -722,15 +709,14 @@ describe("addChannels box", () => { connectorUid: "slack/my-agent", }); const box = makeBox({ + kind: "slack", prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, deps, }); - await expect( - runInteractive([box], resolvedState(["slack"]), silentSink, snapshot), - ).rejects.toThrow( + await expect(runInteractive([box], resolvedState(), silentSink, snapshot)).rejects.toThrow( "Slack workspace installation could not be verified. Slack channel was not added.", ); expect(deps.ensureChannel).not.toHaveBeenCalled(); @@ -742,15 +728,14 @@ describe("addChannels box", () => { state: "connector-lookup-failed", }); const box = makeBox({ + kind: "slack", prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, deps, }); - await expect( - runInteractive([box], resolvedState(["slack"]), silentSink, snapshot), - ).rejects.toThrow( + await expect(runInteractive([box], resolvedState(), silentSink, snapshot)).rejects.toThrow( "Existing Slack connectors could not be inspected. Slack channel was not added.", ); expect(deps.ensureChannel).not.toHaveBeenCalled(); @@ -762,8 +747,9 @@ describe("addChannels box", () => { state: "attach-failed", connectorUid: "slack/my-agent", }); - const state = resolvedState(["slack"]); + const state = resolvedState(); const box = makeBox({ + kind: "slack", prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, @@ -786,8 +772,9 @@ describe("addChannels box", () => { state: "existing-not-installed", connectorUid: "slack/my-agent", }); - const state = resolvedState(["slack"]); + const state = resolvedState(); const box = makeBox({ + kind: "slack", prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, @@ -810,8 +797,9 @@ describe("addChannels box", () => { packageJsonUpdated: [], }); deps.reconcileSlackUid.mockResolvedValue(false); - const state = resolvedState(["slack"]); + const state = resolvedState(); const box = makeBox({ + kind: "slack", prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, @@ -827,8 +815,9 @@ describe("addChannels box", () => { it("throws when Slack is selected without a Vercel project", async () => { const deps = createDeps(); - const state: SetupState = { ...noVercelState(), channelSelection: ["slack"] }; + const state: SetupState = noVercelState(); const box = makeBox({ + kind: "slack", prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, @@ -843,10 +832,11 @@ describe("addChannels box", () => { it("throws when the project resolution is missing", async () => { const deps = createDeps(); - const state = resolvedState(["slack"]); + const state = resolvedState(); // project stays unresolved: the link box did not record a resolution. state.project = { kind: "unresolved" }; const box = makeBox({ + kind: "slack", prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, presetCreateSlackbot: true, diff --git a/packages/eve/src/setup/integrations/channels/setup.ts b/packages/eve/src/setup/integrations/channel-scaffold.ts similarity index 97% rename from packages/eve/src/setup/integrations/channels/setup.ts rename to packages/eve/src/setup/integrations/channel-scaffold.ts index ea7c516ee..53622f506 100644 --- a/packages/eve/src/setup/integrations/channels/setup.ts +++ b/packages/eve/src/setup/integrations/channel-scaffold.ts @@ -16,16 +16,16 @@ import { isProjectResolved, mergeProjectResolution, type ProjectResolution, -} from "../../project-resolution.js"; -import type { Asker } from "../../ask.js"; -import type { Prompter } from "../../prompter.js"; +} from "../project-resolution.js"; +import type { Asker } from "../ask.js"; +import type { Prompter } from "../prompter.js"; import { provisionSlackbot, reconcileSlackUid, type ProvisionSlackbotOptions, type ProvisionSlackbotResult, -} from "../../slackbot.js"; -import { WizardCancelledError, type SetupBox } from "../../step.js"; +} from "../slackbot.js"; +import { WizardCancelledError, type SetupBox } from "../step.js"; /** State required by channel setup, kept narrow so the integration can move packages. */ export interface AddChannelsState { @@ -34,7 +34,6 @@ export interface AddChannelsState { | { kind: "unresolved"; inPlace: boolean } | { kind: "resolved"; inPlace: boolean; path: string }; project: ProjectResolution; - channelSelection: ChannelKind[]; channels: ChannelKind[]; webScaffolded: boolean; slackScaffolded: boolean; @@ -140,6 +139,8 @@ export interface AddChannelsDeps { } export interface AddChannelsOptions { + /** The integration channel this operation scaffolds. */ + kind: ChannelKind; /** Resolves the slackbot question; the composed stack decides how. */ asker: Asker; /** @@ -308,7 +309,7 @@ export function addChannels( payload: AddChannelsPayload, signal?: AbortSignal, ): Promise { - if (!state.channelSelection.includes("web")) return; + if (options.kind !== "web") return; if (state.webScaffolded) { // Already scaffolded by a prior attempt this run: record without // rewriting the files. @@ -428,7 +429,7 @@ export function addChannels( payload: AddChannelsPayload, signal?: AbortSignal, ): Promise { - if (!state.channelSelection.includes("slack")) return; + if (options.kind !== "slack") return; const slug = await deps.deriveSlackConnectorSlug(projectPath); if (options.slackCredentials === "environment") { @@ -565,15 +566,11 @@ export function addChannels( return { id: "add-channels", - async gather({ state }): Promise { + async gather(): Promise { const headless = options.headless ?? false; // Connect opens a browser and remains interactive. Environment-backed // Slack has no provisioning effect, so headless setup can scaffold it. - if ( - headless && - state.channelSelection.includes("slack") && - options.slackCredentials !== "environment" - ) { + if (headless && options.kind === "slack" && options.slackCredentials !== "environment") { throw new Error(SLACK_HEADLESS_ERROR); } return { headless, createSlackbot: options.presetCreateSlackbot }; diff --git a/packages/eve/src/setup/integrations/channels/index.test.ts b/packages/eve/src/setup/integrations/registry.test.ts similarity index 85% rename from packages/eve/src/setup/integrations/channels/index.test.ts rename to packages/eve/src/setup/integrations/registry.test.ts index aea89318e..8b8938a90 100644 --- a/packages/eve/src/setup/integrations/channels/index.test.ts +++ b/packages/eve/src/setup/integrations/registry.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import { interactiveAsker } from "../../ask.js"; -import type { AddChannelsDeps } from "./setup.js"; -import { channelSetupEnvironment } from "./environment.js"; -import { channelSetupIntegration, createChannelSetupUi } from "./index.js"; -import { createDefaultSetupState } from "../../state.js"; -import { WizardCancelledError } from "../../step.js"; +import { interactiveAsker } from "../ask.js"; +import type { AddChannelsDeps } from "./channel-scaffold.js"; +import { channelSetupEnvironment } from "./shared/environment.js"; +import { channelSetupIntegration, createChannelSetupUi } from "./registry.js"; +import { createDefaultSetupState } from "../state.js"; +import { WizardCancelledError } from "../step.js"; function context(prompter = createFakePrompter().prompter) { return { @@ -15,7 +15,6 @@ function context(prompter = createFakePrompter().prompter) { state: { ...createDefaultSetupState(), projectPath: { kind: "resolved", inPlace: true, path: "/tmp/project" } as const, - channelSelection: ["slack" as const], }, ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), }; @@ -49,7 +48,6 @@ describe("channel setup integrations", () => { ...context(fake.prompter), state: { ...context(fake.prompter).state, - channelSelection: ["web"], }, deps: { ensureChannel, diff --git a/packages/eve/src/setup/integrations/channels/index.ts b/packages/eve/src/setup/integrations/registry.ts similarity index 74% rename from packages/eve/src/setup/integrations/channels/index.ts rename to packages/eve/src/setup/integrations/registry.ts index ac6573a91..e569587ba 100644 --- a/packages/eve/src/setup/integrations/channels/index.ts +++ b/packages/eve/src/setup/integrations/registry.ts @@ -1,7 +1,7 @@ import type { ChannelSetupIntegration } from "./types.js"; -import { SLACK_CHANNEL_SETUP } from "./slack.js"; -import { WEB_CHANNEL_SETUP } from "./web.js"; -import type { ChannelKind } from "../../scaffold/index.js"; +import { SLACK_CHANNEL_SETUP } from "./slack/setup.js"; +import { WEB_CHANNEL_SETUP } from "./web/setup.js"; +import type { ChannelKind } from "../scaffold/index.js"; /** Built-in channel integrations in canonical picker order. */ export const CHANNEL_SETUP_INTEGRATIONS: readonly ChannelSetupIntegration[] = [ @@ -16,4 +16,4 @@ export function channelSetupIntegration(kind: ChannelKind): ChannelSetupIntegrat return integration; } -export { createChannelSetupUi } from "./ui.js"; +export { createChannelSetupUi } from "./shared/ui.js"; diff --git a/packages/eve/src/setup/integrations/channels/environment.test.ts b/packages/eve/src/setup/integrations/shared/environment.test.ts similarity index 100% rename from packages/eve/src/setup/integrations/channels/environment.test.ts rename to packages/eve/src/setup/integrations/shared/environment.test.ts diff --git a/packages/eve/src/setup/integrations/channels/environment.ts b/packages/eve/src/setup/integrations/shared/environment.ts similarity index 100% rename from packages/eve/src/setup/integrations/channels/environment.ts rename to packages/eve/src/setup/integrations/shared/environment.ts diff --git a/packages/eve/src/setup/integrations/channels/ui.test.ts b/packages/eve/src/setup/integrations/shared/ui.test.ts similarity index 100% rename from packages/eve/src/setup/integrations/channels/ui.test.ts rename to packages/eve/src/setup/integrations/shared/ui.test.ts diff --git a/packages/eve/src/setup/integrations/channels/ui.ts b/packages/eve/src/setup/integrations/shared/ui.ts similarity index 100% rename from packages/eve/src/setup/integrations/channels/ui.ts rename to packages/eve/src/setup/integrations/shared/ui.ts diff --git a/packages/eve/src/setup/integrations/channels/slack.ts b/packages/eve/src/setup/integrations/slack/setup.ts similarity index 89% rename from packages/eve/src/setup/integrations/channels/slack.ts rename to packages/eve/src/setup/integrations/slack/setup.ts index 564d73036..1811cbdcf 100644 --- a/packages/eve/src/setup/integrations/channels/slack.ts +++ b/packages/eve/src/setup/integrations/slack/setup.ts @@ -1,5 +1,5 @@ -import type { ChannelSetupIntegration } from "./types.js"; -import { runChannelSetup } from "./runner.js"; +import type { ChannelSetupIntegration } from "../types.js"; +import { runChannelSetup } from "../channel-scaffold-runner.js"; import { WizardCancelledError } from "../../step.js"; async function choosePortableCredentials( @@ -46,15 +46,15 @@ export const SLACK_CHANNEL_SETUP: ChannelSetupIntegration = { ); } - const result = await runChannelSetup( - context, - credentials === "environment" + const result = await runChannelSetup(context, { + kind: "slack", + ...(credentials === "environment" ? { slackCredentials: "environment" } : { slackCredentials: "vercel-connect", ensureLinkedProject: "interactive-vercel-link", - }, - ); + }), + }); if ( result.kind === "done" && credentials === "environment" && diff --git a/packages/eve/src/setup/integrations/channels/types.ts b/packages/eve/src/setup/integrations/types.ts similarity index 81% rename from packages/eve/src/setup/integrations/channels/types.ts rename to packages/eve/src/setup/integrations/types.ts index 414004f56..e0f095cc1 100644 --- a/packages/eve/src/setup/integrations/channels/types.ts +++ b/packages/eve/src/setup/integrations/types.ts @@ -1,8 +1,8 @@ -import type { AddChannelsDeps } from "./setup.js"; -import type { ChannelSetupEnvironment } from "./environment.js"; -import type { ChannelSetupUi } from "./ui.js"; -import type { ChannelKind } from "../../scaffold/index.js"; -import type { ProjectResolution } from "../../project-resolution.js"; +import type { AddChannelsDeps } from "./channel-scaffold.js"; +import type { ChannelSetupEnvironment } from "./shared/environment.js"; +import type { ChannelSetupUi } from "./shared/ui.js"; +import type { ChannelKind } from "../scaffold/index.js"; +import type { ProjectResolution } from "../project-resolution.js"; /** Narrow state owned by one channel setup invocation. */ export interface ChannelSetupState { @@ -11,7 +11,6 @@ export interface ChannelSetupState { | { kind: "unresolved"; inPlace: boolean } | { kind: "resolved"; inPlace: boolean; path: string }; readonly project: ProjectResolution; - readonly channelSelection: ChannelKind[]; readonly channels: ChannelKind[]; readonly webScaffolded: boolean; readonly slackScaffolded: boolean; diff --git a/packages/eve/src/setup/integrations/channels/web.ts b/packages/eve/src/setup/integrations/web/setup.ts similarity index 70% rename from packages/eve/src/setup/integrations/channels/web.ts rename to packages/eve/src/setup/integrations/web/setup.ts index 5f71a6df6..7eed14169 100644 --- a/packages/eve/src/setup/integrations/channels/web.ts +++ b/packages/eve/src/setup/integrations/web/setup.ts @@ -1,5 +1,5 @@ -import type { ChannelSetupIntegration } from "./types.js"; -import { runChannelSetup } from "./runner.js"; +import type { ChannelSetupIntegration } from "../types.js"; +import { runChannelSetup } from "../channel-scaffold-runner.js"; /** Web Chat's channel-owned setup behavior. */ export const WEB_CHANNEL_SETUP: ChannelSetupIntegration = { @@ -8,6 +8,7 @@ export const WEB_CHANNEL_SETUP: ChannelSetupIntegration = { hint: "Browser-based chat interface", setup(context) { return runChannelSetup(context, { + kind: "web", configureVercelServices: context.environment.vercel.kind === "available", }); }, diff --git a/packages/eve/src/setup/state.ts b/packages/eve/src/setup/state.ts index 2cb30d3de..7741a83f6 100644 --- a/packages/eve/src/setup/state.ts +++ b/packages/eve/src/setup/state.ts @@ -137,8 +137,6 @@ export interface SetupState { setupMode: SetupMode; modelId: string; modelWiring: WiringMode; - /** Channels chosen in the interview phase; scaffolded later by the channels box. */ - channelSelection: ChannelKind[]; /** Connections planned in the interview phase; scaffolded later by the connections box. */ connectionSelection: ConnectionPlan[]; /** Decided once by the resolve-provisioning box; executed later by the link box. */ @@ -184,7 +182,6 @@ export function createDefaultSetupState(): SetupState { setupMode: "complete", modelId: "", modelWiring: "gateway", - channelSelection: [], connectionSelection: [], vercelProject: { kind: "none" }, aiGateway: { kind: "inherit" }, @@ -226,7 +223,6 @@ export function snapshotSetupState(state: SetupState): SetupState { ...state, aiGatewayCredentials: Object.freeze({ ...state.aiGatewayCredentials }), aiGateway: Object.freeze({ ...state.aiGateway }), - channelSelection: Object.freeze([...state.channelSelection]) as ChannelKind[], connectionSelection: Object.freeze([...state.connectionSelection]) as ConnectionPlan[], channels: Object.freeze([...state.channels]) as ChannelKind[], project: Object.freeze({ ...state.project }) as ProjectResolution, From 4ec12fdde48d0f71f8f44cc113e339d3e86c545e Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:22:28 -0700 Subject: [PATCH 2/6] chore(eve): add integration setup refactor changeset Signed-off-by: owenkephart --- .changeset/single-integrations-restack.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/single-integrations-restack.md diff --git a/.changeset/single-integrations-restack.md b/.changeset/single-integrations-restack.md new file mode 100644 index 000000000..18dee3fc4 --- /dev/null +++ b/.changeset/single-integrations-restack.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Run guided setup for one selected integration at a time. This removes the unused multi-channel selection state from eve's internal setup flow. From 20160d7a20ccdf7046a05a74643f7d807edd8eb4 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:27:19 -0700 Subject: [PATCH 3/6] refactor(eve): dispatch setup through integration registry Signed-off-by: owenkephart --- .../cli/commands/integration-setup.test.ts | 9 ++- .../eve/src/cli/commands/integration-setup.ts | 68 +++++------------- .../src/setup/integrations/registry.test.ts | 6 +- .../eve/src/setup/integrations/registry.ts | 10 ++- packages/eve/src/setup/integrations/runner.ts | 71 +++++++++++++++++++ 5 files changed, 105 insertions(+), 59 deletions(-) create mode 100644 packages/eve/src/setup/integrations/runner.ts diff --git a/packages/eve/src/cli/commands/integration-setup.test.ts b/packages/eve/src/cli/commands/integration-setup.test.ts index e5f8e3e93..f49f66c44 100644 --- a/packages/eve/src/cli/commands/integration-setup.test.ts +++ b/packages/eve/src/cli/commands/integration-setup.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import type { AddChannelsDeps } from "#setup/integrations/channel-scaffold.js"; +import type { IntegrationSetupRunnerDeps } from "#setup/integrations/runner.js"; import { deriveSlackConnectorSlug } from "#setup/scaffold/index.js"; import { runIntegrationSetupCommand } from "./integration-setup.js"; @@ -57,9 +58,11 @@ describe("runIntegrationSetupCommand", () => { {}, { createPrompter: () => fake.prompter, - detectDeployment: vi.fn(async () => ({ state: "unlinked" as const })), - getVercelAuthStatus: vi.fn(async () => "cli-missing" as const), - addChannelsDeps: deps, + runnerDeps: { + detectDeployment: vi.fn(async () => ({ state: "unlinked" as const })), + getVercelAuthStatus: vi.fn(async () => "cli-missing" as const), + addChannelsDeps: deps, + } satisfies IntegrationSetupRunnerDeps, }, ); diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index 31f2fac54..d775dbb31 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -1,16 +1,10 @@ -import { interactiveAsker } from "#setup/ask.js"; -import type { AddChannelsDeps } from "#setup/integrations/channel-scaffold.js"; -import { - channelSetupEnvironment, - describeChannelSetupEnvironment, -} from "#setup/integrations/shared/environment.js"; -import { channelSetupIntegration, createChannelSetupUi } from "#setup/integrations/registry.js"; -import { detectDeployment, projectResolutionFromDeployment } from "#setup/project-resolution.js"; import { createPrompter, type Prompter } from "#setup/prompter.js"; import { createRegistrySetupClient } from "#setup/registry-setup-client.js"; -import { isEveProject, type ChannelKind } from "#setup/scaffold/index.js"; -import { createDefaultSetupState } from "#setup/state.js"; -import { getVercelAuthStatus } from "#setup/vercel-project.js"; +import { + runIntegrationSetup, + type IntegrationSetupRunnerDeps, +} from "#setup/integrations/runner.js"; +import { isEveProject } from "#setup/scaffold/index.js"; import { NOT_AN_AGENT_MESSAGE } from "./preconditions.js"; import type { RegistryCommandLogger } from "./registry.js"; @@ -22,17 +16,12 @@ export interface IntegrationSetupOptions { export interface IntegrationSetupDependencies { createPrompter?: () => Prompter; - detectDeployment: typeof detectDeployment; - getVercelAuthStatus: typeof getVercelAuthStatus; - addChannelsDeps?: AddChannelsDeps; + runnerDeps?: IntegrationSetupRunnerDeps; } -const defaultIntegrationSetupDependencies: IntegrationSetupDependencies = { - detectDeployment, - getVercelAuthStatus, -}; +const defaultIntegrationSetupDependencies: IntegrationSetupDependencies = {}; -/** Runs a built-in integration setup after its registry payload is installed. */ +/** Runs built-in integration setup after its registry payload is installed. */ export async function runIntegrationSetupCommand( logger: RegistryCommandLogger, appRoot: string, @@ -48,45 +37,24 @@ export async function runIntegrationSetupCommand( const client = createRegistrySetupClient({ signal: options.signal }); try { - if (kind !== "slack" && kind !== "web") { - throw new Error( - `Integration setup "${kind}" is not available in this version of eve. Upgrade eve and try again.`, - ); - } - const channelKind: ChannelKind = kind; const prompter = client?.prompter ?? dependencies.createPrompter?.() ?? createPrompter(); - const signal = client?.signal ?? options.signal; - const integration = channelSetupIntegration(channelKind); - prompter.intro(`Set up ${integration.label}`); - prompter.log.message("Checking Vercel setup..."); - const [deployment, authStatus] = await Promise.all([ - dependencies.detectDeployment(appRoot, { signal }), - dependencies.getVercelAuthStatus(appRoot, { signal }), - ]); - const project = projectResolutionFromDeployment(deployment); - const environment = channelSetupEnvironment(authStatus, project); - prompter.log.info(describeChannelSetupEnvironment(environment)); - const result = await integration.setup({ - environment, - state: { - ...createDefaultSetupState(), - project, - projectPath: { kind: "resolved", inPlace: true, path: appRoot }, + const result = await runIntegrationSetup( + kind, + { + appRoot, + prompter, + signal: client?.signal ?? options.signal, + yes: options.yes, }, - ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), - presetCreateSlackbot: options.yes ? true : undefined, - presetPortableCredentials: options.yes ? true : undefined, - skipDependencyMutation: true, - deps: dependencies.addChannelsDeps, - signal, - }); + dependencies.runnerDeps, + ); if (result.kind === "cancelled") { client?.cancel(); if (process.env.EVE_SETUP === "1") process.exitCode = 130; return; } prompter.outro("Integration set up."); - client?.complete(); + client?.complete(result.facts); } catch (error) { client?.fail(error); logger.error(error instanceof Error ? error.message : String(error)); diff --git a/packages/eve/src/setup/integrations/registry.test.ts b/packages/eve/src/setup/integrations/registry.test.ts index 8b8938a90..e780a3fdb 100644 --- a/packages/eve/src/setup/integrations/registry.test.ts +++ b/packages/eve/src/setup/integrations/registry.test.ts @@ -5,7 +5,7 @@ import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import { interactiveAsker } from "../ask.js"; import type { AddChannelsDeps } from "./channel-scaffold.js"; import { channelSetupEnvironment } from "./shared/environment.js"; -import { channelSetupIntegration, createChannelSetupUi } from "./registry.js"; +import { setupIntegration, createChannelSetupUi } from "./registry.js"; import { createDefaultSetupState } from "../state.js"; import { WizardCancelledError } from "../step.js"; @@ -28,7 +28,7 @@ describe("channel setup integrations", () => { }, }); - const result = await channelSetupIntegration("slack").setup(context(fake.prompter)); + const result = await setupIntegration("slack").setup(context(fake.prompter)); expect(result).toMatchObject({ kind: "cancelled" }); }); @@ -44,7 +44,7 @@ describe("channel setup integrations", () => { packageJsonUpdated: [], })); - const result = await channelSetupIntegration("web").setup({ + const result = await setupIntegration("web").setup({ ...context(fake.prompter), state: { ...context(fake.prompter).state, diff --git a/packages/eve/src/setup/integrations/registry.ts b/packages/eve/src/setup/integrations/registry.ts index e569587ba..a645406ca 100644 --- a/packages/eve/src/setup/integrations/registry.ts +++ b/packages/eve/src/setup/integrations/registry.ts @@ -1,7 +1,6 @@ import type { ChannelSetupIntegration } from "./types.js"; import { SLACK_CHANNEL_SETUP } from "./slack/setup.js"; import { WEB_CHANNEL_SETUP } from "./web/setup.js"; -import type { ChannelKind } from "../scaffold/index.js"; /** Built-in channel integrations in canonical picker order. */ export const CHANNEL_SETUP_INTEGRATIONS: readonly ChannelSetupIntegration[] = [ @@ -10,9 +9,14 @@ export const CHANNEL_SETUP_INTEGRATIONS: readonly ChannelSetupIntegration[] = [ ]; /** Resolves a channel setup integration by its filesystem-facing kind. */ -export function channelSetupIntegration(kind: ChannelKind): ChannelSetupIntegration { +/** Resolves one built-in setup integration by its registry setup name. */ +export function setupIntegration(kind: string): ChannelSetupIntegration { const integration = CHANNEL_SETUP_INTEGRATIONS.find((candidate) => candidate.kind === kind); - if (integration === undefined) throw new Error(`No channel setup integration for "${kind}".`); + if (integration === undefined) { + throw new Error( + `Integration setup "${kind}" is not available in this version of eve. Upgrade eve and try again.`, + ); + } return integration; } diff --git a/packages/eve/src/setup/integrations/runner.ts b/packages/eve/src/setup/integrations/runner.ts new file mode 100644 index 000000000..c6b416c9e --- /dev/null +++ b/packages/eve/src/setup/integrations/runner.ts @@ -0,0 +1,71 @@ +import { interactiveAsker } from "#setup/ask.js"; +import type { RegistrySetupFact } from "#setup/registry-setup-protocol.js"; +import { detectDeployment, projectResolutionFromDeployment } from "#setup/project-resolution.js"; +import type { Prompter } from "#setup/prompter.js"; +import { createDefaultSetupState } from "#setup/state.js"; +import { getVercelAuthStatus } from "#setup/vercel-project.js"; + +import type { AddChannelsDeps } from "./channel-scaffold.js"; +import { channelSetupEnvironment, describeChannelSetupEnvironment } from "./shared/environment.js"; +import { createChannelSetupUi } from "./shared/ui.js"; +import { setupIntegration } from "./registry.js"; + +/** Inputs shared by every registry-owned integration setup flow. */ +export interface RunIntegrationSetupOptions { + appRoot: string; + prompter: Prompter; + signal?: AbortSignal; + yes?: boolean; +} + +/** Effects shared by the built-in integration setup runner. */ +export interface IntegrationSetupRunnerDeps { + detectDeployment: typeof detectDeployment; + getVercelAuthStatus: typeof getVercelAuthStatus; + addChannelsDeps?: AddChannelsDeps; +} + +const defaultDeps: IntegrationSetupRunnerDeps = { + detectDeployment, + getVercelAuthStatus, +}; + +/** Outcome returned by one registry-owned integration setup flow. */ +export type IntegrationSetupOutcome = + | { kind: "cancelled" } + | { kind: "done"; facts?: readonly RegistrySetupFact[] }; + +/** Runs one built-in integration setup flow selected by its registry setup name. */ +export async function runIntegrationSetup( + kind: string, + options: RunIntegrationSetupOptions, + deps: IntegrationSetupRunnerDeps = defaultDeps, +): Promise { + const integration = setupIntegration(kind); + options.prompter.intro(`Set up ${integration.label}`); + options.prompter.log.message("Checking Vercel setup..."); + const [deployment, authStatus] = await Promise.all([ + deps.detectDeployment(options.appRoot, { signal: options.signal }), + deps.getVercelAuthStatus(options.appRoot, { signal: options.signal }), + ]); + const project = projectResolutionFromDeployment(deployment); + const environment = channelSetupEnvironment(authStatus, project); + options.prompter.log.info(describeChannelSetupEnvironment(environment)); + const result = await integration.setup({ + environment, + state: { + ...createDefaultSetupState(), + project, + projectPath: { kind: "resolved", inPlace: true, path: options.appRoot }, + }, + ui: createChannelSetupUi({ + asker: interactiveAsker(options.prompter), + prompter: options.prompter, + }), + presetCreateSlackbot: options.yes ? true : undefined, + skipDependencyMutation: true, + deps: deps.addChannelsDeps, + signal: options.signal, + }); + return result.kind === "cancelled" ? result : { kind: "done" }; +} From 1fa781a3c79da670fb2a2cef6b52cb7f6c71139e Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:36:07 -0700 Subject: [PATCH 4/6] chore(eve): remove refactor changeset Signed-off-by: owenkephart --- .changeset/single-integrations-restack.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/single-integrations-restack.md diff --git a/.changeset/single-integrations-restack.md b/.changeset/single-integrations-restack.md deleted file mode 100644 index 18dee3fc4..000000000 --- a/.changeset/single-integrations-restack.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"eve": patch ---- - -Run guided setup for one selected integration at a time. This removes the unused multi-channel selection state from eve's internal setup flow. From 01166b5b5fcc09d1239892774b81332571548703 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:50:16 -0700 Subject: [PATCH 5/6] refactor(eve): split integration setup flows Signed-off-by: owenkephart --- .../cli/commands/integration-setup.test.ts | 43 +- .../integrations/channel-scaffold-runner.ts | 35 - .../integrations/channel-scaffold.test.ts | 876 ------------------ .../setup/integrations/channel-scaffold.ts | 612 ------------ .../src/setup/integrations/registry.test.ts | 74 +- .../eve/src/setup/integrations/registry.ts | 17 +- packages/eve/src/setup/integrations/runner.ts | 28 +- .../integrations/shared/environment.test.ts | 10 +- .../setup/integrations/shared/environment.ts | 10 +- .../src/setup/integrations/shared/scaffold.ts | 46 + .../src/setup/integrations/shared/ui.test.ts | 6 +- .../eve/src/setup/integrations/shared/ui.ts | 7 +- .../eve/src/setup/integrations/slack/setup.ts | 254 ++++- packages/eve/src/setup/integrations/types.ts | 50 +- .../eve/src/setup/integrations/web/setup.ts | 57 +- packages/eve/src/setup/state.ts | 21 - 16 files changed, 392 insertions(+), 1754 deletions(-) delete mode 100644 packages/eve/src/setup/integrations/channel-scaffold-runner.ts delete mode 100644 packages/eve/src/setup/integrations/channel-scaffold.test.ts delete mode 100644 packages/eve/src/setup/integrations/channel-scaffold.ts create mode 100644 packages/eve/src/setup/integrations/shared/scaffold.ts diff --git a/packages/eve/src/cli/commands/integration-setup.test.ts b/packages/eve/src/cli/commands/integration-setup.test.ts index f49f66c44..925631d59 100644 --- a/packages/eve/src/cli/commands/integration-setup.test.ts +++ b/packages/eve/src/cli/commands/integration-setup.test.ts @@ -1,9 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import type { AddChannelsDeps } from "#setup/integrations/channel-scaffold.js"; -import type { IntegrationSetupRunnerDeps } from "#setup/integrations/runner.js"; -import { deriveSlackConnectorSlug } from "#setup/scaffold/index.js"; +import { runIntegrationSetup } from "#setup/integrations/runner.js"; import { runIntegrationSetupCommand } from "./integration-setup.js"; import type { RegistryCommandLogger } from "./registry.js"; @@ -14,41 +12,22 @@ vi.mock("#setup/scaffold/index.js", async (importOriginal) => ({ ...(await importOriginal()), isEveProject, })); +vi.mock("#setup/integrations/runner.js", () => ({ runIntegrationSetup: vi.fn() })); function logger(): RegistryCommandLogger & { errors: string[] } { const errors: string[] = []; return { errors, error: (message) => errors.push(message), log: () => {} }; } -function addChannelsDeps(): AddChannelsDeps { - return { - ensureChannel: vi.fn(async (options) => ({ - kind: "web", - action: "created", - filesWritten: [`${options.projectRoot}/app/page.tsx`], - filesSkipped: [], - packageJsonUpdated: [], - })), - deriveSlackConnectorSlug, - provisionSlackbot: vi.fn(), - reconcileSlackUid: vi.fn(async () => true), - detectPackageManager: vi.fn(async () => ({ - kind: "pnpm", - source: "default", - })), - runPackageManagerInstall: vi.fn(async () => true), - ensureVercelProject: vi.fn(async () => ({ orgId: "team-id", projectId: "project-id" })), - }; -} - afterEach(() => { process.exitCode = undefined; + vi.clearAllMocks(); }); describe("runIntegrationSetupCommand", () => { - it("runs registry-owned setup without mutating or installing dependencies", async () => { + it("delegates registry-owned setup to the integration runner", async () => { + vi.mocked(runIntegrationSetup).mockResolvedValue({ kind: "done" }); const output = logger(); - const deps = addChannelsDeps(); const fake = createFakePrompter(); await runIntegrationSetupCommand( @@ -58,18 +37,14 @@ describe("runIntegrationSetupCommand", () => { {}, { createPrompter: () => fake.prompter, - runnerDeps: { - detectDeployment: vi.fn(async () => ({ state: "unlinked" as const })), - getVercelAuthStatus: vi.fn(async () => "cli-missing" as const), - addChannelsDeps: deps, - } satisfies IntegrationSetupRunnerDeps, }, ); - expect(deps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "web", skipDependencyMutation: true }), + expect(runIntegrationSetup).toHaveBeenCalledWith( + "web", + expect.objectContaining({ appRoot: "/project", prompter: fake.prompter }), + undefined, ); - expect(deps.runPackageManagerInstall).not.toHaveBeenCalled(); expect(output.errors).toEqual([]); }); }); diff --git a/packages/eve/src/setup/integrations/channel-scaffold-runner.ts b/packages/eve/src/setup/integrations/channel-scaffold-runner.ts deleted file mode 100644 index 81a6b73b8..000000000 --- a/packages/eve/src/setup/integrations/channel-scaffold-runner.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { addChannels } from "./channel-scaffold.js"; -import type { ChannelSetupContext, ChannelSetupResult } from "./types.js"; -import { runInteractive } from "../runner.js"; -import type { AddChannelsState } from "./channel-scaffold.js"; -import type { OutputSink } from "../step.js"; - -/** Runs the shared scaffold box with decisions supplied by a channel integration. */ -export async function runChannelSetup( - context: ChannelSetupContext, - options: { - kind: "slack" | "web"; - configureVercelServices?: boolean; - slackCredentials?: "vercel-connect" | "environment"; - ensureLinkedProject?: "interactive-vercel-link"; - }, -): Promise { - const box = addChannels({ - asker: context.ui.asker, - prompter: context.ui.prompter, - kind: options.kind, - headless: context.headless, - presetCreateSlackbot: context.presetCreateSlackbot, - force: context.force, - configureVercelServices: options.configureVercelServices, - slackCredentials: options.slackCredentials, - ensureLinkedProject: options.ensureLinkedProject, - skipDependencyMutation: context.skipDependencyMutation, - deps: context.deps, - }); - const sink: OutputSink = { write: (line) => context.ui.prompter.log.message(line) }; - const result = await runInteractive([box], context.state as AddChannelsState, sink, { - signal: context.signal, - }); - return result.kind === "done" ? { kind: "done", state: result.state } : { kind: "cancelled" }; -} diff --git a/packages/eve/src/setup/integrations/channel-scaffold.test.ts b/packages/eve/src/setup/integrations/channel-scaffold.test.ts deleted file mode 100644 index a00e32f5b..000000000 --- a/packages/eve/src/setup/integrations/channel-scaffold.test.ts +++ /dev/null @@ -1,876 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { HumanActionRequiredError } from "#setup/human-action.js"; -import { normalizeSlackConnectorSlug } from "#setup/scaffold/index.js"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; - -import { headlessAsker, interactiveAsker } from "../ask.js"; -import type { Prompter } from "../prompter.js"; -import { createDefaultSetupState, snapshotSetupState, type SetupState } from "../state.js"; -import type { OutputSink } from "../step.js"; -import { runHeadless, runInteractive } from "../runner.js"; -import { addChannels, type AddChannelsDeps, type AddChannelsOptions } from "./channel-scaffold.js"; - -const silentSink: OutputSink = { write: () => {} }; -const snapshot = { snapshot: snapshotSetupState }; -const TEST_EVE_PACKAGE = { version: "latest", nodeEngine: ">=24" } as const; - -function createPrompter(): Prompter { - return createFakePrompter().prompter; -} - -/** - * Builds the box with the ask channel composed the way each composition site - * does: an interactive base over the test prompter, or the headless base paired - * with the box's `headless` flag. The slackbot question now travels the asker, - * so an interactive prompter's `single` handler answers it through - * {@link interactiveAsker} exactly as the old direct `prompter.select` did. - */ -function makeBox( - options: Omit & { kind?: "slack" | "web" } & { - headless?: boolean; - }, -): ReturnType> { - const headless = options.headless ?? false; - return addChannels({ - ...options, - kind: options.kind ?? "web", - asker: headless ? headlessAsker() : interactiveAsker(options.prompter), - headless, - }); -} - -/** Default fakes: every effect succeeds and the slackbot attaches cleanly. */ -function createDeps() { - return { - ensureChannel: vi.fn(async (options) => - options.kind === "web" - ? { - kind: "web", - action: "created", - filesWritten: ["/tmp/project/app/page.tsx"], - filesSkipped: [], - packageJsonUpdated: [ - { - path: "/tmp/project/package.json", - dependencies: ["next"], - devDependencies: [], - scripts: [], - }, - ], - } - : { - kind: "slack", - action: "created", - filesWritten: ["/tmp/project/agent/channels/slack.ts"], - filesSkipped: [], - packageJsonUpdated: - options.slackCredentials === "environment" - ? [] - : [ - { - path: "/tmp/project/package.json", - dependencies: ["@vercel/connect"], - devDependencies: [], - scripts: [], - }, - ], - slackConnectorSlug: normalizeSlackConnectorSlug("my-agent"), - }, - ), - deriveSlackConnectorSlug: vi.fn( - async (_projectRoot, hint) => normalizeSlackConnectorSlug(hint ?? "my-agent"), - ), - provisionSlackbot: vi.fn(async () => ({ - state: "attached", - connectorUid: "slack/my-agent", - chatUrl: "https://slack.com/app_redirect?app=A0&team=T0", - workspaceName: "Vercel", - })), - reconcileSlackUid: vi.fn(async () => true), - detectPackageManager: vi.fn(async () => ({ - kind: "pnpm", - source: "default", - })), - runPackageManagerInstall: vi.fn(async () => true), - ensureVercelProject: vi.fn(async () => ({ - orgId: "team_demo", - projectId: "prj_demo", - })), - }; -} - -function resolvedState(): SetupState { - return { - ...createDefaultSetupState(), - agentName: "my-agent", - vercelProject: { kind: "new", project: "my-agent", team: "team" }, - project: { kind: "linked", projectId: "prj_demo" }, - projectPath: { kind: "resolved", inPlace: false, path: "/tmp/project" }, - }; -} - -/** A flow that chose not to deploy to Vercel: no project planned or linked. */ -function noVercelState(): SetupState { - return { - ...createDefaultSetupState(), - agentName: "my-agent", - projectPath: { kind: "resolved", inPlace: false, path: "/tmp/project" }, - }; -} - -describe("addChannels box", () => { - it("rejects Slack headlessly with a plain error before any effect", async () => { - const deps = createDeps(); - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - // A preset answer must NOT rescue headless Slack: the Connect create flow - // opens a browser, so only the guided flow can finish it. - presetCreateSlackbot: true, - headless: true, - deps, - }); - - const run = runHeadless([box], resolvedState(), silentSink, snapshot); - - await expect(run).rejects.toThrow( - "Slack setup is interactive. Run `eve add channel/slack` from an interactive terminal.", - ); - // This is a command-mode mismatch, not a browser action the caller can resume. - await expect(run).rejects.not.toBeInstanceOf(HumanActionRequiredError); - expect(deps.ensureChannel).not.toHaveBeenCalled(); - expect(deps.provisionSlackbot).not.toHaveBeenCalled(); - }); - - it("uses portable Slack credentials without provisioning when Vercel is unavailable", async () => { - const deps = createDeps(); - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - slackCredentials: "environment", - deps, - }); - - const next = await runHeadless([box], noVercelState(), silentSink, snapshot); - - expect(deps.provisionSlackbot).not.toHaveBeenCalled(); - expect(deps.ensureVercelProject).not.toHaveBeenCalled(); - expect(deps.runPackageManagerInstall).not.toHaveBeenCalled(); - expect(deps.ensureChannel).toHaveBeenCalledWith({ - projectRoot: "/tmp/project", - kind: "slack", - slackConnectorSlug: "my-agent", - slackCredentials: "environment", - force: undefined, - }); - expect(next.channels).toEqual(["slack"]); - }); - - it("passes the web scaffold options through to ensureChannel", async () => { - const deps = createDeps(); - deps.detectPackageManager.mockResolvedValue({ - kind: "npm", - source: "package-manager-field", - }); - const box = makeBox({ prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, deps }); - - await runHeadless([box], resolvedState(), silentSink, snapshot); - - expect(deps.ensureChannel).toHaveBeenCalledWith({ - projectRoot: "/tmp/project", - kind: "web", - packageManager: "npm", - force: undefined, - webPackageVersions: { evePackage: TEST_EVE_PACKAGE }, - configureVercelServices: true, - }); - expect(deps.runPackageManagerInstall).toHaveBeenCalledWith( - "npm", - "/tmp/project", - expect.anything(), - ); - }); - - it("scaffolds Web Chat without Vercel Services config when not deploying to Vercel", async () => { - const deps = createDeps(); - const box = makeBox({ prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, deps }); - - const next = await runHeadless([box], noVercelState(), silentSink, snapshot); - - expect(deps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "web", configureVercelServices: false }), - ); - expect(next.channels).toEqual(["web"]); - expect(next.webScaffolded).toBe(true); - }); - - it("installs dependencies after recording channels and marks the deploy install done", async () => { - const deps = createDeps(); - const box = makeBox({ prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, deps }); - - const next = await runHeadless([box], resolvedState(), silentSink, snapshot); - - expect(deps.runPackageManagerInstall).toHaveBeenCalledExactlyOnceWith("pnpm", "/tmp/project", { - onOutput: expect.any(Function), - }); - expect(next.channels).toEqual(["web"]); - }); - - it("keeps channels recorded when the install fails, leaving the deploy install pending", async () => { - const deps = createDeps(); - deps.runPackageManagerInstall.mockResolvedValueOnce(false); - const prompter = createPrompter(); - const box = makeBox({ prompter, evePackage: TEST_EVE_PACKAGE, deps }); - - // An earlier success must go stale: the scaffold just changed package.json. - const next = await runHeadless([box], resolvedState(), silentSink, snapshot); - - expect(prompter.log.warning).toHaveBeenCalledWith( - "Dependency installation failed. The new channels stay unloadable until `pnpm install` or a deploy succeeds.", - ); - expect(next.channels).toEqual(["web"]); - }); - - it("skips the install when no channel was recorded", async () => { - const deps = createDeps(); - deps.ensureChannel.mockResolvedValueOnce({ - kind: "web", - action: "skipped", - skipReason: "nextjs-project", - filesWritten: [], - filesSkipped: ["/tmp/project/package.json"], - packageJsonUpdated: [], - }); - const box = makeBox({ prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, deps }); - - await runHeadless([box], resolvedState(), silentSink, snapshot); - - expect(deps.runPackageManagerInstall).not.toHaveBeenCalled(); - }); - - it("honors the configureVercelServices override over the Vercel-project gate", async () => { - const deps = createDeps(); - // The integration setup can pin the services config on even when unlinked, the - // behavior the dissolved engine had (ensureChannel defaulted it to true). - const box = makeBox({ - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - configureVercelServices: true, - deps, - }); - - await runHeadless([box], noVercelState(), silentSink, snapshot); - - expect(deps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "web", configureVercelServices: true }), - ); - }); - - it("omits webPackageVersions entirely when no evePackage is set", async () => { - const deps = createDeps(); - const box = makeBox({ prompter: createPrompter(), deps }); - - await runHeadless([box], resolvedState(), silentSink, snapshot); - - const [ensureOptions] = deps.ensureChannel.mock.calls[0]!; - // The key must be absent, not undefined-valued: ensureChannel resolves its - // build-stamped defaults only from a missing webPackageVersions input. - expect("webPackageVersions" in ensureOptions).toBe(false); - }); - - it("threads force to the selected channel scaffold", async () => { - const deps = createDeps(); - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - presetCreateSlackbot: true, - force: true, - deps, - }); - - const result = await runInteractive([box], resolvedState(), silentSink, snapshot); - - expect(deps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "slack", force: true }), - ); - expect(result.kind).toBe("done"); - }); - - it("warns for overwritten files, an overridden node engine, and competing Next.js configs", async () => { - const deps = createDeps(); - deps.ensureChannel.mockResolvedValueOnce({ - kind: "web", - action: "overwritten", - filesWritten: ["/tmp/project/app/page.tsx"], - filesOverwritten: ["/tmp/project/app/page.tsx"], - nodeEngineOverride: { previous: "22.x", next: "24.x" }, - competingNextConfigFiles: ["/tmp/project/next.config.mjs"], - filesSkipped: [], - packageJsonUpdated: [], - }); - const prompter = createPrompter(); - const box = makeBox({ prompter, evePackage: TEST_EVE_PACKAGE, force: true, deps }); - - await runHeadless([box], resolvedState(), silentSink, snapshot); - - expect(prompter.log.warning).toHaveBeenCalledWith("Overwrote /tmp/project/app/page.tsx"); - expect(prompter.log.warning).toHaveBeenCalledWith( - 'Overrode package.json engines.node from "22.x" to "24.x" because the previous value was not confined to the Node.js major selected by eve.', - ); - expect(prompter.log.warning).toHaveBeenCalledWith( - "Found competing Next.js config at /tmp/project/next.config.mjs; merge any needed settings into next.config.ts and remove it before starting the preview, or Next.js may ignore the generated eve rewrite.", - ); - }); - - it("links an unresolved project before provisioning when the link seam is set", async () => { - const deps = createDeps(); - const state = resolvedState(); - state.project = { kind: "unresolved" }; - state.vercelProject = { kind: "none" }; - const prompter = createPrompter(); - const box = makeBox({ - kind: "slack", - prompter, - presetCreateSlackbot: true, - ensureLinkedProject: "interactive-vercel-link", - deps, - }); - - const result = await runInteractive([box], state, silentSink, snapshot); - - expect(deps.ensureVercelProject).toHaveBeenCalledWith({ - appRoot: "/tmp/project", - prompter, - signal: undefined, - }); - expect(deps.ensureVercelProject.mock.invocationCallOrder[0]).toBeLessThan( - deps.provisionSlackbot.mock.invocationCallOrder[0]!, - ); - expect(result.kind).toBe("done"); - if (result.kind === "done") { - expect(result.state.project).toEqual({ kind: "linked", projectId: "prj_demo" }); - expect(result.state.channels).toEqual(["slack"]); - } - }); - - it("fails the link fallback with the engine's copy when `vercel link` fails", async () => { - const deps = createDeps(); - deps.ensureVercelProject.mockRejectedValue(new Error("Vercel project linking failed.")); - const state = resolvedState(); - state.project = { kind: "unresolved" }; - state.vercelProject = { kind: "none" }; - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - presetCreateSlackbot: true, - ensureLinkedProject: "interactive-vercel-link", - deps, - }); - - await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - "Vercel project linking failed.", - ); - expect(deps.provisionSlackbot).not.toHaveBeenCalled(); - }); - - it("scaffolds the exact connector UID without a second patch step", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "attached", - connectorUid: "slack/my-agent-2", - chatUrl: "https://slack.com/app_redirect?app=A0&team=T0", - workspaceName: "Vercel", - }); - const prompter = createFakePrompter({ single: () => "yes" }).prompter; - const box = makeBox({ kind: "slack", prompter, evePackage: TEST_EVE_PACKAGE, deps }); - - const result = await runInteractive([box], resolvedState(), silentSink, snapshot); - - expect(deps.provisionSlackbot.mock.invocationCallOrder[0]).toBeLessThan( - deps.ensureChannel.mock.invocationCallOrder[0]!, - ); - expect(deps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "slack", - slackConnectorUid: "slack/my-agent-2", - slackConnectorSlug: "my-agent", - }), - ); - expect(deps.reconcileSlackUid).not.toHaveBeenCalled(); - expect(result.kind).toBe("done"); - if (result.kind === "done") { - expect(result.state.channels).toEqual(["slack"]); - } - }); - - it("offers concrete existing connectors and a create-new option", async () => { - const deps = createDeps(); - let pickerOptions: unknown; - const fake = createFakePrompter({ - single: (options) => { - pickerOptions = options; - return "slack/operations"; - }, - }); - deps.provisionSlackbot.mockImplementation(async (_log, _root, _slug, _deps, options) => { - const selected = await options?.selectConnector?.( - [ - { uid: "slack/my-agent", id: "scl_expected" }, - { uid: "slack/operations", id: "scl_operations" }, - ], - { uid: "slack/my-agent", id: "scl_expected" }, - ); - expect(selected).toEqual({ uid: "slack/operations", id: "scl_operations" }); - return { state: "attached", connectorUid: "slack/operations" }; - }); - const box = makeBox({ - kind: "slack", - prompter: fake.prompter, - evePackage: TEST_EVE_PACKAGE, - deps, - }); - - await runInteractive([box], resolvedState(), silentSink, snapshot); - - expect(fake.selectMessages).toEqual(["Which Slack app would you like to use?"]); - expect(pickerOptions).toEqual( - expect.objectContaining({ - initialValue: "slack/my-agent", - options: [ - { value: "slack/my-agent", label: "Use slack/my-agent", hint: "Matches this agent" }, - { value: "slack/operations", label: "Use slack/operations" }, - { value: "create", label: "Create a new Slack app" }, - ], - }), - ); - }); - - it("does not prompt when the slackbot decision is preset", async () => { - const deps = createDeps(); - // The fake prompter throws on any unconfigured prompt, so reaching the end - // proves no question was asked. - const fake = createFakePrompter(); - const box = makeBox({ - kind: "slack", - prompter: fake.prompter, - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - const result = await runInteractive([box], resolvedState(), silentSink, snapshot); - - expect(fake.selectMessages).toEqual([]); - expect(deps.provisionSlackbot).toHaveBeenCalledOnce(); - expect(result.kind).toBe("done"); - }); - - it("passes concurrent retry controls separately from the setup log", async () => { - const deps = createDeps(); - const awaitChoice = vi.fn(() => ({ - choice: Promise.resolve(undefined), - close: vi.fn(), - })); - const prompter = { ...createPrompter(), awaitChoice }; - const box = makeBox({ - kind: "slack", - prompter, - presetCreateSlackbot: true, - deps, - }); - - await runInteractive([box], resolvedState(), silentSink, snapshot); - - expect(deps.provisionSlackbot).toHaveBeenCalledWith( - prompter.log, - "/tmp/project", - "my-agent", - undefined, - { awaitChoice, selectConnector: expect.any(Function) }, - ); - expect("awaitChoice" in prompter.log).toBe(false); - }); - - it("records nothing for web when a Next.js project skips the scaffold", async () => { - const deps = createDeps(); - deps.ensureChannel.mockResolvedValueOnce({ - kind: "web", - action: "skipped", - skipReason: "nextjs-project", - filesWritten: [], - filesSkipped: ["/tmp/project/package.json"], - packageJsonUpdated: [], - }); - const prompter = createPrompter(); - const box = makeBox({ - prompter, - evePackage: TEST_EVE_PACKAGE, - deps, - }); - - const next = await runHeadless([box], resolvedState(), silentSink, snapshot); - - expect(prompter.log.info).toHaveBeenCalledWith( - "Next.js project detected. Skipping Web Chat scaffolding.", - ); - // The deliberate asymmetry: a skipped Web scaffold is NOT recorded, so it - // cannot arm a deploy; a skipped Slack file write still records the channel. - expect(next.channels).toEqual([]); - expect(next.webScaffolded).toBe(false); - }); - - it("continues without Slack when creation fails under warn-and-continue", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "create-failed", - }); - const prompter = createFakePrompter({ single: () => "yes" }).prompter; - const box = makeBox({ - kind: "slack", - prompter, - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - slackbotFailure: "warn-and-continue", - deps, - }); - - const result = await runInteractive([box], resolvedState(), silentSink, snapshot); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - // Slack records nothing, so a later add starts clean. - expect(result.state.channels).toEqual([]); - expect(result.state.slackScaffolded).toBe(false); - expect(result.state.slackbotCreated).toBe(false); - expect(prompter.log.warning).toHaveBeenCalledWith( - "Slackbot creation failed. Continuing without Slack — add it later with `eve add channel/slack`.", - ); - expect(deps.ensureChannel).not.toHaveBeenCalled(); - expect(deps.reconcileSlackUid).not.toHaveBeenCalled(); - }); - - it("folds a cancelled Slack connection attempt without logging an error", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "cancelled", - }); - const prompter = createPrompter(); - const box = makeBox({ - kind: "slack", - prompter, - presetCreateSlackbot: true, - deps, - }); - - const result = await runInteractive([box], resolvedState(), silentSink, snapshot); - - expect(result).toEqual({ kind: "cancelled" }); - expect(prompter.log.error).not.toHaveBeenCalled(); - expect(deps.ensureChannel).not.toHaveBeenCalled(); - }); - - it("surfaces connector cleanup failure instead of claiming the attempt was cleaned up", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "cleanup-failed", - connectorUids: ["slack/my-agent"], - }); - const prompter = createPrompter(); - const box = makeBox({ - kind: "slack", - prompter, - presetCreateSlackbot: true, - deps, - }); - - await expect(runInteractive([box], resolvedState(), silentSink, snapshot)).rejects.toThrow( - "The abandoned Slack connector could not be removed. Slack channel was not added.", - ); - expect(prompter.log.error).toHaveBeenCalledWith( - "The abandoned Slack connector could not be removed. Slack channel was not added.", - ); - expect(deps.ensureChannel).not.toHaveBeenCalled(); - }); - - it("continues without Slack when attachment fails under warn-and-continue", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "attach-failed", - connectorUid: "slack/my-agent", - }); - const prompter = createFakePrompter({ single: () => "yes" }).prompter; - const box = makeBox({ - kind: "slack", - prompter, - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - slackbotFailure: "warn-and-continue", - deps, - }); - - const result = await runInteractive([box], resolvedState(), silentSink, snapshot); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.channels).toEqual([]); - expect(result.state.slackbotCreated).toBe(false); - // Not "eve add channel/slack": re-creating would orphan the connector - // that already exists; the attach remediation was printed by the provision. - expect(prompter.log.warning).toHaveBeenCalledWith( - "Slackbot provisioning did not attach this project. Slack channel was not added. Continuing without Slack — finish event delivery with the `vercel connect attach` command above.", - ); - }); - - it("continues without Slack when detach fails under warn-and-continue", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "detach-failed", - connectorUid: "slack/my-agent", - }); - const prompter = createFakePrompter({ single: () => "yes" }).prompter; - const box = makeBox({ - kind: "slack", - prompter, - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - slackbotFailure: "warn-and-continue", - deps, - }); - - const result = await runInteractive([box], resolvedState(), silentSink, snapshot); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.channels).toEqual([]); - expect(result.state.slackbotCreated).toBe(false); - expect(prompter.log.warning).toHaveBeenCalledWith( - "Slackbot provisioning could not replace the existing trigger destination. Slack channel was not added. Continuing without Slack — run the `vercel connect detach` and `vercel connect attach` commands above.", - ); - }); - - it("does not scaffold when the Slackbot has no workspace installation", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "not-installed", - }); - const state = resolvedState(); - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - "Slackbot is not connected to a Slack workspace. Slack channel was not added.", - ); - - // The whole point: no channel files land without a successful connection. - expect(deps.ensureChannel).not.toHaveBeenCalled(); - expect(state.channels).toEqual([]); - expect(state.slackScaffolded).toBe(false); - }); - - it("continues without Slack when the workspace install is missing under warn-and-continue", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "not-installed", - }); - const prompter = createFakePrompter({ single: () => "yes" }).prompter; - const box = makeBox({ - kind: "slack", - prompter, - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - slackbotFailure: "warn-and-continue", - deps, - }); - - const result = await runInteractive([box], resolvedState(), silentSink, snapshot); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.channels).toEqual([]); - expect(result.state.slackScaffolded).toBe(false); - expect(prompter.log.warning).toHaveBeenCalledWith( - "Slackbot is not connected to a Slack workspace. Slack channel was not added. Continuing without Slack — the install timed out and was cleaned up; re-run `eve add channel/slack` to try again.", - ); - }); - - it("does not misreport an installation lookup failure as an unfinished browser install", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "installation-check-failed", - connectorUid: "slack/my-agent", - }); - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - await expect(runInteractive([box], resolvedState(), silentSink, snapshot)).rejects.toThrow( - "Slack workspace installation could not be verified. Slack channel was not added.", - ); - expect(deps.ensureChannel).not.toHaveBeenCalled(); - }); - - it("does not create or scaffold when existing connectors cannot be inspected", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "connector-lookup-failed", - }); - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - await expect(runInteractive([box], resolvedState(), silentSink, snapshot)).rejects.toThrow( - "Existing Slack connectors could not be inspected. Slack channel was not added.", - ); - expect(deps.ensureChannel).not.toHaveBeenCalled(); - }); - - it("records nothing when Connect attachment fails, so a retry restarts cleanly", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "attach-failed", - connectorUid: "slack/my-agent", - }); - const state = resolvedState(); - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - "Slackbot provisioning did not attach this project. Slack channel was not added.", - ); - - expect(deps.ensureChannel).not.toHaveBeenCalled(); - expect(state.channels).toEqual([]); - expect(state.slackbotCreated).toBe(false); - expect(state.slackScaffolded).toBe(false); - }); - - it("reports a pre-existing connector without an installation as manual recovery", async () => { - const deps = createDeps(); - deps.provisionSlackbot.mockResolvedValue({ - state: "existing-not-installed", - connectorUid: "slack/my-agent", - }); - const state = resolvedState(); - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - "The existing Slack connector is not connected to a Slack workspace. Slack channel was not added.", - ); - expect(deps.ensureChannel).not.toHaveBeenCalled(); - }); - - it("surfaces a failed connector UID reconciliation and records no channel", async () => { - const deps = createDeps(); - deps.ensureChannel.mockResolvedValue({ - kind: "slack", - action: "skipped", - filesWritten: [], - filesSkipped: ["/tmp/project/agent/channels/slack.ts"], - packageJsonUpdated: [], - }); - deps.reconcileSlackUid.mockResolvedValue(false); - const state = resolvedState(); - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - "Slack connector UID update is required before deployment.", - ); - expect(state.channels).toEqual([]); - expect(state.slackbotCreated).toBe(false); - }); - - it("throws when Slack is selected without a Vercel project", async () => { - const deps = createDeps(); - const state: SetupState = noVercelState(); - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - /requires a linked Vercel project/, - ); - expect(deps.provisionSlackbot).not.toHaveBeenCalled(); - }); - - it("throws when the project resolution is missing", async () => { - const deps = createDeps(); - const state = resolvedState(); - // project stays unresolved: the link box did not record a resolution. - state.project = { kind: "unresolved" }; - const box = makeBox({ - kind: "slack", - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - /requires a linked Vercel project/, - ); - expect(deps.provisionSlackbot).not.toHaveBeenCalled(); - }); - - it("logs the one-line error through the rail before rethrowing", async () => { - const deps = createDeps(); - deps.ensureChannel.mockRejectedValueOnce(new Error("disk full\nlong stack detail")); - const prompter = createPrompter(); - const box = makeBox({ prompter, evePackage: TEST_EVE_PACKAGE, deps }); - - await expect(runHeadless([box], resolvedState(), silentSink, snapshot)).rejects.toThrow( - "disk full", - ); - expect(prompter.log.error).toHaveBeenCalledWith("disk full"); - }); - - it("applies the payload onto a copy, leaving the input state untouched", async () => { - const deps = createDeps(); - const state = resolvedState(); - const box = makeBox({ prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, deps }); - - const next = await runHeadless([box], state, silentSink, snapshot); - - expect(next.channels).toEqual(["web"]); - expect(next.webScaffolded).toBe(true); - expect(state.channels).toEqual([]); - expect(state.webScaffolded).toBe(false); - }); -}); diff --git a/packages/eve/src/setup/integrations/channel-scaffold.ts b/packages/eve/src/setup/integrations/channel-scaffold.ts deleted file mode 100644 index 53622f506..000000000 --- a/packages/eve/src/setup/integrations/channel-scaffold.ts +++ /dev/null @@ -1,612 +0,0 @@ -import { - deriveSlackConnectorSlug, - ensureChannel, - type ChannelKind, - type EnsureChannelOptions, - type EvePackageContract, - type SlackConnectorSlug, -} from "#setup/scaffold/index.js"; -import { HumanActionRequiredError } from "#setup/human-action.js"; -import { createPromptCommandOutput, withPhase, type ChannelSetupLog } from "#setup/cli/index.js"; -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 { ensureVercelProject } from "#setup/flows/ensure-vercel-project.js"; -import { - isProjectResolved, - mergeProjectResolution, - type ProjectResolution, -} from "../project-resolution.js"; -import type { Asker } from "../ask.js"; -import type { Prompter } from "../prompter.js"; -import { - provisionSlackbot, - reconcileSlackUid, - type ProvisionSlackbotOptions, - type ProvisionSlackbotResult, -} from "../slackbot.js"; -import { WizardCancelledError, type SetupBox } from "../step.js"; - -/** State required by channel setup, kept narrow so the integration can move packages. */ -export interface AddChannelsState { - projectPath: - | string - | { kind: "unresolved"; inPlace: boolean } - | { kind: "resolved"; inPlace: boolean; path: string }; - project: ProjectResolution; - channels: ChannelKind[]; - webScaffolded: boolean; - slackScaffolded: boolean; -} - -const SLACK_REQUIRES_VERCEL = "Slack setup with Vercel Connect requires a linked Vercel project."; - -const SLACK_HEADLESS_ERROR = - "Slack setup is interactive. Run `eve add channel/slack` from an interactive terminal."; - -const SLACKBOT_NOT_ATTACHED_ERROR = - "Slackbot provisioning did not attach this project. Slack channel was not added."; - -const SLACKBOT_NOT_DETACHED_ERROR = - "Slackbot provisioning could not replace the existing trigger destination. Slack channel was not added."; - -const SLACKBOT_EXISTING_NOT_INSTALLED_ERROR = - "The existing Slack connector is not connected to a Slack workspace. Slack channel was not added."; - -const SLACKBOT_NOT_INSTALLED_ERROR = - "Slackbot is not connected to a Slack workspace. Slack channel was not added."; - -const SLACKBOT_LOOKUP_FAILED_ERROR = - "Existing Slack connectors could not be inspected. Slack channel was not added."; - -const SLACKBOT_INSTALLATION_CHECK_FAILED_ERROR = - "Slack workspace installation could not be verified. Slack channel was not added."; - -const SLACKBOT_CLEANUP_FAILED_ERROR = - "The abandoned Slack connector could not be removed. Slack channel was not added."; - -type SlackbotFailure = Exclude< - ProvisionSlackbotResult, - { state: "attached" } | { state: "cancelled" } ->; - -interface SlackbotFailureCopy { - reason: string; - followUp: string; -} - -function slackbotFailureCopy(result: SlackbotFailure): SlackbotFailureCopy { - switch (result.state) { - case "not-installed": - return { - reason: SLACKBOT_NOT_INSTALLED_ERROR, - followUp: - "Continuing without Slack — the install timed out and was cleaned up; re-run `eve add channel/slack` to try again.", - }; - case "cleanup-failed": - return { - reason: SLACKBOT_CLEANUP_FAILED_ERROR, - followUp: - "Continuing without Slack — resolve the cleanup warning above before trying again.", - }; - case "connector-lookup-failed": - return { - reason: SLACKBOT_LOOKUP_FAILED_ERROR, - followUp: - "Continuing without Slack — restore Vercel CLI access, then re-run `eve add channel/slack`.", - }; - case "installation-check-failed": - return { - reason: SLACKBOT_INSTALLATION_CHECK_FAILED_ERROR, - followUp: - "Continuing without Slack — verify Vercel Connect is reachable, then re-run `eve add channel/slack`.", - }; - case "existing-not-installed": - return { - reason: SLACKBOT_EXISTING_NOT_INSTALLED_ERROR, - followUp: - "Continuing without Slack — resolve the existing connector warning above before trying again.", - }; - case "detach-failed": - return { - reason: SLACKBOT_NOT_DETACHED_ERROR, - followUp: - "Continuing without Slack — run the `vercel connect detach` and `vercel connect attach` commands above.", - }; - case "attach-failed": - return { - reason: SLACKBOT_NOT_ATTACHED_ERROR, - followUp: - "Continuing without Slack — finish event delivery with the `vercel connect attach` command above.", - }; - case "create-failed": - return { - reason: "Slackbot creation failed.", - followUp: "Continuing without Slack — add it later with `eve add channel/slack`.", - }; - } -} - -/** Injected for tests; defaults to the real scaffold, Connect, and Vercel effects. */ -export interface AddChannelsDeps { - ensureChannel: typeof ensureChannel; - deriveSlackConnectorSlug: typeof deriveSlackConnectorSlug; - provisionSlackbot: typeof provisionSlackbot; - reconcileSlackUid: typeof reconcileSlackUid; - detectPackageManager: typeof detectPackageManager; - runPackageManagerInstall: typeof runPackageManagerInstall; - ensureVercelProject: typeof ensureVercelProject; -} - -export interface AddChannelsOptions { - /** The integration channel this operation scaffolds. */ - kind: ChannelKind; - /** Resolves the slackbot question; the composed stack decides how. */ - asker: Asker; - /** - * Logs through `prompter.log` in `perform`, and owns the interactive - * `vercel link` fallback. The slackbot question itself now travels the asker, - * not this prompter. - */ - prompter: Prompter; - /** - * Headless mode: gates the interactive `vercel link` fallback inside `perform` - * and refuses Slack up front. Fixed at composition time (the same place the - * asker base is chosen), since `gather` cannot read the mode off the asker. - */ - headless?: boolean; - /** - * eve package metadata for the scaffolded web `package.json`. When omitted, - * every package value comes from the build-stamped defaults. - */ - evePackage?: EvePackageContract; - /** Reuse the preferred existing Slack connector without prompting. */ - presetCreateSlackbot?: boolean; - /** Overwrite existing channel files (`eve add --overwrite channel/slack`). */ - force?: boolean; - /** Credential source for Slack. Defaults to Vercel Connect. */ - slackCredentials?: "vercel-connect" | "environment"; - /** - * Override for the web scaffold's Vercel services config. The explicit - * environment plan takes precedence; otherwise this defaults to - * `hasVercelProject(state)`. - */ - configureVercelServices?: boolean; - /** - * Opt-in fallback when Slack is chosen interactively but `state.project` is - * 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"; - /** - * What a failed slackbot provision (create or attach) does to the run. The - * default, "abort", fails the whole box — right for `eve add channel/slack`, - * where Slack is the point. "warn-and-continue" records nothing so a later - * `eve add channel/slack` starts clean. - */ - slackbotFailure?: "abort" | "warn-and-continue"; - deps?: AddChannelsDeps; - /** Registry installation already owns package dependency mutations. */ - skipDependencyMutation?: boolean; -} - -/** - * Inputs resolved before `perform` runs channel effects. - */ -export interface AddChannelsInput { - headless: boolean; - createSlackbot: boolean | undefined; -} - -/** Slackbot facts resolved by a successful Connect provision. */ -export interface AddChannelsSlackbotFacts { - connectorUid: string; - /** Deep link that opens a DM compose with the bot ("chat with your agent"). */ - chatUrl?: string; - workspaceName?: string; -} - -/** - * What `perform` actually did. `channelsAdded` lists the channels recorded this - * run (web before slack); a skipped Web scaffold (Next.js detected) records - * nothing, deliberately. `slackbot` is present only after a fresh, fully - * attached provision; every failure mode either throws or (under - * `slackbotFailure: "warn-and-continue"`) skips Slack entirely, so a failed - * Slack setup records nothing (atomicity). - */ -export interface AddChannelsPayload { - channelsAdded: ChannelKind[]; - webScaffolded: boolean; - slackScaffolded: boolean; - /** - * Whether the post-scaffold dependency install succeeded. False both when no - * channels were recorded and when the install failed. - */ - dependenciesChanged: boolean; - dependenciesInstalled: boolean; - project: ProjectResolution; - slackbot?: AddChannelsSlackbotFacts; -} - -function warnOverwrittenFiles(log: ChannelSetupLog, files: readonly string[] | undefined): void { - for (const filePath of files ?? []) { - log.warning(`Overwrote ${filePath}`); - } -} - -function warnCompetingNextConfigFiles( - log: ChannelSetupLog, - files: readonly string[] | undefined, -): void { - for (const filePath of files ?? []) { - log.warning( - `Found competing Next.js config at ${filePath}; merge any needed settings into next.config.ts and remove it before starting the preview, or Next.js may ignore the generated eve rewrite.`, - ); - } -} - -/** - * Channel integration setup. Scaffolds the requested channel: writes the Web Chat files, - * provisions the Slackbot through Vercel Connect, writes the Slack channel - * definition, reconciles a Connect-assigned connector UID, and installs the - * dependencies the scaffold added to `package.json` so a running `eve dev` - * can load the new channel modules right away. The only prompt - * (the slackbot question) travels the asker in `gather`; `perform` is promptless - * and reads `state.project` directly, resolved earlier by the link box or the - * in-project seed. - */ -export function addChannels( - options: AddChannelsOptions, -): SetupBox { - const deps = options.deps ?? { - ensureChannel, - deriveSlackConnectorSlug, - provisionSlackbot, - reconcileSlackUid, - detectPackageManager, - runPackageManagerInstall, - ensureVercelProject, - }; - - async function scaffoldSlackChannel( - log: ChannelSetupLog, - state: Readonly, - projectPath: string, - slug: SlackConnectorSlug, - payload: AddChannelsPayload, - connectorUid: string, - ): Promise { - let wroteExactConnectorUid = false; - if (!state.slackScaffolded) { - const result = await deps.ensureChannel({ - projectRoot: projectPath, - kind: "slack", - slackConnectorUid: connectorUid, - slackConnectorSlug: slug, - force: options.force, - skipDependencyMutation: options.skipDependencyMutation, - }); - payload.dependenciesChanged ||= result.packageJsonUpdated.length > 0; - warnOverwrittenFiles(log, result.filesOverwritten); - if (result.action === "created" || result.action === "overwritten") { - log.success("Scaffolded channel: slack"); - } else { - log.info('Channel "slack" already exists. Skipping file creation.'); - } - wroteExactConnectorUid = result.action !== "skipped"; - payload.slackScaffolded = true; - } - // Slack is recorded even when the file already existed: the channel is live either way. - payload.channelsAdded.push("slack"); - return wroteExactConnectorUid; - } - - async function addWebChannelToPayload( - log: ChannelSetupLog, - state: Readonly, - projectPath: string, - packageManager: PackageManagerKind, - payload: AddChannelsPayload, - signal?: AbortSignal, - ): Promise { - if (options.kind !== "web") return; - if (state.webScaffolded) { - // Already scaffolded by a prior attempt this run: record without - // rewriting the files. - payload.channelsAdded.push("web"); - return; - } - - log.message("Scaffolding Web Chat channel files..."); - const ensureWebOptions: EnsureChannelOptions = { - projectRoot: projectPath, - kind: "web", - packageManager, - force: options.force, - configureVercelServices: options.configureVercelServices ?? isProjectResolved(state.project), - skipDependencyMutation: options.skipDependencyMutation, - }; - if (options.evePackage !== undefined) { - ensureWebOptions.webPackageVersions = { evePackage: options.evePackage }; - } - const result = await deps.ensureChannel(ensureWebOptions); - payload.dependenciesChanged ||= result.packageJsonUpdated.length > 0; - signal?.throwIfAborted(); - warnOverwrittenFiles(log, result.filesOverwritten); - if ( - result.kind === "web" && - result.action !== "skipped" && - result.nodeEngineOverride !== undefined - ) { - log.warning(formatNodeEngineOverrideWarning(result.nodeEngineOverride)); - } - warnCompetingNextConfigFiles( - log, - "competingNextConfigFiles" in result ? result.competingNextConfigFiles : undefined, - ); - if (result.action === "created" || result.action === "overwritten") { - log.success("Scaffolded channel: web"); - payload.webScaffolded = true; - payload.channelsAdded.push("web"); - return; - } - - // A skipped Web scaffold (the project already runs Next.js) records nothing. - log.info("Next.js project detected. Skipping Web Chat scaffolding."); - } - - function assertSlackProjectReady(state: Readonly): void { - if (options.ensureLinkedProject !== undefined) return; - if (!isProjectResolved(state.project)) throw new Error(SLACK_REQUIRES_VERCEL); - if (!isProjectResolved(state.project)) { - throw new Error("Expected a linked Vercel project for Slack, but none was resolved."); - } - } - - async function provisionSlackbotWithControls( - log: ChannelSetupLog, - projectPath: string, - slug: SlackConnectorSlug, - signal?: AbortSignal, - ): Promise { - const provisionOptions: ProvisionSlackbotOptions = { - selectConnector: async (connectors, preferred) => { - if (options.presetCreateSlackbot === true) return preferred ?? connectors[0]!; - const choices = connectors.map((connector) => { - const choice: { value: string; label: string; hint?: string } = { - value: connector.uid, - label: `Use ${connector.uid}`, - }; - if (connector.uid === preferred?.uid) choice.hint = "Matches this agent"; - return choice; - }); - const request = { - message: "Which Slack app would you like to use?", - options: [...choices, { value: "create", label: "Create a new Slack app" }], - initialValue: preferred?.uid, - }; - const selected = await options.prompter.select(request); - if (selected === "create") return "create"; - return connectors.find((connector) => connector.uid === selected)!; - }, - }; - if (signal !== undefined) provisionOptions.signal = signal; - if (options.prompter.awaitChoice !== undefined) { - provisionOptions.awaitChoice = options.prompter.awaitChoice; - } - return deps.provisionSlackbot(log, projectPath, slug, undefined, provisionOptions); - } - - async function scaffoldAttachedSlackChannel( - log: ChannelSetupLog, - state: Readonly, - projectPath: string, - slug: SlackConnectorSlug, - payload: AddChannelsPayload, - slackbot: Extract, - ): Promise { - const wroteExactConnectorUid = await scaffoldSlackChannel( - log, - state, - projectPath, - slug, - payload, - slackbot.connectorUid, - ); - if (wroteExactConnectorUid) return; - - const ready = await deps.reconcileSlackUid(log, projectPath, slackbot, `slack/${slug}`); - if (!ready) { - throw new Error("Slack connector UID update is required before deployment."); - } - } - - async function addSlackChannelToPayload( - log: ChannelSetupLog, - state: Readonly, - input: AddChannelsInput, - projectPath: string, - payload: AddChannelsPayload, - signal?: AbortSignal, - ): Promise { - if (options.kind !== "slack") return; - - const slug = await deps.deriveSlackConnectorSlug(projectPath); - if (options.slackCredentials === "environment") { - if (!state.slackScaffolded) { - const result = await deps.ensureChannel({ - projectRoot: projectPath, - kind: "slack", - slackConnectorSlug: slug, - slackCredentials: "environment", - force: options.force, - skipDependencyMutation: options.skipDependencyMutation, - }); - payload.dependenciesChanged ||= result.packageJsonUpdated.length > 0; - warnOverwrittenFiles(log, result.filesOverwritten); - if (result.action === "created" || result.action === "overwritten") { - log.success("Scaffolded channel: slack"); - } else { - log.info('Channel "slack" already exists. Skipping file creation.'); - } - payload.slackScaffolded = true; - } - payload.channelsAdded.push("slack"); - return; - } - - assertSlackProjectReady(state); - if (!isProjectResolved(payload.project)) { - // Only reachable with the ensureLinkedProject seam; without it the gate - // above already required a resolved project. - 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); - signal?.throwIfAborted(); - if (slackbot.state === "cancelled") { - // Provisioning already cleaned up its connector. - throw new WizardCancelledError(); - } - if (slackbot.state !== "attached") { - const copy = slackbotFailureCopy(slackbot); - if (options.slackbotFailure !== "warn-and-continue") { - throw new Error(copy.reason); - } - // Slack records nothing. A connector that exists but is not attached - // must be recovered from the command printed above, not re-created. - log.warning(`${copy.reason} ${copy.followUp}`); - return; - } - - payload.slackbot = { - connectorUid: slackbot.connectorUid, - chatUrl: slackbot.chatUrl, - workspaceName: slackbot.workspaceName, - }; - await scaffoldAttachedSlackChannel(log, state, projectPath, slug, payload, slackbot); - } - - async function installChannelDependencies( - log: ChannelSetupLog, - projectPath: string, - packageManager: PackageManagerKind, - payload: AddChannelsPayload, - signal?: AbortSignal, - ): Promise { - if (options.skipDependencyMutation || !payload.dependenciesChanged) return; - const installed = await withPhase( - log, - `Installing channel dependencies (${packageManager} install)...`, - () => - deps.runPackageManagerInstall(packageManager, projectPath, { - onOutput: createPromptCommandOutput(log), - signal, - }), - ); - if (installed) { - payload.dependenciesInstalled = true; - return; - } - - // The channels are durable; deploy retries the install. Until one - // succeeds, `eve dev` cannot load the new channel modules. - log.warning( - `Dependency installation failed. The new channels stay unloadable until \`${packageManager} install\` or a deploy succeeds.`, - ); - } - - async function performAddChannels( - state: Readonly, - input: AddChannelsInput, - signal?: AbortSignal, - ): Promise { - signal?.throwIfAborted(); - const log = options.prompter.log; - const projectPath = - typeof state.projectPath === "string" - ? state.projectPath - : state.projectPath.kind === "resolved" - ? state.projectPath.path - : undefined; - if (projectPath === undefined) throw new Error("Project path has not been resolved."); - const payload: AddChannelsPayload = { - channelsAdded: [], - webScaffolded: state.webScaffolded, - slackScaffolded: state.slackScaffolded, - dependenciesChanged: false, - dependenciesInstalled: false, - project: state.project, - }; - const packageManager = await deps.detectPackageManager(projectPath); - await addWebChannelToPayload(log, state, projectPath, packageManager.kind, payload, signal); - await addSlackChannelToPayload(log, state, input, projectPath, payload, signal); - // A retry after a failed run can find durable channel files but no install; - // recorded channels therefore always drive the dependency gate. - await installChannelDependencies(log, projectPath, packageManager.kind, payload, signal); - - return payload; - } - - return { - id: "add-channels", - - async gather(): Promise { - const headless = options.headless ?? false; - // Connect opens a browser and remains interactive. Environment-backed - // Slack has no provisioning effect, so headless setup can scaffold it. - if (headless && options.kind === "slack" && options.slackCredentials !== "environment") { - throw new Error(SLACK_HEADLESS_ERROR); - } - return { headless, createSlackbot: options.presetCreateSlackbot }; - }, - - async perform({ state, input, signal }): Promise { - try { - return await performAddChannels(state, input, signal); - } catch (error) { - if (!(error instanceof WizardCancelledError)) { - const message = error instanceof Error ? error.message : String(error); - const oneLine = message.split("\n")[0]?.trim() ?? message; - options.prompter.log.error(oneLine); - } - throw error; - } - }, - - apply(state, payload) { - const channels = [...state.channels]; - for (const channel of payload.channelsAdded) { - if (!channels.includes(channel)) { - channels.push(channel); - } - } - const next: State = { - ...state, - channels, - webScaffolded: payload.webScaffolded, - slackScaffolded: payload.slackScaffolded, - project: mergeProjectResolution(state.project, payload.project), - } as State; - if (payload.slackbot === undefined) { - return next; - } - return next; - }, - }; -} diff --git a/packages/eve/src/setup/integrations/registry.test.ts b/packages/eve/src/setup/integrations/registry.test.ts index e780a3fdb..f2a42ffa1 100644 --- a/packages/eve/src/setup/integrations/registry.test.ts +++ b/packages/eve/src/setup/integrations/registry.test.ts @@ -1,72 +1,46 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; +import type { Asker } from "#setup/ask.js"; -import { interactiveAsker } from "../ask.js"; -import type { AddChannelsDeps } from "./channel-scaffold.js"; -import { channelSetupEnvironment } from "./shared/environment.js"; -import { setupIntegration, createChannelSetupUi } from "./registry.js"; -import { createDefaultSetupState } from "../state.js"; +import { integrationSetupEnvironment } from "./shared/environment.js"; +import { setupIntegration } from "./registry.js"; import { WizardCancelledError } from "../step.js"; +const unusedAsker: Asker = { + ask: async () => undefined as T, + askMany: async () => [], +}; + function context(prompter = createFakePrompter().prompter) { return { - environment: channelSetupEnvironment("logged-out", { kind: "unresolved" } as const), - state: { - ...createDefaultSetupState(), - projectPath: { kind: "resolved", inPlace: true, path: "/tmp/project" } as const, + appRoot: "/tmp/project", + environment: integrationSetupEnvironment("logged-out", { kind: "unresolved" } as const), + ui: { + asker: unusedAsker, + prompter, + confirm: async () => false, + nextSteps: () => {}, }, - ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), }; } -describe("channel setup integrations", () => { - it("keeps picker cancellation as a structured result", async () => { +describe("setup integrations", () => { + it("keeps Slack credential-picker cancellation as a structured result", async () => { const fake = createFakePrompter({ single: () => { throw new WizardCancelledError(); }, }); - const result = await setupIntegration("slack").setup(context(fake.prompter)); - - expect(result).toMatchObject({ kind: "cancelled" }); - }); - - it("keeps Web setup independent of Slack credential prompts", async () => { - const fake = createFakePrompter(); - const ensureChannel = vi.fn(async () => ({ - kind: "web", - action: "skipped", - skipReason: "nextjs-project", - filesWritten: [], - filesSkipped: ["/tmp/project/package.json"], - packageJsonUpdated: [], - })); - - const result = await setupIntegration("web").setup({ - ...context(fake.prompter), - state: { - ...context(fake.prompter).state, - }, - deps: { - ensureChannel, - deriveSlackConnectorSlug: vi.fn(), - provisionSlackbot: vi.fn(), - reconcileSlackUid: vi.fn(), - detectPackageManager: vi.fn(async () => ({ - kind: "pnpm", - source: "default", - })), - runPackageManagerInstall: vi.fn(), - ensureVercelProject: vi.fn(), - }, + await expect(setupIntegration("slack").setup(context(fake.prompter))).resolves.toEqual({ + kind: "cancelled", }); + }); - expect(result.kind).toBe("done"); - expect(ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "web", configureVercelServices: false }), + it("rejects an unknown integration", () => { + expect(() => setupIntegration("unknown")).toThrow( + 'Integration setup "unknown" is not available', ); - expect(fake.selectMessages).toEqual([]); }); }); diff --git a/packages/eve/src/setup/integrations/registry.ts b/packages/eve/src/setup/integrations/registry.ts index a645406ca..d250ae1cc 100644 --- a/packages/eve/src/setup/integrations/registry.ts +++ b/packages/eve/src/setup/integrations/registry.ts @@ -1,17 +1,14 @@ -import type { ChannelSetupIntegration } from "./types.js"; -import { SLACK_CHANNEL_SETUP } from "./slack/setup.js"; -import { WEB_CHANNEL_SETUP } from "./web/setup.js"; +import type { SetupIntegration } from "./types.js"; +import { SLACK_SETUP } from "./slack/setup.js"; +import { WEB_SETUP } from "./web/setup.js"; /** Built-in channel integrations in canonical picker order. */ -export const CHANNEL_SETUP_INTEGRATIONS: readonly ChannelSetupIntegration[] = [ - WEB_CHANNEL_SETUP, - SLACK_CHANNEL_SETUP, -]; +export const SETUP_INTEGRATIONS: readonly SetupIntegration[] = [WEB_SETUP, SLACK_SETUP]; /** Resolves a channel setup integration by its filesystem-facing kind. */ /** Resolves one built-in setup integration by its registry setup name. */ -export function setupIntegration(kind: string): ChannelSetupIntegration { - const integration = CHANNEL_SETUP_INTEGRATIONS.find((candidate) => candidate.kind === kind); +export function setupIntegration(kind: string): SetupIntegration { + const integration = SETUP_INTEGRATIONS.find((candidate) => candidate.kind === kind); if (integration === undefined) { throw new Error( `Integration setup "${kind}" is not available in this version of eve. Upgrade eve and try again.`, @@ -20,4 +17,4 @@ export function setupIntegration(kind: string): ChannelSetupIntegration { return integration; } -export { createChannelSetupUi } from "./shared/ui.js"; +export { createIntegrationSetupUi } from "./shared/ui.js"; diff --git a/packages/eve/src/setup/integrations/runner.ts b/packages/eve/src/setup/integrations/runner.ts index c6b416c9e..46673758c 100644 --- a/packages/eve/src/setup/integrations/runner.ts +++ b/packages/eve/src/setup/integrations/runner.ts @@ -2,12 +2,13 @@ import { interactiveAsker } from "#setup/ask.js"; import type { RegistrySetupFact } from "#setup/registry-setup-protocol.js"; import { detectDeployment, projectResolutionFromDeployment } from "#setup/project-resolution.js"; import type { Prompter } from "#setup/prompter.js"; -import { createDefaultSetupState } from "#setup/state.js"; import { getVercelAuthStatus } from "#setup/vercel-project.js"; -import type { AddChannelsDeps } from "./channel-scaffold.js"; -import { channelSetupEnvironment, describeChannelSetupEnvironment } from "./shared/environment.js"; -import { createChannelSetupUi } from "./shared/ui.js"; +import { + integrationSetupEnvironment, + describeIntegrationSetupEnvironment, +} from "./shared/environment.js"; +import { createIntegrationSetupUi } from "./shared/ui.js"; import { setupIntegration } from "./registry.js"; /** Inputs shared by every registry-owned integration setup flow. */ @@ -22,7 +23,6 @@ export interface RunIntegrationSetupOptions { export interface IntegrationSetupRunnerDeps { detectDeployment: typeof detectDeployment; getVercelAuthStatus: typeof getVercelAuthStatus; - addChannelsDeps?: AddChannelsDeps; } const defaultDeps: IntegrationSetupRunnerDeps = { @@ -49,23 +49,17 @@ export async function runIntegrationSetup( deps.getVercelAuthStatus(options.appRoot, { signal: options.signal }), ]); const project = projectResolutionFromDeployment(deployment); - const environment = channelSetupEnvironment(authStatus, project); - options.prompter.log.info(describeChannelSetupEnvironment(environment)); + const environment = integrationSetupEnvironment(authStatus, project); + options.prompter.log.info(describeIntegrationSetupEnvironment(environment)); const result = await integration.setup({ environment, - state: { - ...createDefaultSetupState(), - project, - projectPath: { kind: "resolved", inPlace: true, path: options.appRoot }, - }, - ui: createChannelSetupUi({ + appRoot: options.appRoot, + ui: createIntegrationSetupUi({ asker: interactiveAsker(options.prompter), prompter: options.prompter, }), - presetCreateSlackbot: options.yes ? true : undefined, - skipDependencyMutation: true, - deps: deps.addChannelsDeps, + yes: options.yes, signal: options.signal, }); - return result.kind === "cancelled" ? result : { kind: "done" }; + return result; } diff --git a/packages/eve/src/setup/integrations/shared/environment.test.ts b/packages/eve/src/setup/integrations/shared/environment.test.ts index 77ea05bbd..38ee3a37d 100644 --- a/packages/eve/src/setup/integrations/shared/environment.test.ts +++ b/packages/eve/src/setup/integrations/shared/environment.test.ts @@ -1,22 +1,22 @@ import { describe, expect, it } from "vitest"; -import { channelSetupEnvironment, describeChannelSetupEnvironment } from "./environment.js"; +import { integrationSetupEnvironment, describeIntegrationSetupEnvironment } from "./environment.js"; describe("channel setup environment", () => { it("keeps authenticated and unlinked as an available Vercel setup", () => { - const environment = channelSetupEnvironment("authenticated", { kind: "unresolved" }); + const environment = integrationSetupEnvironment("authenticated", { kind: "unresolved" }); expect(environment).toEqual({ vercel: { kind: "available", project: { kind: "unresolved" } }, }); - expect(describeChannelSetupEnvironment(environment)).toBe( + expect(describeIntegrationSetupEnvironment(environment)).toBe( "Found an authenticated Vercel account; this directory is not linked to a project.", ); }); it("reports the credential choice when logged out", () => { - const environment = channelSetupEnvironment("logged-out", { kind: "unresolved" }); + const environment = integrationSetupEnvironment("logged-out", { kind: "unresolved" }); expect(environment).toEqual({ vercel: { kind: "unavailable", reason: "logged-out" } }); - expect(describeChannelSetupEnvironment(environment)).toBe( + expect(describeIntegrationSetupEnvironment(environment)).toBe( "No authenticated Vercel account found; choose Vercel Connect or portable credentials.", ); }); diff --git a/packages/eve/src/setup/integrations/shared/environment.ts b/packages/eve/src/setup/integrations/shared/environment.ts index 3f887302c..6927c89e5 100644 --- a/packages/eve/src/setup/integrations/shared/environment.ts +++ b/packages/eve/src/setup/integrations/shared/environment.ts @@ -2,14 +2,16 @@ import type { ProjectResolution } from "../../project-resolution.js"; import type { VercelAuthStatus } from "../../vercel-project.js"; /** Read-only hosting facts available to channel-owned setup hooks. */ -export interface ChannelSetupEnvironment { +export interface IntegrationSetupEnvironment { vercel: | { kind: "available"; project: ProjectResolution } | { kind: "unavailable"; reason: Exclude }; } /** Describes the result of the read-only Vercel capability probe. */ -export function describeChannelSetupEnvironment(environment: ChannelSetupEnvironment): string { +export function describeIntegrationSetupEnvironment( + environment: IntegrationSetupEnvironment, +): string { if (environment.vercel.kind === "available") { switch (environment.vercel.project.kind) { case "deployed": @@ -31,10 +33,10 @@ export function describeChannelSetupEnvironment(environment: ChannelSetupEnviron } /** Builds channel setup facts from the independent Vercel probes. */ -export function channelSetupEnvironment( +export function integrationSetupEnvironment( authStatus: VercelAuthStatus, project: ProjectResolution, -): ChannelSetupEnvironment { +): IntegrationSetupEnvironment { return authStatus === "authenticated" ? { vercel: { kind: "available", project } } : { vercel: { kind: "unavailable", reason: authStatus } }; diff --git a/packages/eve/src/setup/integrations/shared/scaffold.ts b/packages/eve/src/setup/integrations/shared/scaffold.ts new file mode 100644 index 000000000..233e8436e --- /dev/null +++ b/packages/eve/src/setup/integrations/shared/scaffold.ts @@ -0,0 +1,46 @@ +import { createPromptCommandOutput, withPhase, type ChannelSetupLog } from "#setup/cli/index.js"; +import { detectPackageManager } from "#setup/package-manager.js"; +import { runPackageManagerInstall } from "#setup/primitives/pm/run.js"; + +/** Effects used to install dependencies added by an integration scaffold. */ +export interface IntegrationScaffoldDeps { + detectPackageManager: typeof detectPackageManager; + runPackageManagerInstall: typeof runPackageManagerInstall; +} + +const defaultDeps: IntegrationScaffoldDeps = { detectPackageManager, runPackageManagerInstall }; + +/** Installs dependencies added by an integration scaffold without failing setup if installation fails. */ +export async function installScaffoldDependencies(input: { + changed: boolean; + log: ChannelSetupLog; + projectPath: string; + signal?: AbortSignal; + skip?: boolean; + deps?: IntegrationScaffoldDeps; +}): Promise { + if (!input.changed || input.skip) return; + const deps = input.deps ?? defaultDeps; + const packageManager = await deps.detectPackageManager(input.projectPath); + const installed = await withPhase( + input.log, + `Installing channel dependencies (${packageManager.kind} install)...`, + () => + deps.runPackageManagerInstall(packageManager.kind, input.projectPath, { + onOutput: createPromptCommandOutput(input.log), + signal: input.signal, + }), + ); + if (installed) return; + input.log.warning( + `Dependency installation failed. The new channel stays unloadable until \`${packageManager.kind} install\` or a deploy succeeds.`, + ); +} + +/** Reports files overwritten by one integration scaffold. */ +export function reportOverwrittenFiles( + log: ChannelSetupLog, + files: readonly string[] | undefined, +): void { + for (const filePath of files ?? []) log.warning(`Overwrote ${filePath}`); +} diff --git a/packages/eve/src/setup/integrations/shared/ui.test.ts b/packages/eve/src/setup/integrations/shared/ui.test.ts index e6db1e316..3ccab13b8 100644 --- a/packages/eve/src/setup/integrations/shared/ui.test.ts +++ b/packages/eve/src/setup/integrations/shared/ui.test.ts @@ -3,12 +3,12 @@ import { describe, expect, it } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import { interactiveAsker } from "../../ask.js"; -import { createChannelSetupUi } from "./ui.js"; +import { createIntegrationSetupUi } from "./ui.js"; -describe("createChannelSetupUi", () => { +describe("createIntegrationSetupUi", () => { it("renders integration-owned next steps through the shared notice", () => { const fake = createFakePrompter(); - const ui = createChannelSetupUi({ + const ui = createIntegrationSetupUi({ asker: interactiveAsker(fake.prompter), prompter: fake.prompter, }); diff --git a/packages/eve/src/setup/integrations/shared/ui.ts b/packages/eve/src/setup/integrations/shared/ui.ts index e44b9101e..d2fe9bc4a 100644 --- a/packages/eve/src/setup/integrations/shared/ui.ts +++ b/packages/eve/src/setup/integrations/shared/ui.ts @@ -2,7 +2,7 @@ import { confirm, SkippedSignal, type Asker } from "../../ask.js"; import type { Prompter } from "../../prompter.js"; /** UI capabilities available to a channel-owned setup hook. */ -export interface ChannelSetupUi { +export interface IntegrationSetupUi { readonly asker: Asker; readonly prompter: Prompter; confirm(input: { key: string; message: string; recommended?: boolean }): Promise; @@ -10,7 +10,10 @@ export interface ChannelSetupUi { } /** Adapts the shared setup asker and prompter to the channel hook UI. */ -export function createChannelSetupUi(input: { asker: Asker; prompter: Prompter }): ChannelSetupUi { +export function createIntegrationSetupUi(input: { + asker: Asker; + prompter: Prompter; +}): IntegrationSetupUi { return { ...input, async confirm(question) { diff --git a/packages/eve/src/setup/integrations/slack/setup.ts b/packages/eve/src/setup/integrations/slack/setup.ts index 1811cbdcf..9a74b723d 100644 --- a/packages/eve/src/setup/integrations/slack/setup.ts +++ b/packages/eve/src/setup/integrations/slack/setup.ts @@ -1,22 +1,103 @@ -import type { ChannelSetupIntegration } from "../types.js"; -import { runChannelSetup } from "../channel-scaffold-runner.js"; -import { WizardCancelledError } from "../../step.js"; +import { + deriveSlackConnectorSlug, + ensureChannel, + type SlackConnectorSlug, +} from "#setup/scaffold/index.js"; +import { ensureVercelProject } from "#setup/flows/ensure-vercel-project.js"; +import { + provisionSlackbot, + reconcileSlackUid, + type ProvisionSlackbotOptions, + type ProvisionSlackbotResult, +} from "#setup/slackbot.js"; +import { WizardCancelledError } from "#setup/step.js"; -async function choosePortableCredentials( - context: Parameters[0], -): Promise<"vercel-connect" | "environment" | "cancelled"> { - if (context.presetPortableCredentials !== undefined) { - return context.presetPortableCredentials ? "environment" : "vercel-connect"; +import { installScaffoldDependencies, reportOverwrittenFiles } from "../shared/scaffold.js"; +import type { + IntegrationSetupContext, + IntegrationSetupResult, + SetupIntegration, +} from "../types.js"; + +const SLACK_REQUIRES_VERCEL = "Slack setup with Vercel Connect requires a linked Vercel project."; + +type SlackbotFailure = Exclude< + ProvisionSlackbotResult, + { state: "attached" } | { state: "cancelled" } +>; + +function slackbotFailureCopy(result: SlackbotFailure): { reason: string; followUp: string } { + switch (result.state) { + case "not-installed": + return { + reason: "Slackbot is not connected to a Slack workspace. Slack channel was not added.", + followUp: "Re-run `eve add channel/slack` after the workspace install is complete.", + }; + case "cleanup-failed": + return { + reason: "The abandoned Slack connector could not be removed. Slack channel was not added.", + followUp: "Resolve the cleanup warning above before trying again.", + }; + case "connector-lookup-failed": + return { + reason: "Existing Slack connectors could not be inspected. Slack channel was not added.", + followUp: "Restore Vercel CLI access, then re-run `eve add channel/slack`.", + }; + case "installation-check-failed": + return { + reason: "Slack workspace installation could not be verified. Slack channel was not added.", + followUp: "Verify Vercel Connect is reachable, then re-run `eve add channel/slack`.", + }; + case "existing-not-installed": + return { + reason: + "The existing Slack connector is not connected to a Slack workspace. Slack channel was not added.", + followUp: "Resolve the existing connector warning above before trying again.", + }; + case "detach-failed": + return { + reason: + "Slackbot provisioning could not replace the existing trigger destination. Slack channel was not added.", + followUp: "Run the `vercel connect detach` and `vercel connect attach` commands above.", + }; + case "attach-failed": + return { + reason: "Slackbot provisioning did not attach this project. Slack channel was not added.", + followUp: "Finish event delivery with the `vercel connect attach` command above.", + }; + case "create-failed": + return { + reason: "Slackbot creation failed.", + followUp: "Add it later with `eve add channel/slack`.", + }; } +} + +/** Effects used by Slack setup. */ +export interface SlackSetupDeps { + deriveSlackConnectorSlug: typeof deriveSlackConnectorSlug; + ensureChannel: typeof ensureChannel; + ensureVercelProject: typeof ensureVercelProject; + provisionSlackbot: typeof provisionSlackbot; + reconcileSlackUid: typeof reconcileSlackUid; +} + +const defaultDeps: SlackSetupDeps = { + deriveSlackConnectorSlug, + ensureChannel, + ensureVercelProject, + provisionSlackbot, + reconcileSlackUid, +}; + +async function chooseCredentials( + context: IntegrationSetupContext, +): Promise<"vercel-connect" | "environment" | "cancelled"> { try { return (await context.ui.prompter.select<"vercel" | "portable">({ message: "How would you like to configure Slack?", options: [ - { - value: "vercel", - label: "Set up Vercel Connect", - hint: "Sign in and link this project", - }, + { value: "vercel", label: "Set up Vercel Connect", hint: "Sign in and link this project" }, { value: "portable", label: "Use portable credentials", @@ -32,39 +113,122 @@ async function choosePortableCredentials( } } -/** Slack's channel-owned credential choice, provisioning, and scaffold behavior. */ -export const SLACK_CHANNEL_SETUP: ChannelSetupIntegration = { - kind: "slack", - label: "Slack", - hint: "Slack app mentions and DMs", - async setup(context) { - const credentials = await choosePortableCredentials(context); - if (credentials === "cancelled") return { kind: "cancelled" }; - if (credentials === "vercel-connect" && context.environment.vercel.kind === "unavailable") { - throw new Error( - "Vercel Connect requires an authenticated Vercel CLI. Run `vercel login`, then retry Slack setup.", - ); - } +async function provisionSlack( + context: IntegrationSetupContext, + deps: SlackSetupDeps, + slug: SlackConnectorSlug, +): Promise> { + const provisionOptions: ProvisionSlackbotOptions = { + selectConnector: async (connectors, preferred) => { + if (context.yes) return preferred ?? connectors[0]!; + const selected = await context.ui.prompter.select({ + message: "Which Slack app would you like to use?", + options: [ + ...connectors.map((connector) => ({ + value: connector.uid, + label: `Use ${connector.uid}`, + ...(connector.uid === preferred?.uid ? { hint: "Matches this agent" } : {}), + })), + { value: "create", label: "Create a new Slack app" }, + ], + initialValue: preferred?.uid, + }); + return selected === "create" + ? "create" + : connectors.find((connector) => connector.uid === selected)!; + }, + ...(context.signal === undefined ? {} : { signal: context.signal }), + ...(context.ui.prompter.awaitChoice === undefined + ? {} + : { awaitChoice: context.ui.prompter.awaitChoice }), + }; + const result = await deps.provisionSlackbot( + context.ui.prompter.log, + context.appRoot, + slug, + undefined, + provisionOptions, + ); + context.signal?.throwIfAborted(); + if (result.state === "cancelled") throw new WizardCancelledError(); + if (result.state !== "attached") { + const copy = slackbotFailureCopy(result); + throw new Error(`${copy.reason} ${copy.followUp}`); + } + return result; +} - const result = await runChannelSetup(context, { +/** Runs the Slack setup flow. Exported for direct integration tests. */ +export async function setupSlack( + context: IntegrationSetupContext, + deps: SlackSetupDeps = defaultDeps, +): Promise { + const credentials = await chooseCredentials(context); + if (credentials === "cancelled") return { kind: "cancelled" }; + if (credentials === "vercel-connect" && context.environment.vercel.kind === "unavailable") { + throw new Error( + "Vercel Connect requires an authenticated Vercel CLI. Run `vercel login`, then retry Slack setup.", + ); + } + + const slug = await deps.deriveSlackConnectorSlug(context.appRoot); + if (credentials === "environment") { + const result = await deps.ensureChannel({ + projectRoot: context.appRoot, kind: "slack", - ...(credentials === "environment" - ? { slackCredentials: "environment" } - : { - slackCredentials: "vercel-connect", - ensureLinkedProject: "interactive-vercel-link", - }), + slackConnectorSlug: slug, + slackCredentials: "environment", + force: context.force, + skipDependencyMutation: true, }); - if ( - result.kind === "done" && - credentials === "environment" && - result.state.channels.includes("slack") - ) { - context.ui.nextSteps([ - "Set SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET in .env.local (listed in .env.example).", - "Configure your Slack app to send events to /eve/v1/slack on your public agent URL.", - ]); - } - return result; - }, + reportOverwrittenFiles(context.ui.prompter.log, result.filesOverwritten); + context.ui.prompter.log.success("Scaffolded channel: slack"); + context.ui.nextSteps([ + "Set SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET in .env.local (listed in .env.example).", + "Configure your Slack app to send events to /eve/v1/slack on your public agent URL.", + ]); + return { kind: "done" }; + } + + const project = await deps.ensureVercelProject({ + appRoot: context.appRoot, + prompter: context.ui.prompter, + signal: context.signal, + }); + if (project.projectId.length === 0) throw new Error(SLACK_REQUIRES_VERCEL); + const slackbot = await provisionSlack(context, deps, slug); + const result = await deps.ensureChannel({ + projectRoot: context.appRoot, + kind: "slack", + slackConnectorUid: slackbot.connectorUid, + slackConnectorSlug: slug, + force: context.force, + skipDependencyMutation: true, + }); + reportOverwrittenFiles(context.ui.prompter.log, result.filesOverwritten); + if (result.action === "skipped") { + const ready = await deps.reconcileSlackUid( + context.ui.prompter.log, + context.appRoot, + slackbot, + `slack/${slug}`, + ); + if (!ready) throw new Error("Slack connector UID update is required before deployment."); + } + context.ui.prompter.log.success("Scaffolded channel: slack"); + await installScaffoldDependencies({ + changed: result.packageJsonUpdated.length > 0, + log: context.ui.prompter.log, + projectPath: context.appRoot, + signal: context.signal, + }); + return { kind: "done" }; +} + +/** Slack setup registration. */ +export const SLACK_SETUP: SetupIntegration = { + kind: "slack", + label: "Slack", + hint: "Slack app mentions and DMs", + setup: setupSlack, }; diff --git a/packages/eve/src/setup/integrations/types.ts b/packages/eve/src/setup/integrations/types.ts index e0f095cc1..1a9da739b 100644 --- a/packages/eve/src/setup/integrations/types.ts +++ b/packages/eve/src/setup/integrations/types.ts @@ -1,45 +1,27 @@ -import type { AddChannelsDeps } from "./channel-scaffold.js"; -import type { ChannelSetupEnvironment } from "./shared/environment.js"; -import type { ChannelSetupUi } from "./shared/ui.js"; -import type { ChannelKind } from "../scaffold/index.js"; -import type { ProjectResolution } from "../project-resolution.js"; +import type { RegistrySetupFact } from "#setup/registry-setup-protocol.js"; -/** Narrow state owned by one channel setup invocation. */ -export interface ChannelSetupState { - readonly projectPath: - | string - | { kind: "unresolved"; inPlace: boolean } - | { kind: "resolved"; inPlace: boolean; path: string }; - readonly project: ProjectResolution; - readonly channels: ChannelKind[]; - readonly webScaffolded: boolean; - readonly slackScaffolded: boolean; -} +import type { IntegrationSetupEnvironment } from "./shared/environment.js"; +import type { IntegrationSetupUi } from "./shared/ui.js"; -/** Shared inputs available to a channel-owned setup implementation. */ -export interface ChannelSetupContext { - readonly environment: ChannelSetupEnvironment; - readonly state: Readonly; - readonly ui: ChannelSetupUi; +/** Inputs available to one registry-owned integration setup flow. */ +export interface IntegrationSetupContext { + readonly appRoot: string; + readonly environment: IntegrationSetupEnvironment; + readonly ui: IntegrationSetupUi; readonly signal?: AbortSignal; readonly force?: boolean; - readonly headless?: boolean; - readonly presetCreateSlackbot?: boolean; - readonly presetPortableCredentials?: boolean; - readonly deps?: AddChannelsDeps; - /** Registry installation already owns package dependency mutations. */ - readonly skipDependencyMutation?: boolean; + readonly yes?: boolean; } -/** Structured outcome from a channel-owned setup implementation. */ -export type ChannelSetupResult = - | { readonly kind: "done"; readonly state: ChannelSetupState } +/** Outcome from one registry-owned integration setup flow. */ +export type IntegrationSetupResult = + | { readonly kind: "done"; readonly facts?: readonly RegistrySetupFact[] } | { readonly kind: "cancelled" }; -/** Setup behavior paired with canonical channel catalog metadata. */ -export interface ChannelSetupIntegration { - readonly kind: ChannelKind; +/** One built-in registry-owned integration setup flow. */ +export interface SetupIntegration { + readonly kind: string; readonly label: string; readonly hint?: string; - setup(context: ChannelSetupContext): Promise; + setup(context: IntegrationSetupContext): Promise; } diff --git a/packages/eve/src/setup/integrations/web/setup.ts b/packages/eve/src/setup/integrations/web/setup.ts index 7eed14169..1c9715c87 100644 --- a/packages/eve/src/setup/integrations/web/setup.ts +++ b/packages/eve/src/setup/integrations/web/setup.ts @@ -1,15 +1,60 @@ -import type { ChannelSetupIntegration } from "../types.js"; -import { runChannelSetup } from "../channel-scaffold-runner.js"; +import { detectPackageManager } from "#setup/package-manager.js"; +import { formatNodeEngineOverrideWarning } from "#setup/node-engine.js"; +import { ensureChannel, type EnsureChannelOptions } from "#setup/scaffold/index.js"; -/** Web Chat's channel-owned setup behavior. */ -export const WEB_CHANNEL_SETUP: ChannelSetupIntegration = { +import { installScaffoldDependencies, reportOverwrittenFiles } from "../shared/scaffold.js"; +import type { SetupIntegration } from "../types.js"; + +function reportCompetingNextConfigFiles( + log: Parameters[0], + files: readonly string[] | undefined, +): void { + for (const filePath of files ?? []) { + log.warning( + `Found competing Next.js config at ${filePath}; merge any needed settings into next.config.ts and remove it before starting the preview, or Next.js may ignore the generated eve rewrite.`, + ); + } +} + +/** Web Chat scaffolding. */ +export const WEB_SETUP: SetupIntegration = { kind: "web", label: "Web Chat", hint: "Browser-based chat interface", - setup(context) { - return runChannelSetup(context, { + async setup(context) { + context.ui.prompter.log.message("Scaffolding Web Chat channel files..."); + const options: EnsureChannelOptions = { + projectRoot: context.appRoot, kind: "web", + packageManager: (await detectPackageManager(context.appRoot)).kind, configureVercelServices: context.environment.vercel.kind === "available", + force: context.force, + skipDependencyMutation: true, + }; + const result = await ensureChannel(options); + reportOverwrittenFiles(context.ui.prompter.log, result.filesOverwritten); + if ( + result.kind === "web" && + result.action !== "skipped" && + result.nodeEngineOverride !== undefined + ) { + context.ui.prompter.log.warning(formatNodeEngineOverrideWarning(result.nodeEngineOverride)); + } + reportCompetingNextConfigFiles( + context.ui.prompter.log, + "competingNextConfigFiles" in result ? result.competingNextConfigFiles : undefined, + ); + if (result.action === "skipped") { + context.ui.prompter.log.info("Next.js project detected. Skipping Web Chat scaffolding."); + return { kind: "done" }; + } + context.ui.prompter.log.success("Scaffolded channel: web"); + await installScaffoldDependencies({ + changed: result.packageJsonUpdated.length > 0, + log: context.ui.prompter.log, + projectPath: context.appRoot, + signal: context.signal, }); + return { kind: "done" }; }, }; diff --git a/packages/eve/src/setup/state.ts b/packages/eve/src/setup/state.ts index 7741a83f6..6502c1f7a 100644 --- a/packages/eve/src/setup/state.ts +++ b/packages/eve/src/setup/state.ts @@ -147,37 +147,17 @@ export interface SetupState { aiGatewayCredentials: ResolvedAiGatewayCredentials; chat: ChatPreference | null; - // Status retained while channel setup retries installs, deployments, or - // Connect calls. Advanced by the channel and deploy boxes. - /** Channels scaffolded so far in this run. */ - channels: ChannelKind[]; - webScaffolded: boolean; - slackScaffolded: boolean; deploymentDependenciesInstalled: boolean; /** The linked Vercel project facts, from the link box or the on-disk `.vercel` link. */ project: ProjectResolution; deploymentPending: boolean; - slackbotCreated: boolean; - slackbotAttached: boolean; - slackConnectorUid: string | undefined; - /** Deep link that opens a DM compose with the bot ("chat with your agent"). */ - slackChatUrl: string | undefined; - slackWorkspaceName: string | undefined; } export function createDefaultSetupState(): SetupState { return { - channels: [], - webScaffolded: false, - slackScaffolded: false, deploymentDependenciesInstalled: false, project: { kind: "unresolved" }, deploymentPending: false, - slackbotCreated: false, - slackbotAttached: false, - slackConnectorUid: undefined, - slackChatUrl: undefined, - slackWorkspaceName: undefined, agentName: "", setupMode: "complete", modelId: "", @@ -224,7 +204,6 @@ export function snapshotSetupState(state: SetupState): SetupState { aiGatewayCredentials: Object.freeze({ ...state.aiGatewayCredentials }), aiGateway: Object.freeze({ ...state.aiGateway }), connectionSelection: Object.freeze([...state.connectionSelection]) as ConnectionPlan[], - channels: Object.freeze([...state.channels]) as ChannelKind[], project: Object.freeze({ ...state.project }) as ProjectResolution, projectPath: Object.freeze({ ...state.projectPath }), vercelProject: Object.freeze({ ...state.vercelProject }), From e3db1e454a8ea61267d46c19278f923347997154 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:50:45 -0700 Subject: [PATCH 6/6] refactor(eve): name setup integrations generically Signed-off-by: owenkephart --- .../eve/src/setup/integrations/registry.ts | 3 +- .../setup/integrations/slack/setup.test.ts | 54 +++++++++++++++++++ .../eve/src/setup/integrations/slack/setup.ts | 22 ++++---- 3 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 packages/eve/src/setup/integrations/slack/setup.test.ts diff --git a/packages/eve/src/setup/integrations/registry.ts b/packages/eve/src/setup/integrations/registry.ts index d250ae1cc..bc654ef29 100644 --- a/packages/eve/src/setup/integrations/registry.ts +++ b/packages/eve/src/setup/integrations/registry.ts @@ -2,10 +2,9 @@ import type { SetupIntegration } from "./types.js"; import { SLACK_SETUP } from "./slack/setup.js"; import { WEB_SETUP } from "./web/setup.js"; -/** Built-in channel integrations in canonical picker order. */ +/** Built-in registry setup integrations in canonical picker order. */ export const SETUP_INTEGRATIONS: readonly SetupIntegration[] = [WEB_SETUP, SLACK_SETUP]; -/** Resolves a channel setup integration by its filesystem-facing kind. */ /** Resolves one built-in setup integration by its registry setup name. */ export function setupIntegration(kind: string): SetupIntegration { const integration = SETUP_INTEGRATIONS.find((candidate) => candidate.kind === kind); diff --git a/packages/eve/src/setup/integrations/slack/setup.test.ts b/packages/eve/src/setup/integrations/slack/setup.test.ts new file mode 100644 index 000000000..7c1a3b9fd --- /dev/null +++ b/packages/eve/src/setup/integrations/slack/setup.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; +import type { SlackConnectorSlug } from "#setup/scaffold/index.js"; + +import { integrationSetupEnvironment } from "../shared/environment.js"; +import { createIntegrationSetupUi } from "../shared/ui.js"; +import { setupSlack, type SlackSetupDeps } from "./setup.js"; + +describe("setupSlack", () => { + it("uses Vercel Connect without prompting for credentials in non-interactive mode", async () => { + const fake = createFakePrompter({ + single: () => { + throw new Error("credential selection must not be prompted"); + }, + }); + const provisionSlackbot = vi.fn(async () => ({ + state: "attached" as const, + connectorUid: "uid", + })); + const effects = { + deriveSlackConnectorSlug: vi.fn(async () => "agent" as SlackConnectorSlug), + ensureChannel: vi.fn(async () => ({ + kind: "slack" as const, + action: "created" as const, + filesWritten: [], + filesOverwritten: [], + filesSkipped: [], + packageJsonUpdated: [], + slackConnectorSlug: "agent" as SlackConnectorSlug, + })), + ensureVercelProject: vi.fn(async () => ({ orgId: "org-id", projectId: "project-id" })), + provisionSlackbot, + reconcileSlackUid: vi.fn(), + } as SlackSetupDeps; + + await expect( + setupSlack( + { + appRoot: "/project", + environment: integrationSetupEnvironment("authenticated", { kind: "unresolved" }), + ui: createIntegrationSetupUi({ + asker: { ask: vi.fn(), askMany: vi.fn() }, + prompter: fake.prompter, + }), + yes: true, + }, + effects, + ), + ).resolves.toEqual({ kind: "done" }); + + expect(provisionSlackbot).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/eve/src/setup/integrations/slack/setup.ts b/packages/eve/src/setup/integrations/slack/setup.ts index 9a74b723d..2900e1590 100644 --- a/packages/eve/src/setup/integrations/slack/setup.ts +++ b/packages/eve/src/setup/integrations/slack/setup.ts @@ -93,6 +93,7 @@ const defaultDeps: SlackSetupDeps = { async function chooseCredentials( context: IntegrationSetupContext, ): Promise<"vercel-connect" | "environment" | "cancelled"> { + if (context.yes) return "vercel-connect"; try { return (await context.ui.prompter.select<"vercel" | "portable">({ message: "How would you like to configure Slack?", @@ -124,11 +125,14 @@ async function provisionSlack( const selected = await context.ui.prompter.select({ message: "Which Slack app would you like to use?", options: [ - ...connectors.map((connector) => ({ - value: connector.uid, - label: `Use ${connector.uid}`, - ...(connector.uid === preferred?.uid ? { hint: "Matches this agent" } : {}), - })), + ...connectors.map((connector) => { + const option: { value: string; label: string; hint?: string } = { + value: connector.uid, + label: `Use ${connector.uid}`, + }; + if (connector.uid === preferred?.uid) option.hint = "Matches this agent"; + return option; + }), { value: "create", label: "Create a new Slack app" }, ], initialValue: preferred?.uid, @@ -137,11 +141,11 @@ async function provisionSlack( ? "create" : connectors.find((connector) => connector.uid === selected)!; }, - ...(context.signal === undefined ? {} : { signal: context.signal }), - ...(context.ui.prompter.awaitChoice === undefined - ? {} - : { awaitChoice: context.ui.prompter.awaitChoice }), }; + if (context.signal !== undefined) provisionOptions.signal = context.signal; + if (context.ui.prompter.awaitChoice !== undefined) { + provisionOptions.awaitChoice = context.ui.prompter.awaitChoice; + } const result = await deps.provisionSlackbot( context.ui.prompter.log, context.appRoot,