From 50df58fde922ee90b9ee1f7e6060165b5bdf9132 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:26:15 +0000 Subject: [PATCH] refactor(eve): move connection setup behind eve add Signed-off-by: owenkephart Co-Authored-By: owenkephart --- .changeset/remove-connect-command.md | 5 + docs/guides/dev-tui.md | 14 +- .../eve/src/cli/dev/tui/agent-header.test.ts | 1 - .../src/cli/dev/tui/prompt-command-handler.ts | 1 - .../src/cli/dev/tui/prompt-commands.test.ts | 2 - .../eve/src/cli/dev/tui/prompt-commands.ts | 16 +- packages/eve/src/cli/dev/tui/runner.test.ts | 92 ---- packages/eve/src/cli/dev/tui/runner.ts | 18 +- .../src/cli/dev/tui/setup-commands.test.ts | 45 +- .../eve/src/cli/dev/tui/setup-commands.ts | 49 +-- .../src/setup/boxes/add-connections.test.ts | 399 ------------------ .../eve/src/setup/boxes/add-connections.ts | 216 ---------- .../eve/src/setup/boxes/select-connections.ts | 309 -------------- .../eve/src/setup/flows/connections.test.ts | 219 ---------- packages/eve/src/setup/flows/connections.ts | 221 ---------- 15 files changed, 14 insertions(+), 1593 deletions(-) create mode 100644 .changeset/remove-connect-command.md delete mode 100644 packages/eve/src/setup/boxes/add-connections.test.ts delete mode 100644 packages/eve/src/setup/boxes/add-connections.ts delete mode 100644 packages/eve/src/setup/boxes/select-connections.ts delete mode 100644 packages/eve/src/setup/flows/connections.test.ts delete mode 100644 packages/eve/src/setup/flows/connections.ts diff --git a/.changeset/remove-connect-command.md b/.changeset/remove-connect-command.md new file mode 100644 index 000000000..a28eadcc0 --- /dev/null +++ b/.changeset/remove-connect-command.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Remove the `/connect` dev TUI command. Add connections through `eve add` or `/add`; official Connect-backed connection items now configure and patch their Vercel connector during registry setup. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index 491cdd7c9..fe170df29 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -24,19 +24,17 @@ The conversation streams straight into your terminal's normal scrollback, so you Each turn renders without boxes. A colored gutter glyph marks who is speaking, tool calls collapse to a one-line summary (`✓ get_weather city="SF" → 73°F`), and a subagent's work is indented beneath its `◆` header. When input is ready, the prompt stays bare until you type. A green circle-dot pulses while the agent is waiting to answer and disappears when reasoning or answer content begins. -A persistent line beneath the prompt or status shows the model — with its authored reasoning level and a `↯` marker when the Gateway priority tier is on (`xai/grok-4.5@xhigh ↯`) — the linked Vercel project, and a yellow `/deploy pending` marker once a channel added this session still needs `/deploy`. The Vercel segment stays hidden until the directory is linked. Local sessions lead with a gray `:port` badge. Remote sessions lead with a padded `↗ project (environment)` badge, or the host when Vercel cannot resolve the deployment. The badge is gray while checking or unavailable, yellow while authentication is required or failed, and blue when connected. Status segments separate with plain whitespace. Remote status lines omit AI Gateway endpoint state. +A persistent line beneath the prompt or status shows the model — with its authored reasoning level and a `↯` marker when the Gateway priority tier is on (`xai/grok-4.5@xhigh ↯`) — and the linked Vercel project. The Vercel segment stays hidden until the directory is linked. Local sessions lead with a gray `:port` badge. Remote sessions lead with a padded `↗ project (environment)` badge, or the host when Vercel cannot resolve the deployment. The badge is gray while checking or unavailable, yellow while authentication is required or failed, and blue when connected. Status segments separate with plain whitespace. Remote status lines omit AI Gateway endpoint state. Errors render compactly with docs links highlighted. A code bug escaping your agent's own code shows its stack trace dim beneath the error headline. Dev-server rebuilds condense into one status row that updates in place (`tui/setup-panel.ts changed · rebuilding…`, then `· rebuilt`); only the latest rebuild shows, and paths shrink to their last two components. ## Slash commands -Each command echoes as an invocation line, asks through a bordered panel that takes the input area's place (one question at a time, separate from the chat transcript), and finishes with a one-line `⎿` result. Loading states stay on the ephemeral status line instead of piling into the transcript; model, channel, connection, and the Vercel CLI commands (`/vc:install`, `/vc:login`) use the same green square pulse as the build phase, while `/deploy` keeps a spinner. A connection setup waiting for browser action changes that pulse and the word "browser" to yellow. Setup menus render the selected option with a filled arrow and an inverse label padded by one space on each side. Text prompts use a blinking block cursor over the character at the caret. The selected label is blue normally and yellow for warning rows. +Each command echoes as an invocation line, asks through a bordered panel that takes the input area's place (one question at a time, separate from the chat transcript), and finishes with a one-line `⎿` result. Loading states stay on the ephemeral status line instead of piling into the transcript; model, registry, and the Vercel CLI commands (`/vc:install`, `/vc:login`) use the same green square pulse as the build phase, while `/deploy` keeps a spinner. A setup waiting for browser action changes that pulse and the word "browser" to yellow. Setup menus render the selected option with a filled arrow and an inverse label padded by one space on each side. Text prompts use a blinking block cursor over the character at the caret. The selected label is blue normally and yellow for warning rows. | Command | Does | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/model` | Opens a configure menu that loops until Done (or Esc). See [Configure the model and provider](#configure-the-model-and-provider). | -| `/add` | Browses and installs integrations from the registry. | -| `/connect` | Shows the Vercel Connect MCP catalog and configures the server you pick. See [Add a connection](#add-a-connection). | | `/add` | Searches registry integrations or adds an item address entered directly. | | `/deploy` | Ships the agent to Vercel production, linking the directory first when it is unlinked. | | `/vc:install` | Installs the Vercel CLI. Available locally and on a remote session. | @@ -46,7 +44,7 @@ Each command echoes as an invocation line, asks through a bordered panel that ta | `/exit` | Quits the TUI. | | `/help` | Lists the commands available for the current local or remote session. | -`/model`, `/connect`, `/add`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. +`/model`, `/add`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. ### Configure the model and provider @@ -60,11 +58,7 @@ The provider row demands attention (a bold yellow "Configure model access" with `/add` first organizes integrations by what they add: a way to chat, tools and data, capabilities, or observability. Pick a category to open its searchable catalog from the official eve registry and the registry namespaces configured in `package.json`, or browse everything. Each integration shows its source beside its name, such as `Vercel` or a configured registry namespace. Select a result to review its source, packages, environment variables, and target files before adding it, or type an official item path, configured namespace address, or HTTP(S) URL directly into the search bar. After an add attempt finishes, the panel closes instead of returning to the catalog. -### Add a connection - -`/connect` shows a searchable list of MCP servers available through Vercel Connect. Already-authored connections remain checked. Logged-out users are directed to `/vc:login`. When the directory is not linked, selecting a server opens the same team and project flow used by `/model`, including creating a project or linking an existing one. - -For a selected server, eve first tries to attach the provider's canonical connector. If that fails, choose an existing connector from a searchable list or create one with a specific name. The fallback stays scoped to the team selected by the linked project. A connector created by the current attempt is removed if attachment or connection-file patching fails. Successful setup writes `agent/connections/.ts`, records the attached connector UID, installs the new dependency so the dev server can load it, then returns to the main prompt. +Connection registry items install their definition and then configure Vercel Connect through the same `/add` flow. If the directory is unlinked, setup first offers to create or link a project. Run `eve add connection/` for the equivalent CLI flow. ## Keyboard shortcuts diff --git a/packages/eve/src/cli/dev/tui/agent-header.test.ts b/packages/eve/src/cli/dev/tui/agent-header.test.ts index 3fb97f6cc..e41d55653 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.test.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import type { AgentInfoResult, AgentInfoToolEntry } from "#client/index.js"; import { AGENT_HEADER_TIPS, buildAgentHeader, pickAgentHeaderTip } from "./agent-header.js"; -import { stripAnsi } from "#cli/ui/terminal-text.js"; import { createTheme } from "./theme.js"; const FRAMEWORK_TOOL: AgentInfoToolEntry = { diff --git a/packages/eve/src/cli/dev/tui/prompt-command-handler.ts b/packages/eve/src/cli/dev/tui/prompt-command-handler.ts index f47e1dd75..d196ccbd8 100644 --- a/packages/eve/src/cli/dev/tui/prompt-command-handler.ts +++ b/packages/eve/src/cli/dev/tui/prompt-command-handler.ts @@ -116,7 +116,6 @@ export function createPromptCommandHandler( command: command.name, appRoot: target.workspaceRoot, renderer: flow, - disabledConnectionReasons: context.disabledConnectionReasons, withExclusiveTerminal: context.withExclusiveTerminal, }; if (context.initialModelStep !== undefined) { diff --git a/packages/eve/src/cli/dev/tui/prompt-commands.test.ts b/packages/eve/src/cli/dev/tui/prompt-commands.test.ts index 2edc789e6..a551d0c1c 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.test.ts @@ -93,7 +93,6 @@ describe("promptCommandsFor", () => { it("exposes project commands only for local sessions", () => { const names = promptCommandsFor("local").map((command) => command.name); expect(names).toContain("model"); - expect(names).toContain("connect"); expect(names).toContain("add"); expect(names).toContain("deploy"); expect(names).toContain("vc:install"); @@ -107,7 +106,6 @@ describe("promptCommandsFor", () => { expect(names).toContain("vc:login"); expect(names).not.toContain("vc:auth"); expect(names).not.toContain("model"); - expect(names).not.toContain("connect"); expect(names).not.toContain("add"); expect(names).not.toContain("deploy"); }); diff --git a/packages/eve/src/cli/dev/tui/prompt-commands.ts b/packages/eve/src/cli/dev/tui/prompt-commands.ts index 2e8de3e50..53b778128 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.ts @@ -1,10 +1,4 @@ -export type PromptCommandExtensionName = - | "model" - | "connect" - | "add" - | "deploy" - | "vc:install" - | "vc:login"; +export type PromptCommandExtensionName = "model" | "add" | "deploy" | "vc:install" | "vc:login"; type PromptCommandTarget = "local" | "remote"; @@ -97,14 +91,6 @@ const PROMPT_COMMAND_DEFINITIONS = [ build: (argument) => ({ type: "loglevel", argument }), targets: ["local", "remote"], }, - { - name: "connect", - aliases: [], - description: "Add an MCP server through Vercel Connect", - takesArgument: false, - build: () => ({ type: "extension", name: "connect", argument: "" }), - targets: ["local"], - }, { name: "add", aliases: [], diff --git a/packages/eve/src/cli/dev/tui/runner.test.ts b/packages/eve/src/cli/dev/tui/runner.test.ts index a20195bb9..a9e18debf 100644 --- a/packages/eve/src/cli/dev/tui/runner.test.ts +++ b/packages/eve/src/cli/dev/tui/runner.test.ts @@ -2293,98 +2293,6 @@ describe("EveTUIRunner Vercel status line", () => { expect(info).toHaveBeenCalledTimes(2); }); - it("forces a runtime rebuild after /connect adds a connection", async () => { - const client = stubClient(); - vi.spyOn(client, "info").mockResolvedValue(AGENT_INFO); - const requests: URL[] = []; - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]) => { - const url = new URL( - typeof input === "string" ? input : input instanceof URL ? input : input.url, - ); - requests.push(url); - return Response.json({ revision: url.searchParams.get("force") === "1" ? "next" : "base" }); - }), - ); - const prompts: Array = ["/connect", undefined]; - const renderer = fakeRenderer({ readPrompt: vi.fn(async () => prompts.shift()) }); - const connectOutcome = { - message: "Connections added: linear.", - effect: { kind: "connection-added" }, - } satisfies PromptCommandOutcome; - - const runner = new EveTUIRunner({ - session: stubSession(), - client, - renderer, - serverUrl: "http://localhost:3000", - name: "Weather Agent", - promptCommandHandler: { handle: async () => connectOutcome }, - }); - await runner.run(); - - expect( - requests.some( - (url) => - url.pathname === "/eve/v1/dev/runtime-artifacts/rebuild" && - url.searchParams.get("force") === "1", - ), - ).toBe(true); - }); - - it("forces a runtime rebuild through the /connect command path", async () => { - const client = stubClient(); - vi.spyOn(client, "info").mockResolvedValue(AGENT_INFO); - const requests: URL[] = []; - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]) => { - const url = new URL( - typeof input === "string" ? input : input instanceof URL ? input : input.url, - ); - requests.push(url); - return Response.json({ revision: url.searchParams.get("force") === "1" ? "next" : "base" }); - }), - ); - const runConnectionsFlow = vi.fn(async () => ({ - kind: "done" as const, - addedConnections: ["linear"], - })); - const prompts: Array = ["/connect", undefined]; - const renderer = fakeRenderer({ - readPrompt: vi.fn(async () => prompts.shift()), - setupFlow: idleSetupFlow(), - }); - - const runner = new EveTUIRunner({ - session: stubSession(), - client, - renderer, - serverUrl: "http://localhost:3000", - name: "Weather Agent", - appRoot: "/tmp/weather-agent", - promptCommandHandler: createPromptCommandHandler({ - target: { - kind: "local", - serverUrl: "http://localhost:3000", - workspaceRoot: "/tmp/weather-agent", - }, - flows: { runConnectionsFlow }, - }), - }); - await runner.run(); - - expect(runConnectionsFlow).toHaveBeenCalledTimes(1); - expect( - requests.some( - (url) => - url.pathname === "/eve/v1/dev/runtime-artifacts/rebuild" && - url.searchParams.get("force") === "1", - ), - ).toBe(true); - }); - it("never pushes Vercel status for a remote --url session", async () => { const setVercelStatus = vi.fn(); const renderer = fakeRenderer({ setVercelStatus }); diff --git a/packages/eve/src/cli/dev/tui/runner.ts b/packages/eve/src/cli/dev/tui/runner.ts index 4622d72ae..70281a0d1 100644 --- a/packages/eve/src/cli/dev/tui/runner.ts +++ b/packages/eve/src/cli/dev/tui/runner.ts @@ -373,7 +373,7 @@ export interface PromptCommandOutcome { /** Promotes an outcome to a top-level status. */ tone?: "success" | "error"; /** Post-command work after setup settles. */ - effect?: VercelStatusEffect | { kind: "connection-added" } | { kind: "model-access-changed" }; + effect?: VercelStatusEffect | { kind: "model-access-changed" }; } export interface PromptCommandHandler { @@ -1351,13 +1351,6 @@ export class EveTUIRunner { } async #applyCommandEffect(effect: PromptCommandOutcome["effect"]): Promise { - if (effect?.kind === "connection-added") { - this.#vercelStatus?.applyEffect({ kind: "refresh-identity" }); - this.#authHintStale = true; - await this.#refreshConnectionRuntime(); - await this.#refreshModelAccess(); - return; - } if (effect?.kind === "model-access-changed") { this.#vercelStatus?.applyEffect({ kind: "refresh-identity" }); this.#authHintStale = true; @@ -1371,15 +1364,6 @@ export class EveTUIRunner { void this.#refreshSetupAttention(this.#agentInfo); } - async #refreshConnectionRuntime(): Promise { - const runtimeArtifacts = this.#runtimeArtifacts; - if (runtimeArtifacts === undefined) return; - - await runtimeArtifacts.refreshAfterSourceChange({ - onRuntimeArtifactsChanged: () => this.#handleRuntimeArtifactsChanged(), - }); - } - async #executeExtensionCommand( command: Extract, title: string, diff --git a/packages/eve/src/cli/dev/tui/setup-commands.test.ts b/packages/eve/src/cli/dev/tui/setup-commands.test.ts index 54d18d6a1..fe912fb3b 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.test.ts @@ -54,10 +54,6 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { kind: "done", modelMessage: "Model changed to openai/gpt-5.5. Live on your next prompt.", })), - runConnectionsFlow: vi.fn(async () => ({ - kind: "done", - addedConnections: [], - })), runRegistryFlow: vi.fn(async () => ({ kind: "done", addedItems: [], @@ -71,7 +67,7 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { } function run(input: { - command: "vc:install" | "vc:login" | "model" | "connect" | "add" | "deploy"; + command: "vc:install" | "vc:login" | "model" | "add" | "deploy"; flows: TuiSetupFlows; renderer?: TuiSetupCommandRenderer; initialModelStep?: "provider"; @@ -116,7 +112,6 @@ describe("runTuiSetupCommand", () => { "vc:install": "pulse", "vc:login": "pulse", model: "pulse", - connect: "pulse", add: "pulse", deploy: "spinner", }); @@ -331,44 +326,6 @@ describe("runTuiSetupCommand", () => { }); }); - it.each([ - [ - "configured", - { kind: "done", addedConnections: ["linear", "notion"] }, - "Connections added: linear, notion.", - { kind: "connection-added" }, - ], - [ - "empty", - { kind: "done", addedConnections: [] }, - "No connections added.", - { kind: "model-access-changed" }, - ], - ["cancelled", { kind: "cancelled" }, "/connect dismissed.", { kind: "model-access-changed" }], - [ - "partially failed", - { kind: "failed", addedConnections: ["linear"], message: "install failed" }, - "Connection files changed, but /connect failed: install failed", - { kind: "connection-added" }, - ], - [ - "failed before a connection file was written", - { kind: "failed", addedConnections: [], message: "connector setup failed" }, - "/connect failed: connector setup failed", - { kind: "model-access-changed" }, - ], - ] as const)("reports %s connection flows", async (_case, result, message, effect) => { - const runConnectionsFlow = vi.fn(async () => result); - await expect( - run({ command: "connect", flows: fakeFlows({ runConnectionsFlow }) }), - ).resolves.toEqual({ - message, - preserveFlowDiagnostics: true, - effect, - }); - expect(runConnectionsFlow).toHaveBeenCalledWith(expect.objectContaining({ appRoot: APP_ROOT })); - }); - it.each([ [ "added", diff --git a/packages/eve/src/cli/dev/tui/setup-commands.ts b/packages/eve/src/cli/dev/tui/setup-commands.ts index 293e1f9f9..97c4faaba 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.ts @@ -1,5 +1,4 @@ import { HumanActionRequiredError } from "#setup/human-action.js"; -import { runConnectionsFlow } from "#setup/flows/connections.js"; import { runDeployFlow } from "#setup/flows/deploy.js"; import { runInstallVercelCliFlow, @@ -28,7 +27,6 @@ export const SETUP_FLOW_CONFIG = { "vc:install": { title: "Install the Vercel CLI", indicator: "pulse" }, "vc:login": { title: "Log in to Vercel", indicator: "pulse" }, model: { title: "Configure the agent model", indicator: "pulse" }, - connect: { title: "Agent connections", indicator: "pulse" }, add: { title: "Add to your agent", indicator: "pulse" }, deploy: { title: "Deploy to Vercel", indicator: "spinner" }, } satisfies Record; @@ -48,7 +46,6 @@ export interface TuiSetupCommandInput { renderer: TuiSetupCommandRenderer; /** Initial model-flow step authorized by the runner's boot evidence. */ initialModelStep?: "provider"; - disabledConnectionReasons?: Readonly>; /** Suspends development runtime artifacts while registry installation and setup mutate them. */ withExclusiveTerminal?(task: () => Promise): Promise; /** Test seam; defaults to the real TUI-native prompter over `renderer`. */ @@ -62,7 +59,6 @@ export interface TuiSetupFlows { runInstallVercelCliFlow: typeof runInstallVercelCliFlow; runLoginFlow: typeof runLoginFlow; runModelFlow: typeof runModelFlow; - runConnectionsFlow: typeof runConnectionsFlow; runRegistryFlow: typeof runRegistryFlow; runDeployFlow: typeof runDeployFlow; } @@ -74,7 +70,7 @@ export interface TuiSetupCommandResult { /** Keep warning/error lines after the bordered panel closes. */ preserveFlowDiagnostics: boolean; /** Status refresh required after the command settles. */ - effect?: VercelStatusEffect | { kind: "connection-added" } | { kind: "model-access-changed" }; + effect?: VercelStatusEffect | { kind: "model-access-changed" }; } /** @@ -122,7 +118,7 @@ function muteableRenderer( } /** - * Runs one TUI setup command (/model, /connect, /deploy) over the + * Runs one TUI setup command (/model, /add, /deploy) over the * shared setup flows, asking through the TUI's own bordered panel. Never throws: * every outcome — done, cancelled, failed — folds into the returned command * result. Ctrl-C or Esc on the working indicator (no question open) aborts the @@ -171,7 +167,6 @@ async function executeSetupCommand( runInstallVercelCliFlow, runLoginFlow, runModelFlow, - runConnectionsFlow, runRegistryFlow, runDeployFlow, ...input.flows, @@ -231,46 +226,6 @@ async function executeSetupCommand( } return outcome; } - case "connect": { - const result = await flows.runConnectionsFlow({ - appRoot, - prompter, - signal, - disabledConnectionReasons: input.disabledConnectionReasons, - }); - switch (result.kind) { - case "cancelled": - return { - message: "/connect dismissed.", - preserveFlowDiagnostics: true, - effect: { kind: "model-access-changed" }, - }; - case "failed": - return { - message: - result.addedConnections.length === 0 - ? `/connect failed: ${result.message}` - : `Connection files changed, but /connect failed: ${result.message}`, - preserveFlowDiagnostics: true, - effect: - result.addedConnections.length === 0 - ? { kind: "model-access-changed" } - : { kind: "connection-added" }, - }; - case "done": - return { - message: - result.addedConnections.length === 0 - ? "No connections added." - : `Connections added: ${result.addedConnections.join(", ")}.`, - preserveFlowDiagnostics: true, - effect: - result.addedConnections.length === 0 - ? { kind: "model-access-changed" } - : { kind: "connection-added" }, - }; - } - } case "add": { const result = await flows.runRegistryFlow({ appRoot, prompter, signal }); if (result.kind === "cancelled") { diff --git a/packages/eve/src/setup/boxes/add-connections.test.ts b/packages/eve/src/setup/boxes/add-connections.test.ts deleted file mode 100644 index 135e3596f..000000000 --- a/packages/eve/src/setup/boxes/add-connections.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; - -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 { addConnections, type AddConnectionsDeps } from "./add-connections.js"; -import { - buildCatalogOptions, - selectConnections, - type SelectConnectionsOptions, -} from "./select-connections.js"; - -const silentSink: OutputSink = { write: () => {} }; -const snapshot = { snapshot: snapshotSetupState }; - -function createPrompter(): Prompter { - return createFakePrompter().prompter; -} - -/** - * Composes the selection and execution boxes the way the onboarding site does: - * selection resolves the picker and custom sub-questions into plans on the - * state, execution runs the effects. The ask channel pairs the headless base - * with the selection box's `headless` flag, or an interactive base over the - * test prompter. - */ -function makeBoxes( - options: Omit & { - prompter: Prompter; - headless?: boolean; - deps?: AddConnectionsDeps; - }, -): [ReturnType, ReturnType] { - const headless = options.headless ?? false; - return [ - selectConnections({ - presetConnections: options.presetConnections, - asker: headless ? headlessAsker() : interactiveAsker(options.prompter), - headless, - }), - addConnections({ - prompter: options.prompter, - deps: options.deps, - }), - ]; -} - -function createDeps() { - return { - ensureConnection: vi.fn(async (options) => ({ - slug: options.slug ?? options.entry.slug, - protocol: options.protocol, - action: "created", - filePath: `/tmp/project/agent/connections/${options.slug ?? options.entry.slug}.ts`, - filesWritten: [`/tmp/project/agent/connections/${options.slug ?? options.entry.slug}.ts`], - filesSkipped: [], - packageJsonUpdated: [], - envKeysAdded: [], - envKeysRequired: [], - })), - listAuthoredConnections: vi.fn(async () => []), - readProjectLink: vi.fn(async () => ({ - orgId: "org_1", - projectId: "prj_demo", - })), - setupConnectionConnector: vi.fn(async () => ({ - kind: "existing", - connectorUid: "oauth/connector-1", - })), - cleanupCreatedConnectionConnector: vi.fn< - AddConnectionsDeps["cleanupCreatedConnectionConnector"] - >(async () => {}), - }; -} - -function createdConnectorDeps() { - const deps = createDeps(); - deps.setupConnectionConnector.mockResolvedValueOnce({ - kind: "created", - connectorUid: "linear/new", - connectorId: "scl_new", - }); - return deps; -} - -function runCreatedConnector(deps: ReturnType) { - return runInteractive( - makeBoxes({ prompter: createPrompter(), presetConnections: ["linear"], deps }), - resolvedState(), - silentSink, - snapshot, - ); -} - -function resolvedState(): SetupState { - const state = createDefaultSetupState(); - state.projectPath = { kind: "resolved", inPlace: false, path: "/tmp/project" }; - state.vercelProject = { kind: "new", project: "project", team: "team" }; - state.project = { kind: "linked", projectId: "prj_demo" }; - return state; -} - -/** A flow that chose not to deploy to Vercel: no project planned or linked. */ -function noVercelState(): SetupState { - const state = createDefaultSetupState(); - state.projectPath = { kind: "resolved", inPlace: false, path: "/tmp/project" }; - return state; -} - -describe("selectConnections + addConnections boxes", () => { - test("scaffolds a preset slug headlessly and prints the connector command hint", async () => { - const deps = createDeps(); - const prompter = createPrompter(); - const boxes = makeBoxes({ prompter, presetConnections: ["linear"], headless: true, deps }); - - await runHeadless(boxes, resolvedState(), silentSink, snapshot); - - expect(deps.ensureConnection).toHaveBeenCalledWith({ - projectRoot: "/tmp/project", - slug: "linear", - protocol: "mcp", - entry: expect.objectContaining({ slug: "linear" }), - }); - // Headless runs surface the connector command instead of provisioning, - // since `vercel connect create` opens a browser. - expect(deps.setupConnectionConnector).not.toHaveBeenCalled(); - expect(prompter.log.info).toHaveBeenCalledWith( - "Run `vercel connect create mcp.linear.app --name linear`, then set the connector UID in agent/connections/linear.ts.", - ); - }); - - test("provisions the Connect connector for a preset slug interactively", async () => { - const deps = createDeps(); - const boxes = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["linear"], - deps, - }); - - await runInteractive(boxes, resolvedState(), silentSink, snapshot); - - expect(deps.setupConnectionConnector).toHaveBeenCalledWith( - expect.objectContaining({ - slug: "linear", - service: "mcp.linear.app", - projectRoot: "/tmp/project", - }), - ); - expect(deps.ensureConnection).toHaveBeenCalledWith( - expect.objectContaining({ - entry: expect.objectContaining({ - auth: expect.objectContaining({ connector: "oauth/connector-1" }), - }), - }), - ); - }); - - test("uses the picker selection when no preset is given", async () => { - const deps = createDeps(); - let presented: { value: string | number | boolean; label: string }[] = []; - const prompter = createFakePrompter({ - multiple: (opts) => { - presented = opts.options; - return ["notion"]; - }, - }).prompter; - const boxes = makeBoxes({ prompter, deps }); - - await runInteractive(boxes, resolvedState(), silentSink, snapshot); - - expect(deps.ensureConnection).toHaveBeenCalledWith( - expect.objectContaining({ slug: "notion", protocol: "mcp" }), - ); - expect(deps.setupConnectionConnector).toHaveBeenCalledWith( - expect.objectContaining({ slug: "notion", service: "mcp.notion.com" }), - ); - // The interactive picker offers only curated catalog entries. - expect(presented.length).toBeGreaterThan(0); - expect(presented.some((option) => option.value === "custom")).toBe(false); - }); - - test("marks blocked catalog rows disabled with the reason", () => { - const options = buildCatalogOptions({ linear: "already defined" }); - - const linear = options.find((option) => option.value === "linear"); - expect(linear?.disabled).toBe(true); - expect(linear?.disabledReason).toBe("already defined"); - const others = options.filter((option) => option.value !== "linear"); - expect(others.length).toBeGreaterThan(0); - expect(others.every((option) => option.disabled !== true)).toBe(true); - }); - - test("builds a custom MCP plan from interactive prompts, deriving the service host", async () => { - const deps = createDeps(); - const text = vi - .fn<(opts: { message: string }) => string>() - .mockReturnValueOnce("Internal tools") // description - .mockReturnValueOnce("https://mcp.mycorp.dev/sse"); // url - const prompter = createFakePrompter({ text }).prompter; - // A valid slug that is not in the catalog routes to the custom planner, the - // only production path to it: the picker offers catalog rows only, so the - // channel (which now enforces the offered option ids, unlike the old raw - // prompter.select) would refuse a "custom" sentinel the picker never showed. - // The slug comes from the preset, so only the description and URL are asked. - const boxes = makeBoxes({ prompter, presetConnections: ["mycorp"], deps }); - - await runInteractive(boxes, resolvedState(), silentSink, snapshot); - - expect(text.mock.calls.map(([opts]) => opts.message)).toEqual([ - "Description for mycorp", - "MCP server URL for mycorp", - ]); - expect(deps.ensureConnection).toHaveBeenCalledWith( - expect.objectContaining({ - slug: "mycorp", - protocol: "mcp", - entry: expect.objectContaining({ - slug: "mycorp", - mcp: { url: "https://mcp.mycorp.dev/sse" }, - auth: { kind: "connect", connector: "oauth/connector-1" }, - }), - }), - ); - // Custom MCP URLs retain the host fallback because only curated providers - // declare a verified Vercel Connect service identifier. - expect(deps.setupConnectionConnector).toHaveBeenCalledWith( - expect.objectContaining({ slug: "mycorp", service: "mcp.mycorp.dev" }), - ); - }); - - test("rejects an unknown preset slug headlessly without touching files", async () => { - const deps = createDeps(); - const boxes = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["mystery"], - headless: true, - deps, - }); - - await expect(runHeadless(boxes, resolvedState(), silentSink, snapshot)).rejects.toThrow( - /requires interactive input/, - ); - expect(deps.ensureConnection).not.toHaveBeenCalled(); - }); - - test("rejects a malformed preset slug with the catalog listing", async () => { - const deps = createDeps(); - const boxes = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["Not_A_Slug"], - headless: true, - deps, - }); - - await expect(runHeadless(boxes, resolvedState(), silentSink, snapshot)).rejects.toThrow( - /Unknown connection "Not_A_Slug"/, - ); - expect(deps.ensureConnection).not.toHaveBeenCalled(); - }); - - test("selection records fully-specified plans on the state without running any effect", async () => { - const deps = createDeps(); - const [selectBox] = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["linear"], - headless: true, - deps, - }); - - const next = await runHeadless([selectBox], resolvedState(), silentSink, snapshot); - - expect(next.connectionSelection).toEqual([ - { - slug: "linear", - protocol: "mcp", - entry: expect.objectContaining({ slug: "linear" }), - provision: { kind: "command-hint", service: "mcp.linear.app" }, - }, - ]); - expect(deps.ensureConnection).not.toHaveBeenCalled(); - expect(deps.setupConnectionConnector).not.toHaveBeenCalled(); - }); - - test("the execution box skips entirely when nothing was selected", () => { - const box = addConnections({ prompter: createPrompter(), deps: createDeps() }); - - expect(box.shouldRun?.(snapshotSetupState(resolvedState()))).toBe(false); - expect( - box.shouldRun?.( - snapshotSetupState({ - ...resolvedState(), - connectionSelection: [ - { - slug: "linear", - protocol: "mcp", - entry: { slug: "linear" } as never, - provision: { kind: "none" }, - }, - ], - }), - ), - ).toBe(true); - }); - - test("skips provisioning when the connection file already exists", async () => { - const deps = createDeps(); - deps.listAuthoredConnections.mockResolvedValueOnce(["linear"]); - deps.ensureConnection.mockResolvedValueOnce({ - slug: "linear", - protocol: "mcp", - action: "skipped", - filePath: "/tmp/project/agent/connections/linear.ts", - filesWritten: [], - filesSkipped: ["/tmp/project/agent/connections/linear.ts"], - packageJsonUpdated: [], - envKeysAdded: [], - envKeysRequired: [], - }); - const prompter = createPrompter(); - const boxes = makeBoxes({ prompter, presetConnections: ["linear"], deps }); - - await runInteractive(boxes, resolvedState(), silentSink, snapshot); - - expect(deps.setupConnectionConnector).not.toHaveBeenCalled(); - expect(prompter.log.warning).toHaveBeenCalledWith( - "Skipped linear (already exists; pass --force to overwrite).", - ); - }); - - test("removes a connector created before a scaffold failure", async () => { - const deps = createdConnectorDeps(); - deps.ensureConnection.mockRejectedValueOnce(new Error("write failed")); - - await expect(runCreatedConnector(deps)).rejects.toThrow("write failed"); - expect(deps.cleanupCreatedConnectionConnector).toHaveBeenCalledWith( - expect.objectContaining({ connectorId: "scl_new" }), - ); - }); - - test("reports both scaffold and connector cleanup failures", async () => { - const deps = createdConnectorDeps(); - deps.ensureConnection.mockRejectedValueOnce(new Error("write failed")); - deps.cleanupCreatedConnectionConnector.mockRejectedValueOnce( - new Error("remove scl_new manually"), - ); - await expect(runCreatedConnector(deps)).rejects.toThrow("write failed remove scl_new manually"); - }); - - test("is skipped in headless mode when no connections are requested", async () => { - const deps = createDeps(); - const boxes = makeBoxes({ prompter: createPrompter(), headless: true, deps }); - - const next = await runHeadless(boxes, resolvedState(), silentSink, snapshot); - - expect(deps.ensureConnection).not.toHaveBeenCalled(); - expect(next.project).toEqual({ kind: "linked", projectId: "prj_demo" }); - }); - - test("offers the full catalog before the deployment decision (no Vercel plan yet)", async () => { - // The deployment question now comes after the connections picker, so no - // row is disabled here; the provisioning box resolves Connect-backed - // selections to Vercel instead. - const deps = createDeps(); - let presented: { value: string | number | boolean; disabled?: boolean }[] = []; - const prompter = createFakePrompter({ - multiple: (opts) => { - presented = opts.options; - return []; - }, - }).prompter; - const boxes = makeBoxes({ prompter, deps }); - - await runInteractive(boxes, noVercelState(), silentSink, snapshot); - - expect(presented.length).toBeGreaterThan(0); - expect(presented.every((option) => option.disabled !== true)).toBe(true); - expect(deps.ensureConnection).not.toHaveBeenCalled(); - }); - - test("throws when reusing an unresolved project while deploying to Vercel", async () => { - const state = resolvedState(); - // project stays unresolved: the link box did not record a resolution. - state.project = { kind: "unresolved" }; - const deps = createDeps(); - const boxes = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["linear"], - deps, - }); - - await expect(runInteractive(boxes, state, silentSink, snapshot)).rejects.toThrow( - /none was resolved/, - ); - }); -}); diff --git a/packages/eve/src/setup/boxes/add-connections.ts b/packages/eve/src/setup/boxes/add-connections.ts deleted file mode 100644 index ff360ef73..000000000 --- a/packages/eve/src/setup/boxes/add-connections.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { - ensureConnection, - listAuthoredConnections, - type ConnectionInput, - type ConnectionMutationResult, -} from "#setup/scaffold/index.js"; -import type { ChannelSetupLog } from "#setup/cli/index.js"; - -import { - cleanupCreatedConnectionConnector, - setupConnectionConnector, -} from "../connection-connector.js"; -import { canonicalConnectorNameForEntry } from "../scaffold/connections/catalog.js"; -import { - isProjectResolved, - mergeProjectResolution, - readProjectLink, - type ProjectResolution, -} from "../project-resolution.js"; -import type { Prompter } from "../prompter.js"; -import { hasVercelProject, requireProjectPath, type SetupState } from "../state.js"; -import type { SetupBox } from "../step.js"; -import { projectIdFromResolution } from "../vercel-project.js"; -import { CONNECT_REQUIRES_VERCEL } from "./select-connections.js"; - -/** Injected for tests; defaults to the real scaffold and Connect effects. */ -export interface AddConnectionsDeps { - ensureConnection: typeof ensureConnection; - listAuthoredConnections: typeof listAuthoredConnections; - readProjectLink: typeof readProjectLink; - setupConnectionConnector: typeof setupConnectionConnector; - cleanupCreatedConnectionConnector: typeof cleanupCreatedConnectionConnector; -} - -export interface AddConnectionsOptions { - /** Carries connector selection prompts and provisioning output. */ - prompter: Prompter; - signal?: AbortSignal; - deps?: AddConnectionsDeps; -} - -function logFollowUp(log: ChannelSetupLog, result: ConnectionMutationResult): void { - if (result.action === "skipped") { - log.warning(`Skipped ${result.slug} (already exists; pass --force to overwrite).`); - return; - } - log.success(`Added agent/connections/${result.slug}.ts`); - if (result.envKeysAdded.length > 0) { - log.info(`Set ${result.envKeysAdded.join(", ")} in .env.local`); - } else if (result.envKeysRequired.length > 0) { - log.info(`Set ${result.envKeysRequired.join(", ")} in your environment`); - } -} - -function withConnectorUid(entry: ConnectionInput, connectorUid: string): ConnectionInput { - if (entry.auth?.kind !== "connect") { - throw new Error(`Connection ${entry.slug} is not configured for Vercel Connect.`); - } - return { ...entry, auth: { ...entry.auth, connector: connectorUid } }; -} - -/** - * THE CONNECTIONS BOX: executes the {@link ConnectionPlan}s the - * select-connections box recorded during the interview. It scaffolds each file - * and resolves the concrete Connect connector against the linked project. - */ -export function addConnections( - options: AddConnectionsOptions, -): SetupBox { - const deps = options.deps ?? { - ensureConnection, - listAuthoredConnections, - readProjectLink, - setupConnectionConnector, - cleanupCreatedConnectionConnector, - }; - - return { - id: "add-connections", - - shouldRun(state) { - return state.connectionSelection.length > 0; - }, - - async gather(): Promise { - // No questions: the plans were resolved by the select-connections box. - return null; - }, - - async perform({ state }): Promise { - const log = options.prompter.log; - const projectRoot = requireProjectPath(state); - const noVercel = !hasVercelProject(state); - const project = state.project; - const authored = new Set(await deps.listAuthoredConnections(projectRoot)); - - for (const plan of state.connectionSelection) { - if (authored.has(plan.slug)) { - const result = await deps.ensureConnection({ - projectRoot, - slug: plan.slug, - protocol: plan.protocol, - entry: plan.entry, - }); - logFollowUp(log, result); - continue; - } - - let entry = plan.entry; - let createdConnectorId: string | undefined; - - switch (plan.provision.kind) { - case "connect": { - const canonicalConnectorName = canonicalConnectorNameForEntry(plan.entry); - if (canonicalConnectorName === undefined) { - throw new Error(`Connection ${plan.slug} has no canonical connector name.`); - } - const connector = await deps.setupConnectionConnector({ - log, - prompter: options.prompter, - projectRoot, - slug: plan.slug, - service: plan.provision.service, - canonicalConnectorName, - project: await resolveConnectionProject({ - noVercel, - project, - projectRoot, - readProjectLink: deps.readProjectLink, - }), - signal: options.signal, - }); - entry = withConnectorUid(entry, connector.connectorUid); - if (connector.kind === "created") createdConnectorId = connector.connectorId; - break; - } - case "command-hint": - log.info( - `Run \`vercel connect create ${plan.provision.service} --name ${plan.slug}\`, then set the connector UID in agent/connections/${plan.slug}.ts.`, - ); - break; - case "connect-manual": - log.warning( - `Could not determine a Connect service for ${plan.slug}. Create the connector manually and set its UID in agent/connections/${plan.slug}.ts.`, - ); - break; - case "none": - break; - } - - let result: ConnectionMutationResult; - try { - result = await deps.ensureConnection({ - projectRoot, - slug: plan.slug, - protocol: plan.protocol, - entry, - }); - } catch (error) { - if (createdConnectorId !== undefined) { - try { - await deps.cleanupCreatedConnectionConnector({ - log, - projectRoot, - connectorId: createdConnectorId, - }); - } catch (cleanupError) { - throw new AggregateError( - [error, cleanupError], - `${error instanceof Error ? error.message : String(error)} ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, - ); - } - } - throw error; - } - if (result.action === "skipped" && createdConnectorId !== undefined) { - await deps.cleanupCreatedConnectionConnector({ - log, - projectRoot, - connectorId: createdConnectorId, - }); - } - logFollowUp(log, result); - if (result.action !== "skipped") authored.add(result.slug); - } - return project; - }, - - apply(state, payload) { - return { ...state, project: mergeProjectResolution(state.project, payload) }; - }, - }; -} - -async function resolveConnectionProject(input: { - noVercel: boolean; - project: ProjectResolution; - projectRoot: string; - readProjectLink: typeof readProjectLink; -}) { - if (input.noVercel) { - throw new Error(CONNECT_REQUIRES_VERCEL); - } - if (!isProjectResolved(input.project)) { - throw new Error("Expected a linked Vercel project for Connect, but none was resolved."); - } - - const linkedProject = await input.readProjectLink(input.projectRoot); - if ( - linkedProject === undefined || - linkedProject.projectId !== projectIdFromResolution(input.project) - ) { - throw new Error("A linked Vercel project is required. Run `eve link`, then retry /connect."); - } - return linkedProject; -} diff --git a/packages/eve/src/setup/boxes/select-connections.ts b/packages/eve/src/setup/boxes/select-connections.ts deleted file mode 100644 index 62ec994ff..000000000 --- a/packages/eve/src/setup/boxes/select-connections.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { - catalogSlugs, - CONNECTION_CATALOG, - CUSTOM_CONNECTION_SLUG, - effectiveProtocols, - getCatalogEntry, - isValidConnectionSlug, - SUPPORTED_PROTOCOLS, - type ConnectionInput, - type ConnectionProtocol, - type CustomConnectionInput, -} from "#setup/scaffold/index.js"; -import { connectorServiceForEntry } from "#setup/scaffold/connections/catalog.js"; -import type { ConnectionSelectOption } from "#setup/cli/index.js"; - -import { select, text, type Asker, type MultiSelectOption } from "../ask.js"; -import type { ConnectionPlan, SetupState } from "../state.js"; -import type { SetupBox } from "../step.js"; - -export const CONNECT_REQUIRES_VERCEL = - "Authenticates through Vercel Connect, which needs a Vercel project. Re-run and choose to deploy to Vercel."; - -const PROTOCOL_LABELS: Record = { - mcp: "MCP", - openapi: "OpenAPI", -}; - -export interface SelectConnectionsOptions { - /** Resolves the picker and custom sub-questions; the composed stack decides how. */ - asker: Asker; - /** - * Headless mode: skips the picker entirely unless `presetConnections` were - * requested. Fixed at composition time (the same place the asker base is - * chosen), since `gather` cannot read the mode off the asker. - */ - headless?: boolean; - /** Skip the picker and plan exactly these catalog slugs. */ - presetConnections?: string[]; -} - -/** Exported for tests: the picker rows derived from the curated catalog. */ -export function buildCatalogOptions( - disabledReasons: Readonly>, -): ConnectionSelectOption[] { - const options: ConnectionSelectOption[] = CONNECTION_CATALOG.map((entry) => { - const reason = disabledReasons[entry.slug]; - return reason === undefined - ? { value: entry.slug, label: entry.label, hint: entry.hint } - : { - value: entry.slug, - label: entry.label, - hint: entry.hint, - disabled: true, - disabledReason: reason, - }; - }); - return options; -} - -function unknownSlugError(rawSlug: string): Error { - return new Error( - `Unknown connection "${rawSlug}". Known: ${catalogSlugs().join(", ")}, or pass a custom name with a definition.`, - ); -} - -function assertSupportedProtocols( - effective: readonly ConnectionProtocol[], - label: string, -): asserts effective is readonly [ConnectionProtocol, ...ConnectionProtocol[]] { - if (effective.length === 0) { - throw new Error( - `No supported protocol for "${label}". Supported: ${SUPPORTED_PROTOCOLS.join(", ")}.`, - ); - } -} - -async function resolveProtocolInteractive( - asker: Asker, - declared: readonly ConnectionProtocol[] | undefined, - label: string, -): Promise { - const effective = effectiveProtocols(declared); - assertSupportedProtocols(effective, label); - if (effective.length === 1) return effective[0]; - - return asker.ask( - select({ - key: `protocol:${label}`, - message: `Protocol for ${label}`, - options: effective.map((protocol) => ({ - id: protocol, - value: protocol, - label: PROTOCOL_LABELS[protocol], - })), - }), - ); -} - -function resolveProtocolHeadless( - declared: readonly ConnectionProtocol[] | undefined, - label: string, -): ConnectionProtocol { - const effective = effectiveProtocols(declared); - assertSupportedProtocols(effective, label); - if (effective.length > 1) { - throw new Error( - `Connection "${label}" supports multiple protocols (${effective.join(", ")}). Pass --protocol to choose one.`, - ); - } - return effective[0]; -} - -/** - * How the connector for a Connect-backed entry gets provisioned. Interactive - * runs provision through Connect (browser OAuth); headless runs print the - * `vercel connect create` command instead. An underivable service degrades to - * a manual instruction either way. - */ -function deriveProvision(entry: ConnectionInput, headless: boolean): ConnectionPlan["provision"] { - if (entry.auth?.kind !== "connect") return { kind: "none" }; - const service = connectorServiceForEntry({ mcp: entry.mcp, auth: entry.auth }); - if (service === undefined) return { kind: "connect-manual" }; - return headless ? { kind: "command-hint", service } : { kind: "connect", service }; -} - -async function promptCustomSlug(asker: Asker): Promise { - return asker.ask( - text({ - key: "connection-name", - message: "Connection name", - placeholder: "mycorp", - validate: (value) => { - const trimmed = value.trim(); - if (trimmed.length === 0) return "A name is required."; - if (!isValidConnectionSlug(trimmed)) { - return "Start with a lowercase letter; use only lowercase letters, digits, and hyphens (max 64 characters)."; - } - return null; - }, - }), - ); -} - -async function planCustomInteractive(asker: Asker, slug: string): Promise { - const protocol = await resolveProtocolInteractive(asker, undefined, slug); - const description = await asker.ask( - text({ - key: `description:${slug}`, - message: `Description for ${slug}`, - placeholder: "What this connection exposes", - }), - ); - - if (protocol === "mcp") { - const url = await asker.ask( - text({ - key: `mcp-url:${slug}`, - message: `MCP server URL for ${slug}`, - placeholder: "https://mcp.example.com/mcp", - validate: (value) => (value.trim().length === 0 ? "A URL is required." : null), - }), - ); - const entry: CustomConnectionInput = { - slug, - description, - protocols: ["mcp"], - mcp: { url: url.trim() }, - auth: { kind: "connect", connector: slug }, - }; - return { slug, protocol, entry, provision: deriveProvision(entry, false) }; - } - - const spec = await asker.ask( - text({ - key: `openapi-spec:${slug}`, - message: `OpenAPI spec URL for ${slug}`, - placeholder: "https://api.example.com/openapi.json", - validate: (value) => (value.trim().length === 0 ? "A spec URL is required." : null), - }), - ); - const baseUrl = await asker.ask( - text({ - key: `openapi-base-url:${slug}`, - message: `Base URL for ${slug} (optional)`, - placeholder: "https://api.example.com", - }), - ); - const openapi = - baseUrl.trim().length > 0 - ? { spec: spec.trim(), baseUrl: baseUrl.trim() } - : { spec: spec.trim() }; - const entry: CustomConnectionInput = { - slug, - description, - protocols: ["openapi"], - openapi, - auth: { kind: "connect", connector: slug }, - }; - return { slug, protocol, entry, provision: deriveProvision(entry, false) }; -} - -async function planSelectionInteractive(asker: Asker, rawSlug: string): Promise { - if (rawSlug === CUSTOM_CONNECTION_SLUG) { - const slug = (await promptCustomSlug(asker)).trim(); - return planCustomInteractive(asker, slug); - } - - const entry = getCatalogEntry(rawSlug); - if (entry !== undefined) { - const protocol = await resolveProtocolInteractive(asker, entry.protocols, rawSlug); - return { slug: rawSlug, protocol, entry, provision: deriveProvision(entry, false) }; - } - - if (!isValidConnectionSlug(rawSlug)) { - throw unknownSlugError(rawSlug); - } - return planCustomInteractive(asker, rawSlug); -} - -function planPresetHeadless(rawSlug: string): ConnectionPlan { - if (rawSlug === CUSTOM_CONNECTION_SLUG) { - throw new Error("Custom connection requires interactive input or a preset definition."); - } - - const entry = getCatalogEntry(rawSlug); - if (entry !== undefined) { - return { - slug: rawSlug, - protocol: resolveProtocolHeadless(entry.protocols, rawSlug), - entry, - provision: deriveProvision(entry, true), - }; - } - - if (!isValidConnectionSlug(rawSlug)) { - throw unknownSlugError(rawSlug); - } - throw new Error( - `Custom connection "${rawSlug}" requires interactive input or a preset definition.`, - ); -} - -/** - * THE CONNECTION SELECTION BOX: part of the interview phase, before any - * filesystem write. Offers the connection catalog (plus a custom MCP / OpenAPI - * escape hatch through preset slugs) and resolves every prompt into - * fully-specified {@link ConnectionPlan}s recorded on the state; the - * add-connections box executes them after the scaffold and link. The full - * catalog is selectable: the deployment decision comes later in the interview, - * and the provisioning box resolves to Vercel when a Connect-backed selection - * needs a project. - */ -export function selectConnections( - options: SelectConnectionsOptions, -): SetupBox { - return { - id: "select-connections", - - async gather(): Promise { - const headless = options.headless ?? false; - const presets = options.presetConnections ?? []; - - // Headless maps presets to plans without prompting (and refuses an - // ambiguous protocol with a flag hint), or skips entirely when no preset - // was requested. - if (headless) { - return presets.map((slug) => planPresetHeadless(slug)); - } - - // The preset short-circuits the picker but still flows through the - // interactive planners (a catalog preset can still need a protocol pick), - // so it stays a factory option rather than a withAnswers rung. - let selected: string[]; - if (presets.length > 0) { - selected = [...presets]; - } else { - const pickerOptions: MultiSelectOption[] = buildCatalogOptions({}).map( - (option) => ({ - id: String(option.value), - value: String(option.value), - label: option.label, - hint: option.hint, - disabled: option.disabled, - disabledReason: option.disabledReason, - }), - ); - selected = await options.asker.askMany({ - key: "connection", - message: "What should your agent connect to?", - options: pickerOptions, - }); - } - - const plans: ConnectionPlan[] = []; - for (const rawSlug of selected) { - plans.push(await planSelectionInteractive(options.asker, rawSlug)); - } - return plans; - }, - - async perform({ input }): Promise { - return input; - }, - - apply(state, payload) { - return { ...state, connectionSelection: payload }; - }, - }; -} diff --git a/packages/eve/src/setup/flows/connections.test.ts b/packages/eve/src/setup/flows/connections.test.ts deleted file mode 100644 index bf4bade3d..000000000 --- a/packages/eve/src/setup/flows/connections.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import type { AddConnectionsDeps } from "#setup/boxes/add-connections.js"; -import type { DeploymentInfo } from "#setup/project-resolution.js"; -import type { PrompterValue, SingleSelectOptions } from "#setup/prompter.js"; -import { WizardCancelledError } from "#setup/step.js"; - -import { - CONNECTIONS_PROMPT_MESSAGE, - runConnectionsFlow, - type ConnectionsFlowDeps, -} from "./connections.js"; - -const APP_ROOT = "/app/agent"; -const LINKED: DeploymentInfo = { state: "linked", projectId: "prj_1", orgId: "org_1" }; - -function scriptConnectionList(queue: Array) { - const requests: SingleSelectOptions[] = []; - return { - requests, - single(options: SingleSelectOptions): PrompterValue { - if (options.message !== CONNECTIONS_PROMPT_MESSAGE) { - throw new Error(`Unexpected select: ${options.message}`); - } - requests.push(options); - const next = queue.shift(); - if (next === undefined) throw new Error("Connection list exhausted its scripted picks."); - if (next === "cancel") throw new WizardCancelledError(); - return next; - }, - }; -} - -function addConnectionDeps(): AddConnectionsDeps { - return { - ensureConnection: vi.fn(async (options) => ({ - slug: options.slug ?? options.entry.slug, - protocol: options.protocol, - action: "created", - filePath: `${APP_ROOT}/agent/connections/${options.slug ?? options.entry.slug}.ts`, - filesWritten: [`${APP_ROOT}/agent/connections/${options.slug ?? options.entry.slug}.ts`], - filesSkipped: [], - packageJsonUpdated: [], - envKeysAdded: [], - envKeysRequired: [], - })), - setupConnectionConnector: vi.fn(async () => - Object.freeze({ kind: "existing", connectorUid: "mcp.linear.app/linear" }), - ), - listAuthoredConnections: vi.fn(async () => []), - readProjectLink: vi.fn(async () => ({ orgId: "org_1", projectId: "prj_1" })), - cleanupCreatedConnectionConnector: vi.fn(async () => {}), - }; -} - -function runConnectionFlow( - list: ReturnType, - deps: Partial = {}, - disabledConnectionReasons: Readonly> = {}, -) { - const defaults: ConnectionsFlowDeps = { - detectDeployment: vi.fn(async () => LINKED), - detectPackageManager: vi.fn(async () => Object.freeze({ kind: "pnpm", source: "default" })), - ensureConnectionDependencies: vi.fn(async () => []), - getVercelAuthStatus: vi.fn(() => Promise.resolve<"authenticated">("authenticated")), - listAuthoredConnections: vi.fn(async () => []), - runLinkFlow: vi.fn(async () => Object.freeze({ kind: "done" })), - runPackageManagerInstall: vi.fn(async () => true), - addConnections: addConnectionDeps(), - }; - return runConnectionsFlow({ - appRoot: APP_ROOT, - prompter: createFakePrompter({ single: list.single }).prompter, - disabledConnectionReasons, - deps: { ...defaults, ...deps }, - }); -} - -describe("runConnectionsFlow", () => { - it("returns a terminal success after adding a catalog connection", async () => { - const listAuthoredConnections = vi - .fn(async () => [] as string[]) - .mockResolvedValueOnce([]) - .mockResolvedValue(["linear"]); - const list = scriptConnectionList(["linear"]); - - await expect(runConnectionFlow(list, { listAuthoredConnections })).resolves.toEqual({ - kind: "done", - addedConnections: ["linear"], - }); - - expect(list.requests[0]).toMatchObject({ - hintLayout: "inline", - search: true, - placeholder: "type to search MCP servers", - }); - expect(list.requests[0]?.options.map((row) => row.value)).toEqual([ - "linear", - "notion", - "datadog", - "honeycomb", - "done", - ]); - expect(list.requests).toHaveLength(1); - }); - - it("defaults to Done when every catalog connection is already authored", async () => { - const list = scriptConnectionList(["done"]); - await runConnectionFlow(list, { - listAuthoredConnections: vi.fn(async () => ["linear", "notion", "datadog", "honeycomb"]), - }); - - expect(list.requests[0]?.initialValue).toBe("done"); - }); - - it("blocks logged-out rows", async () => { - const loggedOutList = scriptConnectionList(["cancel"]); - await expect( - runConnectionFlow(loggedOutList, { - detectDeployment: vi.fn(() => Promise.resolve({ state: "unlinked" })), - getVercelAuthStatus: vi.fn(async (): Promise<"logged-out"> => "logged-out"), - }), - ).resolves.toEqual({ kind: "cancelled" }); - expect(loggedOutList.requests[0]?.options.find((row) => row.value === "linear")).toMatchObject({ - disabled: true, - disabledReason: "Log in to Vercel first, see /vc:login", - }); - }); - - it("disables a connection whose MCP endpoint is unreachable", async () => { - const list = scriptConnectionList(["cancel"]); - await expect( - runConnectionFlow( - list, - {}, - { - notion: "https://mcp.notion.com/mcp is not reachable (HTTP 404).", - }, - ), - ).resolves.toEqual({ kind: "cancelled" }); - - expect(list.requests[0]?.options.find((row) => row.value === "notion")).toMatchObject({ - disabled: true, - disabledReasonTone: "warning", - disabledReason: "https://mcp.notion.com/mcp is not reachable (HTTP 404).", - }); - }); - - it("runs the shared create-or-link flow before configuring an unlinked project", async () => { - const detectDeployment = vi - .fn() - .mockResolvedValueOnce({ state: "unlinked" }) - .mockResolvedValueOnce(LINKED); - const runLinkFlow = vi.fn(async () => ({ kind: "done" })); - const listAuthoredConnections = vi - .fn(async () => [] as string[]) - .mockResolvedValueOnce([]) - .mockResolvedValue(["linear"]); - const list = scriptConnectionList(["linear"]); - await expect( - runConnectionFlow(list, { - detectDeployment, - listAuthoredConnections, - runLinkFlow, - }), - ).resolves.toEqual({ kind: "done", addedConnections: ["linear"] }); - - const linkInput = runLinkFlow.mock.calls[0]?.[0]; - expect(linkInput?.projectSelection).toBe("create-or-link"); - expect(linkInput?.teamSelectMessage?.("Acme")).toBe( - "You need to link to a project to use Vercel Connect.\n\nSelect your team", - ); - }); - - it("returns a terminal cancellation when project linking is cancelled", async () => { - const runLinkFlow = vi.fn(async () => Object.freeze({ kind: "cancelled" })); - const list = scriptConnectionList(["linear"]); - - await expect( - runConnectionFlow(list, { - detectDeployment: vi.fn(() => Promise.resolve({ state: "unlinked" })), - runLinkFlow, - }), - ).resolves.toEqual({ kind: "cancelled" }); - expect(list.requests).toHaveLength(1); - }); - - it("returns a terminal failure when connector setup fails", async () => { - const list = scriptConnectionList(["linear"]); - const addConnections = addConnectionDeps(); - vi.mocked(addConnections.setupConnectionConnector).mockRejectedValueOnce( - new Error("Could not create the mcp.linear.app connector."), - ); - - await expect(runConnectionFlow(list, { addConnections })).resolves.toEqual({ - kind: "failed", - addedConnections: [], - message: "Could not create the mcp.linear.app connector.", - }); - expect(list.requests).toHaveLength(1); - }); - - it("installs dependencies before provisioning a connector", async () => { - const list = scriptConnectionList(["linear"]); - const addConnections = addConnectionDeps(); - const runPackageManagerInstall = vi.fn(async () => false); - - await expect( - runConnectionFlow(list, { addConnections, runPackageManagerInstall }), - ).resolves.toEqual({ - kind: "failed", - addedConnections: [], - message: "Dependency installation failed. Run `pnpm install`.", - }); - - expect(addConnections.setupConnectionConnector).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/eve/src/setup/flows/connections.ts b/packages/eve/src/setup/flows/connections.ts deleted file mode 100644 index 70765329c..000000000 --- a/packages/eve/src/setup/flows/connections.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { - CONNECTION_CATALOG, - ensureConnectionDependencies, - listAuthoredConnections, -} from "#setup/scaffold/index.js"; -import { createPromptCommandOutput, withPhase } from "#setup/cli/index.js"; -import { detectPackageManager } from "#setup/package-manager.js"; -import { runPackageManagerInstall } from "#setup/primitives/pm/run.js"; -import { toErrorMessage } from "#shared/errors.js"; - -import { interactiveAsker } from "../ask.js"; -import { addConnections, type AddConnectionsDeps } from "../boxes/add-connections.js"; -import { selectConnections } from "../boxes/select-connections.js"; -import { - detectDeployment, - isProjectResolved, - projectResolutionFromDeployment, -} from "../project-resolution.js"; -import type { Prompter, SelectOption, SingleSelectOptions } from "../prompter.js"; -import { runInteractive, type AnySetupBox } from "../runner.js"; -import { snapshotSetupState, type SetupState } from "../state.js"; -import { WizardCancelledError } from "../step.js"; -import { - getVercelAuthStatus, - vercelAuthBlockerReason, - type VercelAuthStatus, -} from "../vercel-project.js"; -import { withSpinner } from "../with-spinner.js"; - -import { inProjectSetupState, prompterSink } from "./in-project.js"; -import { runLinkFlow } from "./link.js"; - -export const CONNECTIONS_PROMPT_MESSAGE = - "Select an MCP server to add to your agent through Vercel Connect"; - -const CONNECT_CONNECTIONS = CONNECTION_CATALOG.filter((entry) => entry.auth.kind === "connect"); - -export interface ConnectionsFlowDeps { - detectDeployment: typeof detectDeployment; - detectPackageManager: typeof detectPackageManager; - getVercelAuthStatus: typeof getVercelAuthStatus; - runLinkFlow: typeof runLinkFlow; - ensureConnectionDependencies: typeof ensureConnectionDependencies; - listAuthoredConnections: typeof listAuthoredConnections; - runPackageManagerInstall: typeof runPackageManagerInstall; - addConnections?: AddConnectionsDeps; -} - -export type ConnectionsFlowResult = - | { kind: "done"; addedConnections: readonly string[] } - | { kind: "cancelled" } - | { kind: "failed"; addedConnections: readonly string[]; message: string }; - -function connectionRows( - authored: ReadonlySet, - authStatus: VercelAuthStatus, - disabledConnectionReasons: Readonly>, -): SelectOption[] { - const blocker = vercelAuthBlockerReason(authStatus); - const rows: SelectOption[] = CONNECT_CONNECTIONS.map((entry) => { - const row = { value: entry.slug, label: entry.label }; - if (authored.has(entry.slug)) { - return { ...row, completed: true, focusHint: "Already added" }; - } - const disabledConnectionReason = disabledConnectionReasons[entry.slug]; - if (disabledConnectionReason !== undefined) { - return { - ...row, - disabled: true, - disabledReason: disabledConnectionReason, - disabledReasonTone: "warning", - }; - } - if (blocker !== undefined) { - return { - ...row, - disabled: true, - disabledReason: blocker, - disabledReasonTone: "warning", - }; - } - return { ...row, hint: entry.hint }; - }); - rows.push({ value: "done", label: "Done", trailingAction: true }); - return rows; -} - -async function pickConnection( - prompter: Prompter, - authored: ReadonlySet, - authStatus: VercelAuthStatus, - disabledConnectionReasons: Readonly>, -): Promise { - const options = connectionRows(authored, authStatus, disabledConnectionReasons); - const request: SingleSelectOptions = { - message: CONNECTIONS_PROMPT_MESSAGE, - options, - hintLayout: "inline", - search: true, - placeholder: "type to search MCP servers", - }; - if ( - !options.some( - (option) => option.value !== "done" && option.disabled !== true && option.completed !== true, - ) - ) { - request.initialValue = "done"; - } - try { - return await prompter.select(request); - } catch (error) { - if (error instanceof WizardCancelledError) return undefined; - throw error; - } -} - -/** Runs `/connect`, linking a project on first selection when needed. */ -export async function runConnectionsFlow(input: { - appRoot: string; - prompter: Prompter; - signal?: AbortSignal; - disabledConnectionReasons?: Readonly>; - deps?: Partial; -}): Promise { - const { appRoot, prompter, signal } = input; - const deps: ConnectionsFlowDeps = { - detectDeployment, - detectPackageManager, - ensureConnectionDependencies, - getVercelAuthStatus, - listAuthoredConnections, - runLinkFlow, - runPackageManagerInstall, - ...input.deps, - }; - const [deployment, initialAuthored, authStatus] = await withSpinner( - prompter, - "Checking the project…", - () => - Promise.all([ - deps.detectDeployment(appRoot, { signal }), - deps.listAuthoredConnections(appRoot), - deps.getVercelAuthStatus(appRoot, { signal }), - ]), - ); - signal?.throwIfAborted(); - - let state = inProjectSetupState(appRoot, projectResolutionFromDeployment(deployment)); - const authored = new Set(initialAuthored); - const selected = await pickConnection( - prompter, - authored, - authStatus, - input.disabledConnectionReasons ?? {}, - ); - if (selected === undefined) return { kind: "cancelled" }; - if (selected === "done" || authored.has(selected)) { - return { kind: "done", addedConnections: [] }; - } - - try { - if (!isProjectResolved(state.project)) { - const link = await deps.runLinkFlow({ - appRoot, - prompter, - signal, - projectSelection: "create-or-link", - teamSelectMessage: () => - "You need to link to a project to use Vercel Connect.\n\nSelect your team", - }); - if (link.kind === "cancelled") return { kind: "cancelled" }; - - const deploymentAfterLink = await withSpinner(prompter, "Checking the project…", () => - deps.detectDeployment(appRoot, { signal }), - ); - const project = projectResolutionFromDeployment(deploymentAfterLink); - if (!isProjectResolved(project)) throw new Error("Project link was not found after linking."); - state = { ...state, project }; - } - - const packageManager = await deps.detectPackageManager(appRoot); - await deps.ensureConnectionDependencies({ projectRoot: appRoot }); - const installed = await withPhase( - prompter.log, - `Installing connection dependencies (${packageManager.kind} install)...`, - () => - deps.runPackageManagerInstall(packageManager.kind, appRoot, { - onOutput: createPromptCommandOutput(prompter.log), - signal, - }), - ); - if (!installed) { - throw new Error(`Dependency installation failed. Run \`${packageManager.kind} install\`.`); - } - - const boxes: AnySetupBox[] = [ - selectConnections({ asker: interactiveAsker(prompter), presetConnections: [selected] }), - addConnections({ prompter, signal, deps: deps.addConnections }), - ]; - - const result = await runInteractive(boxes, state, prompterSink(prompter), { - snapshot: snapshotSetupState, - signal, - }); - if (result.kind !== "done") return { kind: "cancelled" }; - - const updatedAuthored = new Set(await deps.listAuthoredConnections(appRoot)); - if (!updatedAuthored.has(selected)) { - throw new Error(`Connection "${selected}" was not added.`); - } - return { kind: "done", addedConnections: [selected] }; - } catch (error) { - if (error instanceof WizardCancelledError) return { kind: "cancelled" }; - const updatedAuthored = new Set(await deps.listAuthoredConnections(appRoot)); - return { - kind: "failed", - addedConnections: updatedAuthored.has(selected) ? [selected] : [], - message: toErrorMessage(error), - }; - } -}