diff --git a/packages/eve/src/cli/commands/integration-setup.test.ts b/packages/eve/src/cli/commands/integration-setup.test.ts index 9ba0c9684..925631d59 100644 --- a/packages/eve/src/cli/commands/integration-setup.test.ts +++ b/packages/eve/src/cli/commands/integration-setup.test.ts @@ -1,8 +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 { 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"; @@ -13,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( @@ -57,16 +37,14 @@ describe("runIntegrationSetupCommand", () => { {}, { createPrompter: () => fake.prompter, - detectDeployment: vi.fn(async () => ({ state: "unlinked" as const })), - getVercelAuthStatus: vi.fn(async () => "cli-missing" as const), - addChannelsDeps: deps, }, ); - 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/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index e5626ca88..d775dbb31 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -1,19 +1,10 @@ -import { interactiveAsker } from "#setup/ask.js"; -import type { AddChannelsDeps } from "#setup/integrations/channels/setup.js"; -import { - channelSetupEnvironment, - describeChannelSetupEnvironment, -} from "#setup/integrations/channels/environment.js"; -import { - channelSetupIntegration, - createChannelSetupUi, -} from "#setup/integrations/channels/index.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"; @@ -25,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, @@ -51,46 +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 }, - channelSelection: [channelKind], + 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/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/index.test.ts b/packages/eve/src/setup/integrations/channels/index.test.ts deleted file mode 100644 index aea89318e..000000000 --- a/packages/eve/src/setup/integrations/channels/index.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -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"; - -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, - channelSelection: ["slack" as const], - }, - ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), - }; -} - -describe("channel setup integrations", () => { - it("keeps picker cancellation as a structured result", async () => { - const fake = createFakePrompter({ - single: () => { - throw new WizardCancelledError(); - }, - }); - - const result = await channelSetupIntegration("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 channelSetupIntegration("web").setup({ - ...context(fake.prompter), - state: { - ...context(fake.prompter).state, - channelSelection: ["web"], - }, - 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(), - }, - }); - - expect(result.kind).toBe("done"); - expect(ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "web", configureVercelServices: false }), - ); - expect(fake.selectMessages).toEqual([]); - }); -}); diff --git a/packages/eve/src/setup/integrations/channels/index.ts b/packages/eve/src/setup/integrations/channels/index.ts deleted file mode 100644 index ac6573a91..000000000 --- a/packages/eve/src/setup/integrations/channels/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -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"; - -/** Built-in channel integrations in canonical picker order. */ -export const CHANNEL_SETUP_INTEGRATIONS: readonly ChannelSetupIntegration[] = [ - WEB_CHANNEL_SETUP, - SLACK_CHANNEL_SETUP, -]; - -/** Resolves a channel setup integration by its filesystem-facing kind. */ -export function channelSetupIntegration(kind: ChannelKind): ChannelSetupIntegration { - const integration = CHANNEL_SETUP_INTEGRATIONS.find((candidate) => candidate.kind === kind); - if (integration === undefined) throw new Error(`No channel setup integration for "${kind}".`); - return integration; -} - -export { createChannelSetupUi } from "./ui.js"; diff --git a/packages/eve/src/setup/integrations/channels/runner.ts b/packages/eve/src/setup/integrations/channels/runner.ts deleted file mode 100644 index ffeccea90..000000000 --- a/packages/eve/src/setup/integrations/channels/runner.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { addChannels } from "./setup.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"; - -/** Runs the shared scaffold box with decisions supplied by a channel integration. */ -export async function runChannelSetup( - context: ChannelSetupContext, - options: { - configureVercelServices?: boolean; - slackCredentials?: "vercel-connect" | "environment"; - ensureLinkedProject?: "interactive-vercel-link"; - }, -): Promise { - const box = addChannels({ - asker: context.ui.asker, - prompter: context.ui.prompter, - 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/channels/setup.test.ts b/packages/eve/src/setup/integrations/channels/setup.test.ts deleted file mode 100644 index 99a3acce5..000000000 --- a/packages/eve/src/setup/integrations/channels/setup.test.ts +++ /dev/null @@ -1,886 +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 "./setup.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 & { headless?: boolean }, -): ReturnType> { - const headless = options.headless ?? false; - return addChannels({ - ...options, - 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(channelSelection: SetupState["channelSelection"] = ["web"]): 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" }, - }; -} - -/** A flow that chose not to deploy to Vercel: no project planned or linked. */ -function noVercelState(): SetupState { - return { - ...createDefaultSetupState(), - agentName: "my-agent", - channelSelection: ["web"], - 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({ - 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(["web", "slack"]), 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({ - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - slackCredentials: "environment", - deps, - }); - - const next = await runHeadless( - [box], - { ...noVercelState(), channelSelection: ["slack"] }, - 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 both channel scaffolds", async () => { - const deps = createDeps(); - const box = makeBox({ - prompter: createPrompter(), - presetCreateSlackbot: true, - force: true, - deps, - }); - - const result = await runInteractive( - [box], - resolvedState(["web", "slack"]), - silentSink, - snapshot, - ); - - expect(deps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "web", force: true }), - ); - 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("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"]); - state.project = { kind: "unresolved" }; - state.vercelProject = { kind: "none" }; - const prompter = createPrompter(); - const box = makeBox({ - 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(["slack"]); - state.project = { kind: "unresolved" }; - state.vercelProject = { kind: "none" }; - const box = makeBox({ - 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({ prompter, evePackage: TEST_EVE_PACKAGE, deps }); - - const result = await runInteractive([box], resolvedState(["slack"]), 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({ prompter: fake.prompter, evePackage: TEST_EVE_PACKAGE, deps }); - - await runInteractive([box], resolvedState(["slack"]), 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({ - prompter: fake.prompter, - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - const result = await runInteractive([box], resolvedState(["slack"]), 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({ - prompter, - presetCreateSlackbot: true, - deps, - }); - - await runInteractive([box], resolvedState(["slack"]), 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({ - prompter, - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - slackbotFailure: "warn-and-continue", - deps, - }); - - const result = await runInteractive( - [box], - resolvedState(["web", "slack"]), - 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"]); - 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.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({ - prompter, - presetCreateSlackbot: true, - deps, - }); - - const result = await runInteractive([box], resolvedState(["slack"]), 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({ - prompter, - presetCreateSlackbot: true, - deps, - }); - - await expect( - runInteractive([box], resolvedState(["slack"]), 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({ - prompter, - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - slackbotFailure: "warn-and-continue", - deps, - }); - - const result = await runInteractive([box], resolvedState(["slack"]), 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({ - prompter, - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - slackbotFailure: "warn-and-continue", - deps, - }); - - const result = await runInteractive([box], resolvedState(["slack"]), 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(["slack"]); - const box = makeBox({ - 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({ - prompter, - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - slackbotFailure: "warn-and-continue", - deps, - }); - - const result = await runInteractive([box], resolvedState(["slack"]), 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({ - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - await expect( - runInteractive([box], resolvedState(["slack"]), 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({ - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - await expect( - runInteractive([box], resolvedState(["slack"]), 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(["slack"]); - const box = makeBox({ - 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(["slack"]); - const box = makeBox({ - 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(["slack"]); - const box = makeBox({ - 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(), channelSelection: ["slack"] }; - const box = makeBox({ - 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(["slack"]); - // project stays unresolved: the link box did not record a resolution. - state.project = { kind: "unresolved" }; - const box = makeBox({ - 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/channels/setup.ts b/packages/eve/src/setup/integrations/channels/setup.ts deleted file mode 100644 index ea7c516ee..000000000 --- a/packages/eve/src/setup/integrations/channels/setup.ts +++ /dev/null @@ -1,615 +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; - channelSelection: ChannelKind[]; - 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 { - /** 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 (!state.channelSelection.includes("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 (!state.channelSelection.includes("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({ state }): 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" - ) { - 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/channels/slack.ts b/packages/eve/src/setup/integrations/channels/slack.ts deleted file mode 100644 index 564d73036..000000000 --- a/packages/eve/src/setup/integrations/channels/slack.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { ChannelSetupIntegration } from "./types.js"; -import { runChannelSetup } from "./runner.js"; -import { WizardCancelledError } from "../../step.js"; - -async function choosePortableCredentials( - context: Parameters[0], -): Promise<"vercel-connect" | "environment" | "cancelled"> { - if (context.presetPortableCredentials !== undefined) { - return context.presetPortableCredentials ? "environment" : "vercel-connect"; - } - 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: "portable", - label: "Use portable credentials", - hint: "Read Slack tokens from environment variables", - }, - ], - })) === "portable" - ? "environment" - : "vercel-connect"; - } catch (error) { - if (error instanceof WizardCancelledError) return "cancelled"; - throw error; - } -} - -/** 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.", - ); - } - - const result = await runChannelSetup( - context, - credentials === "environment" - ? { slackCredentials: "environment" } - : { - slackCredentials: "vercel-connect", - ensureLinkedProject: "interactive-vercel-link", - }, - ); - 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; - }, -}; diff --git a/packages/eve/src/setup/integrations/channels/types.ts b/packages/eve/src/setup/integrations/channels/types.ts deleted file mode 100644 index 414004f56..000000000 --- a/packages/eve/src/setup/integrations/channels/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -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"; - -/** 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 channelSelection: ChannelKind[]; - readonly channels: ChannelKind[]; - readonly webScaffolded: boolean; - readonly slackScaffolded: boolean; -} - -/** Shared inputs available to a channel-owned setup implementation. */ -export interface ChannelSetupContext { - readonly environment: ChannelSetupEnvironment; - readonly state: Readonly; - readonly ui: ChannelSetupUi; - 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; -} - -/** Structured outcome from a channel-owned setup implementation. */ -export type ChannelSetupResult = - | { readonly kind: "done"; readonly state: ChannelSetupState } - | { readonly kind: "cancelled" }; - -/** Setup behavior paired with canonical channel catalog metadata. */ -export interface ChannelSetupIntegration { - readonly kind: ChannelKind; - readonly label: string; - readonly hint?: string; - setup(context: ChannelSetupContext): Promise; -} diff --git a/packages/eve/src/setup/integrations/channels/web.ts b/packages/eve/src/setup/integrations/channels/web.ts deleted file mode 100644 index 5f71a6df6..000000000 --- a/packages/eve/src/setup/integrations/channels/web.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ChannelSetupIntegration } from "./types.js"; -import { runChannelSetup } from "./runner.js"; - -/** Web Chat's channel-owned setup behavior. */ -export const WEB_CHANNEL_SETUP: ChannelSetupIntegration = { - kind: "web", - label: "Web Chat", - hint: "Browser-based chat interface", - setup(context) { - return runChannelSetup(context, { - configureVercelServices: context.environment.vercel.kind === "available", - }); - }, -}; diff --git a/packages/eve/src/setup/integrations/registry.test.ts b/packages/eve/src/setup/integrations/registry.test.ts new file mode 100644 index 000000000..f2a42ffa1 --- /dev/null +++ b/packages/eve/src/setup/integrations/registry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; +import type { Asker } from "#setup/ask.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 { + appRoot: "/tmp/project", + environment: integrationSetupEnvironment("logged-out", { kind: "unresolved" } as const), + ui: { + asker: unusedAsker, + prompter, + confirm: async () => false, + nextSteps: () => {}, + }, + }; +} + +describe("setup integrations", () => { + it("keeps Slack credential-picker cancellation as a structured result", async () => { + const fake = createFakePrompter({ + single: () => { + throw new WizardCancelledError(); + }, + }); + + await expect(setupIntegration("slack").setup(context(fake.prompter))).resolves.toEqual({ + kind: "cancelled", + }); + }); + + it("rejects an unknown integration", () => { + expect(() => setupIntegration("unknown")).toThrow( + 'Integration setup "unknown" is not available', + ); + }); +}); diff --git a/packages/eve/src/setup/integrations/registry.ts b/packages/eve/src/setup/integrations/registry.ts new file mode 100644 index 000000000..bc654ef29 --- /dev/null +++ b/packages/eve/src/setup/integrations/registry.ts @@ -0,0 +1,19 @@ +import type { SetupIntegration } from "./types.js"; +import { SLACK_SETUP } from "./slack/setup.js"; +import { WEB_SETUP } from "./web/setup.js"; + +/** Built-in registry setup integrations in canonical picker order. */ +export const SETUP_INTEGRATIONS: readonly SetupIntegration[] = [WEB_SETUP, SLACK_SETUP]; + +/** 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); + if (integration === undefined) { + throw new Error( + `Integration setup "${kind}" is not available in this version of eve. Upgrade eve and try again.`, + ); + } + return integration; +} + +export { createIntegrationSetupUi } from "./shared/ui.js"; diff --git a/packages/eve/src/setup/integrations/runner.ts b/packages/eve/src/setup/integrations/runner.ts new file mode 100644 index 000000000..46673758c --- /dev/null +++ b/packages/eve/src/setup/integrations/runner.ts @@ -0,0 +1,65 @@ +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 { getVercelAuthStatus } from "#setup/vercel-project.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. */ +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; +} + +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 = integrationSetupEnvironment(authStatus, project); + options.prompter.log.info(describeIntegrationSetupEnvironment(environment)); + const result = await integration.setup({ + environment, + appRoot: options.appRoot, + ui: createIntegrationSetupUi({ + asker: interactiveAsker(options.prompter), + prompter: options.prompter, + }), + yes: options.yes, + signal: options.signal, + }); + return result; +} diff --git a/packages/eve/src/setup/integrations/channels/environment.test.ts b/packages/eve/src/setup/integrations/shared/environment.test.ts similarity index 60% rename from packages/eve/src/setup/integrations/channels/environment.test.ts rename to packages/eve/src/setup/integrations/shared/environment.test.ts index 77ea05bbd..38ee3a37d 100644 --- a/packages/eve/src/setup/integrations/channels/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/channels/environment.ts b/packages/eve/src/setup/integrations/shared/environment.ts similarity index 87% rename from packages/eve/src/setup/integrations/channels/environment.ts rename to packages/eve/src/setup/integrations/shared/environment.ts index 3f887302c..6927c89e5 100644 --- a/packages/eve/src/setup/integrations/channels/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/channels/ui.test.ts b/packages/eve/src/setup/integrations/shared/ui.test.ts similarity index 81% rename from packages/eve/src/setup/integrations/channels/ui.test.ts rename to packages/eve/src/setup/integrations/shared/ui.test.ts index e6db1e316..3ccab13b8 100644 --- a/packages/eve/src/setup/integrations/channels/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/channels/ui.ts b/packages/eve/src/setup/integrations/shared/ui.ts similarity index 84% rename from packages/eve/src/setup/integrations/channels/ui.ts rename to packages/eve/src/setup/integrations/shared/ui.ts index e44b9101e..d2fe9bc4a 100644 --- a/packages/eve/src/setup/integrations/channels/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.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 new file mode 100644 index 000000000..2900e1590 --- /dev/null +++ b/packages/eve/src/setup/integrations/slack/setup.ts @@ -0,0 +1,238 @@ +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"; + +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"> { + if (context.yes) return "vercel-connect"; + 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: "portable", + label: "Use portable credentials", + hint: "Read Slack tokens from environment variables", + }, + ], + })) === "portable" + ? "environment" + : "vercel-connect"; + } catch (error) { + if (error instanceof WizardCancelledError) return "cancelled"; + throw error; + } +} + +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) => { + 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, + }); + return selected === "create" + ? "create" + : connectors.find((connector) => connector.uid === selected)!; + }, + }; + 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, + 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; +} + +/** 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", + slackConnectorSlug: slug, + slackCredentials: "environment", + force: context.force, + skipDependencyMutation: true, + }); + 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 new file mode 100644 index 000000000..1a9da739b --- /dev/null +++ b/packages/eve/src/setup/integrations/types.ts @@ -0,0 +1,27 @@ +import type { RegistrySetupFact } from "#setup/registry-setup-protocol.js"; + +import type { IntegrationSetupEnvironment } from "./shared/environment.js"; +import type { IntegrationSetupUi } from "./shared/ui.js"; + +/** 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 yes?: boolean; +} + +/** Outcome from one registry-owned integration setup flow. */ +export type IntegrationSetupResult = + | { readonly kind: "done"; readonly facts?: readonly RegistrySetupFact[] } + | { readonly kind: "cancelled" }; + +/** One built-in registry-owned integration setup flow. */ +export interface SetupIntegration { + readonly kind: string; + readonly label: string; + readonly hint?: string; + setup(context: IntegrationSetupContext): Promise; +} diff --git a/packages/eve/src/setup/integrations/web/setup.ts b/packages/eve/src/setup/integrations/web/setup.ts new file mode 100644 index 000000000..1c9715c87 --- /dev/null +++ b/packages/eve/src/setup/integrations/web/setup.ts @@ -0,0 +1,60 @@ +import { detectPackageManager } from "#setup/package-manager.js"; +import { formatNodeEngineOverrideWarning } from "#setup/node-engine.js"; +import { ensureChannel, type EnsureChannelOptions } from "#setup/scaffold/index.js"; + +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", + 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 2cb30d3de..6502c1f7a 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. */ @@ -149,42 +147,21 @@ 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: "", modelWiring: "gateway", - channelSelection: [], connectionSelection: [], vercelProject: { kind: "none" }, aiGateway: { kind: "inherit" }, @@ -226,9 +203,7 @@ 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, projectPath: Object.freeze({ ...state.projectPath }), vercelProject: Object.freeze({ ...state.vercelProject }),