From 8c401f24855cbed55aefa8eeb1161e6b3795df1d Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Wed, 24 Jun 2026 22:05:24 -0400 Subject: [PATCH 01/11] feat(eve): add /connect setup flow Signed-off-by: Rui Conti --- .changeset/connect-command.md | 5 + docs/guides/dev-tui.md | 11 +- packages/eve-catalog/src/index.test.ts | 4 + packages/eve-catalog/src/index.ts | 2 +- .../src/cli/dev/tui/prompt-commands.test.ts | 7 + .../eve/src/cli/dev/tui/prompt-commands.ts | 9 + .../src/cli/dev/tui/setup-commands.test.ts | 54 +- .../eve/src/cli/dev/tui/setup-commands.ts | 24 + packages/eve/src/cli/dev/tui/setup-flow.ts | 5 +- .../eve/src/cli/dev/tui/setup-panel.test.ts | 8 +- packages/eve/src/cli/dev/tui/setup-panel.ts | 38 +- .../src/cli/dev/tui/terminal-renderer.test.ts | 15 + .../eve/src/cli/dev/tui/terminal-renderer.ts | 43 +- packages/eve/src/cli/dev/tui/tui-prompter.ts | 9 +- .../src/setup/boxes/add-connections.test.ts | 98 +++- .../eve/src/setup/boxes/add-connections.ts | 121 +++- .../src/setup/cli/channel-setup-prompter.ts | 10 +- packages/eve/src/setup/cli/index.ts | 1 + .../connection-connector.integration.test.ts | 464 +++++----------- .../eve/src/setup/connection-connector.ts | 515 +++++++++++------- .../eve/src/setup/flows/connections.test.ts | 205 +++++++ packages/eve/src/setup/flows/connections.ts | 229 ++++++++ .../scaffold/connections/catalog.test.ts | 14 + .../src/setup/scaffold/connections/catalog.ts | 11 + packages/eve/src/setup/scaffold/index.ts | 2 + .../src/setup/scaffold/update/connections.ts | 16 + 26 files changed, 1324 insertions(+), 596 deletions(-) create mode 100644 .changeset/connect-command.md create mode 100644 packages/eve/src/setup/flows/connections.test.ts create mode 100644 packages/eve/src/setup/flows/connections.ts diff --git a/.changeset/connect-command.md b/.changeset/connect-command.md new file mode 100644 index 000000000..342101b27 --- /dev/null +++ b/.changeset/connect-command.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add a searchable `/connect` flow to the local dev TUI. It scaffolds an MCP connection, tries the provider's canonical Vercel Connect connector first, and offers explicit Find or Create paths when attachment fails. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index e07663dd0..54c43cdb3 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -30,12 +30,13 @@ Errors render compactly with docs links highlighted. A code bug escaping your ag ## 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, and the Vercel CLI commands (`/vc:install`, `/vc:login`) use the same green square pulse as the build phase, while `/deploy` keeps a spinner. 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, 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. | Command | Does | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/model` | Opens a configure menu that loops until Done (or Esc). See [Configure the model and provider](#configure-the-model-and-provider). | | `/channels` | Shows the agent's channel list and adds the one you pick. See [Add a channel](#add-a-channel). | +| `/connect` | Shows the Vercel Connect MCP catalog and configures the server you pick. See [Add a connection](#add-a-connection). | | `/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. | | `/vc:login` | Logs in to Vercel locally. On a remote session, resolves the deployment's project, refreshes its OIDC token, and confirms any required Trusted Sources rule. | @@ -44,7 +45,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`, `/channels`, and `/deploy` manage the project and are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. +`/model`, `/channels`, `/connect`, 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 @@ -58,6 +59,12 @@ The provider row demands attention (a bold yellow "Configure model access" with `/channels` shows the agent's channel list. Already-registered channels render as checked, focusable rows with an "Already installed" hint. Picking one adds it (including the Slack Connect provisioning), then installs the dependencies the scaffold added so the dev server can load the new channels right away. After each addition the list repaints with the channel checked, until Done (or Esc) leaves the flow. +### Add a connection + +`/connect` shows a searchable list of MCP servers available through Vercel Connect. Already-authored connections remain checked. Vercel login stays in `/vc:login`, and team or project selection stays in `eve link`; unlinked or logged-out projects point to those commands instead of opening another selection flow. + +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. 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, and installs the new dependency so the dev server can load it. + ## Keyboard shortcuts Chat and freeform `ask_question` inputs behave like a shell line editor. diff --git a/packages/eve-catalog/src/index.test.ts b/packages/eve-catalog/src/index.test.ts index c3d4abab1..911bad193 100644 --- a/packages/eve-catalog/src/index.test.ts +++ b/packages/eve-catalog/src/index.test.ts @@ -44,4 +44,8 @@ describe("integration catalog", () => { ]); expect(connectionProtocols(getIntegrationEntry("linear")!.connection!)).toEqual(["mcp"]); }); + + it("uses Linear's current MCP endpoint", () => { + expect(getIntegrationEntry("linear")?.connection?.mcp?.url).toBe("https://mcp.linear.app/mcp"); + }); }); diff --git a/packages/eve-catalog/src/index.ts b/packages/eve-catalog/src/index.ts index 07682f226..1c51c74b7 100644 --- a/packages/eve-catalog/src/index.ts +++ b/packages/eve-catalog/src/index.ts @@ -151,7 +151,7 @@ export const INTEGRATIONS: readonly IntegrationEntry[] = [ surfaces: { scaffoldable: true, gallery: true }, connection: { description: "Linear workspace: issues, projects, cycles, and comments.", - mcp: { url: "https://mcp.linear.app/sse" }, + mcp: { url: "https://mcp.linear.app/mcp" }, }, }, { 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 e41553e80..0f2256517 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.test.ts @@ -50,6 +50,11 @@ describe("parsePromptCommand", () => { name: "channels", argument: "", }); + expect(parsePromptCommand("/connect")).toEqual({ + type: "extension", + name: "connect", + argument: "", + }); expect(parsePromptCommand("/deploy")).toEqual({ type: "extension", name: "deploy", @@ -94,6 +99,7 @@ describe("promptCommandsFor", () => { const names = promptCommandsFor("local").map((command) => command.name); expect(names).toContain("model"); expect(names).toContain("channels"); + expect(names).toContain("connect"); expect(names).toContain("deploy"); expect(names).toContain("vc:install"); expect(names).toContain("vc:login"); @@ -107,6 +113,7 @@ describe("promptCommandsFor", () => { expect(names).not.toContain("vc:auth"); expect(names).not.toContain("model"); expect(names).not.toContain("channels"); + expect(names).not.toContain("connect"); 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 0839d69f9..be7bac701 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.ts @@ -1,6 +1,7 @@ export type PromptCommandExtensionName = | "model" | "channels" + | "connect" | "deploy" | "vc:install" | "vc:login"; @@ -104,6 +105,14 @@ const PROMPT_COMMAND_DEFINITIONS = [ build: () => ({ type: "extension", name: "channels", argument: "" }), targets: ["local"], }, + { + name: "connect", + aliases: [], + description: "Add an MCP server through Vercel Connect", + takesArgument: false, + build: () => ({ type: "extension", name: "connect", argument: "" }), + targets: ["local"], + }, { name: "deploy", aliases: [], 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 32a684b8e..a4e98aa21 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.test.ts @@ -62,6 +62,10 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { kind: "done", addedChannels: [], })), + runConnectionsFlow: vi.fn(async () => ({ + kind: "done", + addedConnections: [], + })), runDeployFlow: vi.fn(async () => ({ kind: "deployed", productionUrl: "https://my-agent.vercel.app", @@ -71,7 +75,7 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { } function run(input: { - command: "vc:install" | "vc:login" | "model" | "channels" | "deploy"; + command: "vc:install" | "vc:login" | "model" | "channels" | "connect" | "deploy"; flows: TuiSetupFlows; renderer?: TuiSetupCommandRenderer; initialModelStep?: "provider"; @@ -101,6 +105,7 @@ describe("runTuiSetupCommand", () => { "vc:login": "pulse", model: "pulse", channels: "pulse", + connect: "pulse", deploy: "spinner", }); }); @@ -321,6 +326,53 @@ describe("runTuiSetupCommand", () => { }); }); + it("reports configured connections", async () => { + const flows = fakeFlows({ + runConnectionsFlow: vi.fn(async () => ({ + kind: "done", + addedConnections: ["linear", "notion"], + })), + }); + + await expect(run({ command: "connect", flows })).resolves.toEqual({ + message: "Connections added: linear, notion.", + preserveFlowDiagnostics: true, + }); + expect(flows.runConnectionsFlow).toHaveBeenCalledWith( + expect.objectContaining({ appRoot: APP_ROOT }), + ); + }); + + it("reports empty, cancelled, and partially failed connection flows", async () => { + await expect(run({ command: "connect", flows: fakeFlows() })).resolves.toEqual({ + message: "No connections added.", + preserveFlowDiagnostics: true, + }); + await expect( + run({ + command: "connect", + flows: fakeFlows({ + runConnectionsFlow: async () => ({ kind: "cancelled" }), + }), + }), + ).resolves.toEqual({ message: "/connect cancelled.", preserveFlowDiagnostics: true }); + await expect( + run({ + command: "connect", + flows: fakeFlows({ + runConnectionsFlow: async () => ({ + kind: "failed", + addedConnections: ["linear"], + message: "install failed", + }), + }), + }), + ).resolves.toEqual({ + message: "Connection files changed, but /connect failed: install failed", + preserveFlowDiagnostics: true, + }); + }); + it("keeps deploy pending when channel files landed before a sub-flow failure", async () => { const flows = fakeFlows({ runChannelsFlow: vi.fn(async () => ({ diff --git a/packages/eve/src/cli/dev/tui/setup-commands.ts b/packages/eve/src/cli/dev/tui/setup-commands.ts index c62c8d99f..6f3ae9d63 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.ts @@ -1,5 +1,6 @@ import { HumanActionRequiredError } from "#setup/human-action.js"; import { runChannelsFlow } from "#setup/flows/channels.js"; +import { runConnectionsFlow } from "#setup/flows/connections.js"; import { runDeployFlow } from "#setup/flows/deploy.js"; import { runInstallVercelCliFlow, @@ -30,6 +31,7 @@ export const SETUP_FLOW_CONFIG = { "vc:login": { title: "Log in to Vercel", indicator: "pulse" }, model: { title: "Configure the agent model", indicator: "pulse" }, channels: { title: "Agent channels", indicator: "pulse" }, + connect: { title: "Agent connections", indicator: "pulse" }, deploy: { title: "Deploy to Vercel", indicator: "spinner" }, } satisfies Record; @@ -59,6 +61,7 @@ export interface TuiSetupFlows { runLoginFlow: typeof runLoginFlow; runModelFlow: typeof runModelFlow; runChannelsFlow: typeof runChannelsFlow; + runConnectionsFlow: typeof runConnectionsFlow; runDeployFlow: typeof runDeployFlow; } @@ -158,6 +161,7 @@ async function executeSetupCommand( runLoginFlow, runModelFlow, runChannelsFlow, + runConnectionsFlow, runDeployFlow, ...input.flows, }; @@ -235,6 +239,26 @@ async function executeSetupCommand( }; } } + case "connect": { + const result = await flows.runConnectionsFlow({ appRoot, prompter, signal }); + switch (result.kind) { + case "cancelled": + return { message: "/connect cancelled.", preserveFlowDiagnostics: true }; + case "failed": + return { + message: `Connection files changed, but /connect failed: ${result.message}`, + preserveFlowDiagnostics: true, + }; + case "done": + return { + message: + result.addedConnections.length === 0 + ? "No connections added." + : `Connections added: ${result.addedConnections.join(", ")}.`, + preserveFlowDiagnostics: true, + }; + } + } case "deploy": { const result = await flows.runDeployFlow({ appRoot, prompter, interactive: true, signal }); if (result.kind === "cancelled") { diff --git a/packages/eve/src/cli/dev/tui/setup-flow.ts b/packages/eve/src/cli/dev/tui/setup-flow.ts index be8d93b33..2df002cfb 100644 --- a/packages/eve/src/cli/dev/tui/setup-flow.ts +++ b/packages/eve/src/cli/dev/tui/setup-flow.ts @@ -12,6 +12,9 @@ export type SetupEditableSelectResult = /** Animation shown while a setup flow is between questions. */ export type SetupFlowIndicator = "spinner" | "pulse"; +/** Ephemeral setup status, with external user action distinct from background work. */ +export type SetupFlowStatus = string | { kind: "external-action"; text: string; emphasis: string }; + interface SetupSelectRequestBase { message: string; options: readonly SetupPanelOption[]; @@ -92,7 +95,7 @@ export interface SetupFlowRenderer { * whichever settles first wins. */ readChoice(options: ChannelSetupChoiceOptions): ChannelSetupChoice; - setStatus(text: string | undefined): void; + setStatus(status: SetupFlowStatus | undefined): void; renderLine(text: string, tone: "info" | "success" | "warning" | "error"): void; renderOutput(text: string): void; /** diff --git a/packages/eve/src/cli/dev/tui/setup-panel.test.ts b/packages/eve/src/cli/dev/tui/setup-panel.test.ts index 671c4ccf2..85a5150be 100644 --- a/packages/eve/src/cli/dev/tui/setup-panel.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-panel.test.ts @@ -73,7 +73,11 @@ describe("renderFlowPanel", () => { lines: [], content: { kind: "status", - status: { text: "Loading teams…", indicator: { glyph: "⠼", color: "yellow" } }, + status: { + kind: "progress", + text: "Loading teams…", + indicator: { glyph: "⠼", color: "yellow" }, + }, }, }, theme, @@ -91,6 +95,7 @@ describe("renderFlowPanel", () => { content: { kind: "status", status: { + kind: "progress", text: "Checking the project…", indicator: { glyph: "▪", color: "green" }, }, @@ -112,6 +117,7 @@ describe("renderFlowPanel", () => { content: { kind: "question", status: { + kind: "progress", text: "Creating a Slackbot through Vercel Connect…", indicator: { glyph: "▪", color: "green" }, }, diff --git a/packages/eve/src/cli/dev/tui/setup-panel.ts b/packages/eve/src/cli/dev/tui/setup-panel.ts index d5fdd65bd..936bc4165 100644 --- a/packages/eve/src/cli/dev/tui/setup-panel.ts +++ b/packages/eve/src/cli/dev/tui/setup-panel.ts @@ -150,16 +150,26 @@ export interface FlowPanelIndicator { color: "green" | "yellow"; } +/** One live flow status after its animation frame and visual intent are resolved. */ +export type FlowPanelStatus = + | { kind: "progress"; text: string; indicator: FlowPanelIndicator } + | { + kind: "external-action"; + text: string; + emphasis: string; + indicator: FlowPanelIndicator; + }; + export type FlowPanelContent = | { kind: "question"; rows: readonly string[]; /** The install wait keeps its indicator above the concurrent actions. */ - status?: { text: string; indicator: FlowPanelIndicator }; + status?: FlowPanelStatus; } | { kind: "status"; - status: { text: string; indicator: FlowPanelIndicator }; + status: FlowPanelStatus; /** Latest child-process output shown transiently beneath the status. */ preview?: string; } @@ -223,6 +233,21 @@ function renderIndicator(indicator: FlowPanelIndicator, theme: Theme): string { : theme.colors.yellow(indicator.glyph); } +function renderStatusText(status: FlowPanelStatus, theme: Theme): string { + if (status.kind === "progress") return theme.colors.dim(status.text); + + const start = status.text.indexOf(status.emphasis); + if (start === -1) return theme.colors.dim(status.text); + const end = start + status.emphasis.length; + return `${theme.colors.dim(status.text.slice(0, start))}${theme.colors.yellow( + status.text.slice(start, end), + )}${theme.colors.dim(status.text.slice(end))}`; +} + +function renderFlowPanelStatus(status: FlowPanelStatus, theme: Theme): string { + return `${renderIndicator(status.indicator, theme)} ${renderStatusText(status, theme)}`; +} + /** * Paints the bordered flow panel. Everything a running command produces lives * here — progress, questions, the status indicator — and the panel vanishes @@ -250,17 +275,12 @@ export function renderFlowPanel(state: FlowPanelState, theme: Theme, width: numb case "question": // The install wait's question rides beneath its live status indicator. if (state.content.status !== undefined) { - rows.push( - ` ${renderIndicator(state.content.status.indicator, theme)} ${c.dim(state.content.status.text)}`, - "", - ); + rows.push(` ${renderFlowPanelStatus(state.content.status, theme)}`, ""); } rows.push(...state.content.rows); break; case "status": - rows.push( - ` ${renderIndicator(state.content.status.indicator, theme)} ${c.dim(state.content.status.text)}`, - ); + rows.push(` ${renderFlowPanelStatus(state.content.status, theme)}`); if (state.content.preview !== undefined) { rows.push(` ${c.dim(state.content.preview)}`); } diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index 4bfcf1668..772cc99ba 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -1939,6 +1939,21 @@ describe("TerminalRenderer setup flow session", () => { } }); + it("uses the attention color for an external-action pulse", () => { + const { screen, renderer } = makeRenderer(); + + renderer.setupFlow.begin("Agent connections", "pulse"); + renderer.setupFlow.setStatus({ + kind: "external-action", + text: "Waiting for you to complete setup in the browser…", + emphasis: "browser", + }); + + expect(screen.rawOutput()).toContain("\x1b[33m▪\x1b[39m"); + expect(screen.rawOutput()).toContain("\x1b[33mbrowser\x1b[39m"); + renderer.shutdown(); + }); + it("uses an ASCII fallback for pulse setup flows", () => { const screen = new MockScreen({ columns: 80, rows: 30 }); const input = new MockUserInput(); diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index b045f3900..1aee06c58 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -41,6 +41,7 @@ import { type FlowPanelContent, type FlowPanelIndicator, type FlowPanelLine, + type FlowPanelStatus, type SetupPanelOption, type SetupSelectPanelState, } from "./setup-panel.js"; @@ -48,6 +49,7 @@ import type { SetupEditableSelectResult, SetupFlowIndicator, SetupFlowRenderer, + SetupFlowStatus, SetupSelectRequest, } from "./setup-flow.js"; import type { SelectNotice } from "#setup/prompter.js"; @@ -177,6 +179,10 @@ function completedTurnStatus(interrupted: boolean, continueSession: boolean): st type SetupFlowIndicatorState = { kind: "spinner" } | { kind: "pulse"; startedAtMs: number }; +type SetupFlowStatusState = + | { kind: "progress"; text: string } + | { kind: "external-action"; text: string; emphasis: string }; + type TurnIndicatorState = | { kind: "idle" } | { kind: "waiting"; startedAtMs: number } @@ -186,7 +192,7 @@ type SetupFlowState = { title: string; indicator: SetupFlowIndicatorState; lines: FlowPanelLine[]; - status?: string; + status?: SetupFlowStatusState; /** Latest subprocess output line; replaced per write, never persisted. */ preview?: string; /** Recent subprocess output, flushed as context when a warning settles it. */ @@ -1381,7 +1387,7 @@ export class TerminalRenderer implements AgentTUIRenderer { ): ReturnType { this.#start(); const flow = this.#requireSetupFlow(); - flow.status = opts.status; + flow.status = { kind: "progress", text: stripTerminalControls(opts.status) }; // No action is pre-selected: the user must move into the action group before // Enter can act, rather than firing "Try again" by reflex. let cursor: number | undefined; @@ -1899,8 +1905,17 @@ export class TerminalRenderer implements AgentTUIRenderer { * status into the working indicator; `undefined` clears it. Nothing is ever * committed to the transcript. */ - #setFlowStatus(text: string | undefined): void { - const content = text === undefined ? undefined : stripTerminalControls(text); + #setFlowStatus(status: SetupFlowStatus | undefined): void { + const content: SetupFlowStatusState | undefined = + status === undefined + ? undefined + : typeof status === "string" + ? { kind: "progress", text: stripTerminalControls(status) } + : { + kind: "external-action", + text: stripTerminalControls(status.text), + emphasis: stripTerminalControls(status.emphasis), + }; if (this.#setupFlow !== undefined) { this.#setupFlow.status = content; if (content === undefined) this.#setupFlow.preview = undefined; @@ -1916,7 +1931,7 @@ export class TerminalRenderer implements AgentTUIRenderer { } this.#start(); this.#startWorking(); - this.#status = content; + this.#status = content.text; this.#paint(); } @@ -2673,7 +2688,7 @@ export class TerminalRenderer implements AgentTUIRenderer { return isProgressPulseVisible(Date.now() - startedAtMs) ? glyph : " "; } - #setupFlowIndicator(flow: SetupFlowState): FlowPanelIndicator { + #setupFlowIndicator(flow: SetupFlowState, status?: SetupFlowStatusState): FlowPanelIndicator { if (flow.indicator.kind === "spinner") { return { glyph: this.#spinnerFrame(), color: "yellow" }; } @@ -2682,7 +2697,7 @@ export class TerminalRenderer implements AgentTUIRenderer { flow.indicator.startedAtMs, this.#theme.unicode ? PROGRESS_PULSE_GLYPH : PROGRESS_PULSE_ASCII_GLYPH, ), - color: "green", + color: status?.kind === "external-action" ? "yellow" : "green", }; } @@ -2696,7 +2711,9 @@ export class TerminalRenderer implements AgentTUIRenderer { // very state the line shows (link, pending deploy, model), so mid-flow // values are guaranteed stale; it reappears, refreshed, when the // panel closes. - const indicator = this.#setupFlowIndicator(flow); + const indicator = this.#setupFlowIndicator(flow, flow.status); + const status: FlowPanelStatus | undefined = + flow.status === undefined ? undefined : { ...flow.status, indicator }; let content: FlowPanelContent; // A live status indicator rides alongside an open question only when one is // explicitly set (the install wait); ordinary questions leave it cleared, @@ -2704,15 +2721,15 @@ export class TerminalRenderer implements AgentTUIRenderer { if (flow.question !== undefined) { const rows = flow.question(width); content = { kind: "question", rows }; - if (flow.status !== undefined) { - content = { kind: "question", rows, status: { text: flow.status, indicator } }; + if (status !== undefined) { + content = { kind: "question", rows, status }; } - } else if (flow.status !== undefined) { - content = { kind: "status", status: { text: flow.status, indicator } }; + } else if (status !== undefined) { + content = { kind: "status", status }; if (flow.preview !== undefined) { content = { kind: "status", - status: { text: flow.status, indicator }, + status, preview: flow.preview, }; } diff --git a/packages/eve/src/cli/dev/tui/tui-prompter.ts b/packages/eve/src/cli/dev/tui/tui-prompter.ts index 8d1e789f2..fe0bdc5ca 100644 --- a/packages/eve/src/cli/dev/tui/tui-prompter.ts +++ b/packages/eve/src/cli/dev/tui/tui-prompter.ts @@ -9,6 +9,7 @@ import type { } from "#setup/prompter.js"; import { createSelectOptionCodec } from "#setup/cli/select-option-codec.js"; import { searchActionQuery } from "#setup/cli/select-state.js"; +import type { SetupSpinnerIntent } from "#setup/cli/index.js"; import { WizardCancelledError } from "#setup/step.js"; import type { SetupFlowPrompterRenderer, SetupSelectRequest } from "./setup-flow.js"; @@ -205,8 +206,12 @@ export function createTuiPrompter(renderer: TuiPrompterRenderer): Prompter { renderer.renderLine(title, "info"); for (const entry of lines) renderer.renderLine(` ${entry}`, "info"); }, - spinner(message) { - renderer.setStatus(message); + spinner(message, intent?: SetupSpinnerIntent) { + renderer.setStatus( + intent?.kind === "external-action" + ? { kind: "external-action", text: message, emphasis: intent.emphasis } + : message, + ); let stopped = false; return { stop() { diff --git a/packages/eve/src/setup/boxes/add-connections.test.ts b/packages/eve/src/setup/boxes/add-connections.test.ts index 76d8bc088..d945bb116 100644 --- a/packages/eve/src/setup/boxes/add-connections.test.ts +++ b/packages/eve/src/setup/boxes/add-connections.test.ts @@ -33,6 +33,7 @@ function makeBoxes( prompter: Prompter; headless?: boolean; deps?: AddConnectionsDeps; + beforeScaffold?: (projectRoot: string) => Promise; }, ): [ReturnType, ReturnType] { const headless = options.headless ?? false; @@ -42,7 +43,11 @@ function makeBoxes( asker: headless ? headlessAsker() : interactiveAsker(options.prompter), headless, }), - addConnections({ prompter: options.prompter, deps: options.deps }), + addConnections({ + prompter: options.prompter, + deps: options.deps, + beforeScaffold: options.beforeScaffold, + }), ]; } @@ -59,11 +64,14 @@ function createDeps() { envKeysAdded: [], envKeysRequired: [], })), + listAuthoredConnections: vi.fn(async () => []), setupConnectionConnector: vi.fn(async () => ({ - kind: "patched", - created: true, + kind: "existing", connectorUid: "oauth/connector-1", })), + cleanupCreatedConnectionConnector: vi.fn< + AddConnectionsDeps["cleanupCreatedConnectionConnector"] + >(async () => {}), }; } @@ -118,10 +126,16 @@ describe("selectConnections + addConnections boxes", () => { expect.objectContaining({ slug: "linear", service: "mcp.linear.app", - connectionFilePath: "/tmp/project/agent/connections/linear.ts", 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 () => { @@ -186,7 +200,7 @@ describe("selectConnections + addConnections boxes", () => { entry: expect.objectContaining({ slug: "mycorp", mcp: { url: "https://mcp.mycorp.dev/sse" }, - auth: { kind: "connect", connector: "mycorp" }, + auth: { kind: "connect", connector: "oauth/connector-1" }, }), }), ); @@ -272,6 +286,7 @@ describe("selectConnections + addConnections boxes", () => { test("skips provisioning when the connection file already exists", async () => { const deps = createDeps(); + deps.listAuthoredConnections.mockResolvedValueOnce(["linear"]); deps.ensureConnection.mockResolvedValueOnce({ slug: "linear", protocol: "mcp", @@ -294,6 +309,75 @@ describe("selectConnections + addConnections boxes", () => { ); }); + test("removes a connector created before a scaffold failure", async () => { + const deps = createDeps(); + deps.setupConnectionConnector.mockResolvedValueOnce({ + kind: "created", + connectorUid: "linear/new", + connectorId: "scl_new", + }); + deps.ensureConnection.mockRejectedValueOnce(new Error("write failed")); + const boxes = makeBoxes({ + prompter: createPrompter(), + presetConnections: ["linear"], + deps, + }); + + await expect(runInteractive(boxes, resolvedState(), silentSink, snapshot)).rejects.toThrow( + "write failed", + ); + expect(deps.cleanupCreatedConnectionConnector).toHaveBeenCalledWith( + expect.objectContaining({ connectorId: "scl_new" }), + ); + }); + + test("removes a created connector when pre-scaffold preparation fails", async () => { + const deps = createDeps(); + deps.setupConnectionConnector.mockResolvedValueOnce({ + kind: "created", + connectorUid: "linear/new", + connectorId: "scl_new", + }); + const boxes = makeBoxes({ + prompter: createPrompter(), + presetConnections: ["linear"], + deps, + beforeScaffold: async () => { + throw new Error("install failed"); + }, + }); + + await expect(runInteractive(boxes, resolvedState(), silentSink, snapshot)).rejects.toThrow( + "install failed", + ); + expect(deps.ensureConnection).not.toHaveBeenCalled(); + expect(deps.cleanupCreatedConnectionConnector).toHaveBeenCalledWith( + expect.objectContaining({ connectorId: "scl_new" }), + ); + }); + + test("reports both scaffold and connector cleanup failures", async () => { + const deps = createDeps(); + deps.setupConnectionConnector.mockResolvedValueOnce({ + kind: "created", + connectorUid: "linear/new", + connectorId: "scl_new", + }); + deps.ensureConnection.mockRejectedValueOnce(new Error("write failed")); + deps.cleanupCreatedConnectionConnector.mockRejectedValueOnce( + new Error("remove scl_new manually"), + ); + const boxes = makeBoxes({ + prompter: createPrompter(), + presetConnections: ["linear"], + deps, + }); + + await expect(runInteractive(boxes, resolvedState(), silentSink, snapshot)).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 }); @@ -332,7 +416,7 @@ describe("selectConnections + addConnections boxes", () => { const deps = createDeps(); deps.setupConnectionConnector.mockImplementation(async (opts) => { await opts.linkProject?.(); - return { kind: "patched", created: true, connectorUid: "oauth/linear-1" }; + return { kind: "existing", connectorUid: "oauth/linear-1" }; }); const boxes = makeBoxes({ prompter: createPrompter(), @@ -350,7 +434,7 @@ describe("selectConnections + addConnections boxes", () => { let linkedProjectId: string | undefined; deps.setupConnectionConnector.mockImplementation(async (opts) => { linkedProjectId = await opts.linkProject?.(); - return { kind: "patched", created: true, connectorUid: "oauth/linear-1" }; + return { kind: "existing", connectorUid: "oauth/linear-1" }; }); const boxes = makeBoxes({ prompter: createPrompter(), diff --git a/packages/eve/src/setup/boxes/add-connections.ts b/packages/eve/src/setup/boxes/add-connections.ts index 0426325eb..4c339c8a2 100644 --- a/packages/eve/src/setup/boxes/add-connections.ts +++ b/packages/eve/src/setup/boxes/add-connections.ts @@ -1,7 +1,16 @@ -import { ensureConnection, type ConnectionMutationResult } from "#setup/scaffold/index.js"; +import { + ensureConnection, + listAuthoredConnections, + type ConnectionInput, + type ConnectionMutationResult, +} from "#setup/scaffold/index.js"; import type { ChannelSetupLog } from "#setup/cli/index.js"; -import { setupConnectionConnector } from "../connection-connector.js"; +import { + cleanupCreatedConnectionConnector, + setupConnectionConnector, +} from "../connection-connector.js"; +import { canonicalConnectorUidForEntry } from "../scaffold/connections/catalog.js"; import { isProjectResolved, mergeProjectResolution, @@ -16,12 +25,17 @@ 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; setupConnectionConnector: typeof setupConnectionConnector; + cleanupCreatedConnectionConnector: typeof cleanupCreatedConnectionConnector; } export interface AddConnectionsOptions { - /** Carries the follow-up log lines and `perform`'s progress output. The box never prompts. */ + /** Carries connector selection prompts and provisioning output. */ prompter: Prompter; + /** Runs after connector selection but before the connection file is written. */ + beforeScaffold?: (projectRoot: string) => Promise; + signal?: AbortSignal; deps?: AddConnectionsDeps; } @@ -38,17 +52,27 @@ function logFollowUp(log: ChannelSetupLog, result: ConnectionMutationResult): vo } } +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. Prompts for nothing — - * every decision (slug, protocol, entry, provisioning mode) was resolved at - * selection time — and only runs effects: the file scaffold, the follow-up log - * lines, and the Connect connector provisioning against the linked project. + * 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, setupConnectionConnector }; + const deps = options.deps ?? { + ensureConnection, + listAuthoredConnections, + setupConnectionConnector, + cleanupCreatedConnectionConnector, + }; return { id: "add-connections", @@ -67,27 +91,37 @@ export function addConnections( 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) { - const result = await deps.ensureConnection({ - projectRoot, - slug: plan.slug, - protocol: plan.protocol, - entry: plan.entry, - }); - logFollowUp(log, result); - // Whether the file already existed is only known at effect time; an - // existing connection keeps its connector, so provisioning is skipped. - if (result.action === "skipped") continue; + 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": - await deps.setupConnectionConnector({ + case "connect": { + const canonicalConnectorUid = canonicalConnectorUidForEntry(plan.entry); + if (canonicalConnectorUid === undefined) { + throw new Error(`Connection ${plan.slug} has no canonical connector UID.`); + } + const connector = await deps.setupConnectionConnector({ log, + prompter: options.prompter, projectRoot, - slug: result.slug, + slug: plan.slug, service: plan.provision.service, - connectionFilePath: result.filePath, + canonicalConnectorUid, + signal: options.signal, // The project was linked up front by the link box; Connect // provisioning reuses it. The link box is a hard invariant once // Vercel is in play: an unresolved project here means it did not @@ -104,20 +138,59 @@ export function addConnections( return projectIdFromResolution(project); }, }); + 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 ${result.slug}\`, then set the connector UID in agent/connections/${result.slug}.ts.`, + `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 ${result.slug}. Create the connector manually and set its UID in agent/connections/${result.slug}.ts.`, + `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 { + await options.beforeScaffold?.(projectRoot); + 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; }, diff --git a/packages/eve/src/setup/cli/channel-setup-prompter.ts b/packages/eve/src/setup/cli/channel-setup-prompter.ts index e7c43e0d6..a31ce655c 100644 --- a/packages/eve/src/setup/cli/channel-setup-prompter.ts +++ b/packages/eve/src/setup/cli/channel-setup-prompter.ts @@ -28,6 +28,11 @@ export interface ChannelSetupChoice { /** Optional interaction capability for a long-running setup operation. */ export type ChannelSetupAwaitChoice = (options: ChannelSetupChoiceOptions) => ChannelSetupChoice; +/** Why a live setup indicator is waiting. */ +export type SetupSpinnerIntent = + | { kind: "progress" } + | { kind: "external-action"; emphasis: string }; + /** Status and subprocess output operations used by shared setup flows. */ export interface ChannelSetupLog { message(text: string): void; @@ -44,7 +49,7 @@ export interface ChannelSetupLog { * (which have no live status surface) can omit it and callers fall back to * a persisted message. */ - spinner?(message: string): { stop(): void }; + spinner?(message: string, intent?: SetupSpinnerIntent): { stop(): void }; } /** @@ -57,8 +62,9 @@ export async function withPhase( log: ChannelSetupLog, message: string, task: () => Promise, + intent?: SetupSpinnerIntent, ): Promise { - const spinner = log.spinner?.(message); + const spinner = log.spinner?.(message, intent); if (!spinner) log.message(message); try { return await task(); diff --git a/packages/eve/src/setup/cli/index.ts b/packages/eve/src/setup/cli/index.ts index 039efd4ee..88c384e59 100644 --- a/packages/eve/src/setup/cli/index.ts +++ b/packages/eve/src/setup/cli/index.ts @@ -6,6 +6,7 @@ export { type ChannelSetupChoiceOptions, type ChannelSetupLog, type DisabledChannelReasons, + type SetupSpinnerIntent, withPhase, } from "./channel-setup-prompter.js"; export { createPromptCommandOutput, type PromptCommandLog } from "./command-output.js"; diff --git a/packages/eve/src/setup/connection-connector.integration.test.ts b/packages/eve/src/setup/connection-connector.integration.test.ts index ae42f2a76..d65ca9bb1 100644 --- a/packages/eve/src/setup/connection-connector.integration.test.ts +++ b/packages/eve/src/setup/connection-connector.integration.test.ts @@ -1,15 +1,15 @@ -import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChannelSetupLog } from "#setup/cli/index.js"; +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import { captureVercel, runVercel, runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; import { + parseConnectors, parseCreatedConnector, - pickConnectConnector, setupConnectionConnector, } from "./connection-connector.js"; @@ -19,161 +19,48 @@ vi.mock("#setup/primitives/run-vercel.js", () => ({ runVercelCaptureStdout: vi.fn(), })); -const mockedCaptureVercel = vi.mocked(captureVercel); -const mockedRunVercel = vi.mocked(runVercel); -const mockedRunVercelCaptureStdout = vi.mocked(runVercelCaptureStdout); - +const capture = vi.mocked(captureVercel); +const run = vi.mocked(runVercel); +const create = vi.mocked(runVercelCaptureStdout); const SERVICE = "mcp.linear.app"; - -/** `vercel connect create … -F json` stdout payload on CLI 54.x. */ -function createConnectorJson(uid: string, id = "scl_linear"): string { - return JSON.stringify({ uid, id, type: "oauth", name: "linear" }); -} - -describe("parseCreatedConnector", () => { - test("reads uid and id from `vercel connect create -F json` stdout", () => { - expect(parseCreatedConnector(createConnectorJson("linear/my-agent", "scl_1"))).toEqual({ - uid: "linear/my-agent", +const CANONICAL_UID = "mcp.linear.app/linear"; + +describe("connector response parsing", () => { + it("parses terminal JSON and rejects created connectors without user support", () => { + const response = { uid: "linear/acme", id: "scl_1", supportedSubjectTypes: ["user"] }; + expect( + parseCreatedConnector(`Connector ready\n\u001B[32m${JSON.stringify(response)}\u001B[0m`), + ).toEqual({ + uid: "linear/acme", id: "scl_1", }); - }); - - test("tolerates surrounding whitespace", () => { - expect(parseCreatedConnector(`\n ${createConnectorJson("linear/x")} \n`)?.uid).toBe( - "linear/x", - ); - }); - - test("returns undefined for empty, non-JSON, or shape-mismatched stdout", () => { - expect(parseCreatedConnector("")).toBeUndefined(); - expect(parseCreatedConnector(" ")).toBeUndefined(); - expect(parseCreatedConnector("Vercel CLI 54.9.1")).toBeUndefined(); - expect(parseCreatedConnector(JSON.stringify({ uid: "linear/x" }))).toBeUndefined(); - expect(parseCreatedConnector(JSON.stringify({ id: "scl_1" }))).toBeUndefined(); - expect(parseCreatedConnector(JSON.stringify([1, 2, 3]))).toBeUndefined(); - }); -}); - -describe("pickConnectConnector", () => { - test("reads the `connectors` key emitted by `vercel connect list -F json`", () => { - const list = { - connectors: [{ uid: "linear/my-agent", id: "scl_1", type: "oauth", createdAt: 1 }], - }; - expect(pickConnectConnector(list, SERVICE, undefined)?.uid).toBe("linear/my-agent"); - }); - - test("prefers a connector attached to the project", () => { - const list = { - connectors: [ - { uid: "linear/a", id: "1", type: "oauth", createdAt: 1, projects: [] }, - { uid: "linear/b", id: "2", type: "oauth", createdAt: 2, projects: [{ id: "prj_1" }] }, - ], - }; - expect(pickConnectConnector(list, SERVICE, "prj_1")?.uid).toBe("linear/b"); - }); - - test("falls back to the newest connector when none are attached", () => { - const list = { - connectors: [ - { uid: "linear/a", id: "1", type: "oauth", createdAt: 1 }, - { uid: "linear/b", id: "2", type: "oauth", createdAt: 5 }, - ], - }; - expect(pickConnectConnector(list, SERVICE, undefined)?.uid).toBe("linear/b"); - }); - - test("accepts connectors whose type is not `oauth` (managed MCP connectors)", () => { - const list = { connectors: [{ uid: "linear/mcp", id: "1", type: "mcp", createdAt: 1 }] }; - expect(pickConnectConnector(list, SERVICE, undefined)?.uid).toBe("linear/mcp"); - }); - - test("defensively skips connectors whose reported service differs", () => { - const list = { - connectors: [ - { uid: "notion/x", id: "1", type: "oauth", service: "mcp.notion.com", createdAt: 8 }, - { uid: "linear/x", id: "2", type: "oauth", service: SERVICE, createdAt: 1 }, - ], - }; - expect(pickConnectConnector(list, SERVICE, undefined)?.uid).toBe("linear/x"); - }); - - test("falls back to the legacy `clients` key for older CLI builds", () => { - const list = { clients: [{ uid: "linear/legacy", id: "1", type: "oauth", createdAt: 1 }] }; - expect(pickConnectConnector(list, SERVICE, undefined)?.uid).toBe("linear/legacy"); - }); - - test("returns undefined for malformed input", () => { - expect(pickConnectConnector(null, SERVICE, undefined)).toBeUndefined(); - expect(pickConnectConnector({}, SERVICE, undefined)).toBeUndefined(); - expect(pickConnectConnector({ connectors: "nope" }, SERVICE, undefined)).toBeUndefined(); + expect( + parseCreatedConnector(JSON.stringify({ ...response, supportedSubjectTypes: ["app"] })), + ).toBeUndefined(); + expect( + parseConnectors( + { + connectors: [ + { uid: "linear/acme", id: "scl_1", name: "acme", service: SERVICE }, + { uid: "notion/acme", id: "scl_2", service: "mcp.notion.com" }, + ], + }, + SERVICE, + ), + ).toEqual([{ uid: "linear/acme", id: "scl_1", name: "acme" }]); }); }); -function createTestLog(): ChannelSetupLog { - return { - message: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warning: vi.fn(), - error: vi.fn(), - commandOutput: vi.fn(), - }; -} - -/** Connector list payload as emitted by `vercel connect list -F json` on CLI 54.x. */ -function connectListV54(connectorUid: string, projectId: string) { - return JSON.stringify({ - connectors: [ - { - uid: connectorUid, - id: "scl_linear", - name: "linear", - type: "oauth", - typeName: "Linear", - createdAt: 2, - icon: null, - backgroundColor: null, - accentColor: null, - projects: [{ id: projectId, name: "my-agent" }], - hasMoreProjects: false, - }, - ], - cursor: undefined, - }); -} - -describe("setupConnectionConnector (end-to-end)", () => { +describe("setupConnectionConnector", () => { let projectRoot: string; - let connectionFilePath: string; - const PROJECT_ID = "prj_ExampleProjectId0000000000"; beforeEach(async () => { vi.clearAllMocks(); - // The connector attach succeeds by default; failure is exercised separately. - mockedRunVercel.mockResolvedValue(true); - projectRoot = await mkdtemp(join(tmpdir(), "eve-setup-connection-")); + projectRoot = await mkdtemp(join(tmpdir(), "eve-connect-")); await mkdir(join(projectRoot, ".vercel"), { recursive: true }); await writeFile( join(projectRoot, ".vercel", "project.json"), - JSON.stringify({ projectId: PROJECT_ID, orgId: "team_x" }), - "utf8", - ); - await mkdir(join(projectRoot, "agent", "connections"), { recursive: true }); - connectionFilePath = join(projectRoot, "agent", "connections", "linear.ts"); - // Mirrors the scaffolder's emitted `auth: connect("")` placeholder. - await writeFile( - connectionFilePath, - [ - 'import { defineMcpClientConnection } from "eve/connections";', - 'import { connect } from "@vercel/connect";', - "", - "export default defineMcpClientConnection({", - ' url: "https://mcp.linear.app/sse",', - ' auth: connect("linear"),', - "});", - "", - ].join("\n"), - "utf8", + JSON.stringify({ projectId: "prj_1", orgId: "org_1" }), ); }); @@ -181,212 +68,127 @@ describe("setupConnectionConnector (end-to-end)", () => { await rm(projectRoot, { recursive: true, force: true }); }); - test("resolves the UID from `connect create -F json` without a follow-up list", async () => { - mockedRunVercelCaptureStdout.mockResolvedValue({ - ok: true, - stdout: createConnectorJson("linear/my-agent"), - }); - - const result = await setupConnectionConnector({ - log: createTestLog(), - projectRoot, - slug: "linear", - service: SERVICE, - connectionFilePath, - }); - - expect(result).toEqual({ kind: "patched", created: true, connectorUid: "linear/my-agent" }); - - // Created with the catalog service identifier, slug name, and JSON output. - expect(mockedRunVercelCaptureStdout).toHaveBeenCalledWith( - ["connect", "create", SERVICE, "--name", "linear", "-F", "json"], - expect.objectContaining({ cwd: projectRoot }), - ); - // The authoritative create payload makes the flaky list lookup unnecessary. - expect(mockedCaptureVercel).not.toHaveBeenCalled(); - // The connector is attached to the linked project so the agent can call it. - expect(mockedRunVercel).toHaveBeenCalledWith( - ["connect", "attach", "linear/my-agent", "--yes"], - expect.objectContaining({ cwd: projectRoot }), - ); - // The generated connect("…") UID was rewritten on disk. - const patched = await readFile(connectionFilePath, "utf8"); - expect(patched).toContain('connect("linear/my-agent")'); - expect(patched).not.toContain('connect("linear")'); - }); - - test("warns but still patches when attaching the connector to the project fails", async () => { - mockedRunVercelCaptureStdout.mockResolvedValue({ - ok: true, - stdout: createConnectorJson("linear/my-agent"), - }); - mockedRunVercel.mockResolvedValue(false); - const log = createTestLog(); - - const result = await setupConnectionConnector({ - log, - projectRoot, - slug: "linear", - service: SERVICE, - connectionFilePath, - }); - - // Attach failure is non-fatal: the connector exists and the file is patched. - expect(result).toEqual({ kind: "patched", created: true, connectorUid: "linear/my-agent" }); - expect(log.warning).toHaveBeenCalledWith( - expect.stringContaining("could not attach it to this project"), - ); - expect(await readFile(connectionFilePath, "utf8")).toContain('connect("linear/my-agent")'); - }); - - test("falls back to a service-scoped list when create emits no parseable JSON", async () => { - mockedRunVercelCaptureStdout.mockResolvedValue({ ok: true, stdout: "Vercel CLI 54.9.1\n" }); - mockedCaptureVercel.mockResolvedValue({ - ok: true, - stdout: connectListV54("linear/my-agent", PROJECT_ID), - }); - - const result = await setupConnectionConnector({ - log: createTestLog(), + function options(prompter = createFakePrompter().prompter) { + return { + log: prompter.log, + prompter, projectRoot, slug: "linear", service: SERVICE, - connectionFilePath, - }); - - expect(result).toEqual({ kind: "patched", created: true, connectorUid: "linear/my-agent" }); - expect(mockedCaptureVercel).toHaveBeenCalledWith( - ["connect", "list", "-F", "json", "--all-projects", "--service", SERVICE], - expect.objectContaining({ cwd: projectRoot }), + canonicalConnectorUid: CANONICAL_UID, + linkProject: async () => "prj_1", + }; + } + + it("attaches the canonical connector without listing or creating", async () => { + run.mockResolvedValue(true); + + await expect(setupConnectionConnector(options())).resolves.toEqual({ + kind: "existing", + connectorUid: CANONICAL_UID, + }); + expect(capture).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); + + it("paginates and offers only existing connectors that support user authorization", async () => { + run.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + capture + .mockResolvedValueOnce({ + ok: true, + stdout: JSON.stringify({ + connectors: [{ uid: "linear/app", id: "scl_app" }], + cursor: "next_page", + }), + }) + .mockResolvedValueOnce({ + ok: true, + stdout: JSON.stringify({ connectors: [{ uid: "linear/user", id: "scl_user" }] }), + }) + .mockResolvedValueOnce({ + ok: true, + stdout: JSON.stringify({ + uid: "linear/app", + id: "scl_app", + service: SERVICE, + supportedSubjectTypes: ["app"], + }), + }) + .mockResolvedValueOnce({ + ok: true, + stdout: JSON.stringify({ + uid: "linear/user", + id: "scl_user", + service: SERVICE, + supportedSubjectTypes: ["user"], + }), + }); + const answers = ["find", "linear/user"]; + const fake = createFakePrompter({ single: () => answers.shift()! }); + + await expect(setupConnectionConnector(options(fake.prompter))).resolves.toEqual({ + kind: "existing", + connectorUid: "linear/user", + }); + expect(capture).toHaveBeenCalledWith( + expect.arrayContaining(["--next", "next_page"]), + expect.any(Object), ); - expect(await readFile(connectionFilePath, "utf8")).toContain('connect("linear/my-agent")'); + expect(fake.selectMessages).toEqual([ + "Which connector should linear use?", + "Select a connector for linear", + ]); }); - test("fallback still resolves the legacy `clients` key from older CLI builds", async () => { - mockedRunVercelCaptureStdout.mockResolvedValue({ ok: true, stdout: "" }); - mockedCaptureVercel.mockResolvedValue({ + it("removes a created connector when attach fails", async () => { + run.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + capture.mockResolvedValue({ ok: true, stdout: JSON.stringify({ - clients: [ - { uid: "linear/legacy", id: "scl_legacy", type: "oauth", createdAt: 1, projects: [] }, - ], + connectors: [{ uid: CANONICAL_UID, id: "scl_existing", name: "Linear" }], }), }); - - const result = await setupConnectionConnector({ - log: createTestLog(), - projectRoot, - slug: "linear", - service: SERVICE, - connectionFilePath, - }); - - expect(result).toEqual({ kind: "patched", created: true, connectorUid: "linear/legacy" }); - expect(await readFile(connectionFilePath, "utf8")).toContain('connect("linear/legacy")'); - }); - - test("reports create-failed and leaves the file untouched when create fails", async () => { - mockedRunVercelCaptureStdout.mockResolvedValue({ ok: false, stdout: "" }); - - const result = await setupConnectionConnector({ - log: createTestLog(), - projectRoot, - slug: "linear", - service: SERVICE, - connectionFilePath, - }); - - expect(result).toEqual({ kind: "create-failed", created: false }); - expect(mockedCaptureVercel).not.toHaveBeenCalled(); - expect(await readFile(connectionFilePath, "utf8")).toContain('connect("linear")'); - }); - - test("links a Vercel project when none is linked yet, then attaches", async () => { - // Start unlinked: the gateway step used an API key/local provider, or this - // is a fresh `eve connections add` checkout. - await rm(join(projectRoot, ".vercel", "project.json"), { force: true }); - mockedRunVercelCaptureStdout.mockResolvedValue({ + create.mockResolvedValue({ ok: true, - stdout: createConnectorJson("linear/my-agent"), + stdout: JSON.stringify({ + uid: "linear/linear-2", + id: "scl_created", + supportedSubjectTypes: ["user"], + }), }); - // `vercel link` writes `.vercel/project.json`; everything else succeeds. - mockedRunVercel.mockImplementation(async (args) => { - if (args[0] === "link") { - await writeFile( - join(projectRoot, ".vercel", "project.json"), - JSON.stringify({ projectId: PROJECT_ID, orgId: "team_x" }), - "utf8", - ); - } - return true; + const fake = createFakePrompter({ + single: () => "create", + text: (input) => input.defaultValue!, }); - const log = createTestLog(); - const result = await setupConnectionConnector({ - log, - projectRoot, - slug: "linear", - service: SERVICE, - connectionFilePath, - }); - - expect(result).toEqual({ kind: "patched", created: true, connectorUid: "linear/my-agent" }); - // Linked first, then attached to the now-linked project. - expect(mockedRunVercel).toHaveBeenCalledWith( - ["link"], - expect.objectContaining({ cwd: projectRoot }), + await expect(setupConnectionConnector(options(fake.prompter))).rejects.toThrow( + "Could not attach linear/linear-2", ); - expect(mockedRunVercel).toHaveBeenCalledWith( - ["connect", "attach", "linear/my-agent", "--yes"], - expect.objectContaining({ cwd: projectRoot }), + expect(create).toHaveBeenCalledWith( + ["connect", "create", SERVICE, "--name", "linear-2", "-F", "json"], + expect.any(Object), + ); + expect(run).toHaveBeenLastCalledWith( + ["connect", "remove", "scl_created", "--disconnect-all", "--yes"], + expect.any(Object), ); - expect(await readFile(connectionFilePath, "utf8")).toContain('connect("linear/my-agent")'); }); - test("creates the connector but skips attach when linking does not complete", async () => { - await rm(join(projectRoot, ".vercel", "project.json"), { force: true }); - mockedRunVercelCaptureStdout.mockResolvedValue({ - ok: true, - stdout: createConnectorJson("linear/my-agent"), + it("recovers a partially created connector id from CLI progress and removes it", async () => { + run.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + capture.mockResolvedValue({ ok: true, stdout: JSON.stringify({ connectors: [] }) }); + create.mockImplementation(async (_args, createOptions) => { + createOptions.onOutput?.({ stream: "stderr", text: "Connector created: scl_partial" }); + return { ok: false, stdout: "" }; }); - // `vercel link` is declined/fails, so no project.json is written. - mockedRunVercel.mockResolvedValue(false); - mockedCaptureVercel.mockResolvedValue({ ok: true, stdout: JSON.stringify({ connectors: [] }) }); - const log = createTestLog(); + const fake = createFakePrompter({ single: () => "create", text: () => "acme" }); - const result = await setupConnectionConnector({ - log, - projectRoot, - slug: "linear", - service: SERVICE, - connectionFilePath, - }); - - // The connector still exists and the file is patched; attach is skipped. - expect(result).toEqual({ kind: "patched", created: true, connectorUid: "linear/my-agent" }); - expect(mockedRunVercel).not.toHaveBeenCalledWith( - ["connect", "attach", "linear/my-agent", "--yes"], - expect.anything(), + await expect(setupConnectionConnector(options(fake.prompter))).rejects.toThrow( + `Could not create the ${SERVICE} connector`, ); - expect(log.warning).toHaveBeenCalledWith( - expect.stringContaining("no Vercel project is linked"), + expect(run).toHaveBeenLastCalledWith( + ["connect", "remove", "scl_partial", "--disconnect-all", "--yes"], + expect.any(Object), ); - expect(await readFile(connectionFilePath, "utf8")).toContain('connect("linear/my-agent")'); - }); - - test("reports connector-unresolved when neither create nor the list yield a UID", async () => { - mockedRunVercelCaptureStdout.mockResolvedValue({ ok: true, stdout: "" }); - mockedCaptureVercel.mockResolvedValue({ ok: true, stdout: JSON.stringify({ connectors: [] }) }); - - const result = await setupConnectionConnector({ - log: createTestLog(), - projectRoot, - slug: "linear", - service: SERVICE, - connectionFilePath, - }); - - expect(result).toEqual({ kind: "connector-unresolved", created: true }); - expect(await readFile(connectionFilePath, "utf8")).toContain('connect("linear")'); }); }); diff --git a/packages/eve/src/setup/connection-connector.ts b/packages/eve/src/setup/connection-connector.ts index 0cd0eee29..dd7efa188 100644 --- a/packages/eve/src/setup/connection-connector.ts +++ b/packages/eve/src/setup/connection-connector.ts @@ -1,252 +1,363 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { stripVTControlCharacters } from "node:util"; -import { createPromptCommandOutput, type ChannelSetupLog } from "#setup/cli/index.js"; +import { createPromptCommandOutput, type ChannelSetupLog, withPhase } from "#setup/cli/index.js"; +import type { ProcessOutputHandler } from "#setup/primitives/process-output.js"; +import type { Prompter } from "#setup/prompter.js"; import { captureVercel, runVercel, runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; -import { updateConnectionConnectorUid } from "#setup/scaffold/update/update-connection-connector.js"; -/** Controls connector provisioning while adding a Connect-backed connection. */ +/** Controls connector selection while adding a Connect-backed connection. */ export interface SetupConnectionConnectorOptions { - /** Status and command output stream through this log (rail styling preserved). */ log: ChannelSetupLog; + prompter: Prompter; projectRoot: string; - /** Connection slug; also the connector `--name`. */ slug: string; - /** `vercel connect create ` identifier (e.g. `mcp.linear.app`). */ service: string; - /** Generated `agent/connections/.ts` whose `connect("…")` is patched. */ - connectionFilePath: string; - /** - * Links a Vercel project before Connect provisioning when the caller owns a - * richer linking flow (e.g. shared team selection). Returns the linked - * project id, or `undefined` when linking did not complete. When omitted, - * falls back to a bare `vercel link`. - */ - linkProject?: () => Promise; -} - -/** Outcome of the Connect create-and-patch sequence for a connection. */ + canonicalConnectorUid: string; + signal?: AbortSignal; + linkProject: () => Promise; +} + +/** Connector identity returned by the Vercel CLI. */ +export interface ConnectConnectorRef { + uid: string; + id: string; + name?: string; +} + export type SetupConnectionConnectorResult = - | { kind: "create-failed"; created: false } - | { kind: "connector-unresolved"; created: true } - | { kind: "patch-failed"; created: true; connectorUid: string } - | { kind: "patched"; created: true; connectorUid: string }; + | { kind: "existing"; connectorUid: string } + | { kind: "created"; connectorUid: string; connectorId: string }; -interface VercelConnectListClient { - uid?: unknown; - id?: unknown; - type?: unknown; - service?: unknown; - createdAt?: unknown; - projects?: unknown; +interface ProjectLink { + projectId: string; + orgId: string; } -interface VercelConnectListResponse { - /** `vercel connect list -F json` (current CLI). */ - connectors?: unknown; - /** Older CLI builds emitted the same array under `clients`. */ - clients?: unknown; +type ConnectorResolution = + | { kind: "existing"; connector: ConnectConnectorRef } + | { kind: "created"; connector: ConnectConnectorRef }; + +const CREATED_CONNECTOR = /\bConnector created:\s*(scl_[A-Za-z0-9_-]+)\b/u; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } -/** Identifiers returned by Vercel Connect for an OAuth connector. */ -export interface ConnectConnectorRef { - uid: string; - id: string; +function parseTerminalJsonObject(source: string): unknown { + const clean = stripVTControlCharacters(source).trim(); + let start = clean.lastIndexOf("{"); + while (start >= 0) { + try { + return JSON.parse(clean.slice(start)); + } catch { + start = clean.lastIndexOf("{", start - 1); + } + } + return undefined; } -/** - * Reads the connector identifiers from `vercel connect create … -F json` - * stdout. This is the authoritative source for the just-created connector's - * UID — it avoids a follow-up `connect list`, which can momentarily 404/rate - * limit right after creation and cannot disambiguate when a service already - * has multiple connectors. Returns `undefined` when stdout is empty or not the - * expected JSON (e.g. an older CLI without `-F json` support on `create`). - */ -export function parseCreatedConnector(stdout: string): ConnectConnectorRef | undefined { - const trimmed = stdout.trim(); - if (!trimmed) return undefined; - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { +function parseConnectorRef(value: unknown): ConnectConnectorRef | undefined { + if (!isRecord(value) || typeof value["uid"] !== "string" || typeof value["id"] !== "string") { return undefined; } - if (typeof parsed !== "object" || parsed === null) return undefined; - const { uid, id } = parsed as { uid?: unknown; id?: unknown }; - if (typeof uid !== "string" || typeof id !== "string") return undefined; - return { uid, id }; -} - -function attachedToProject(raw: VercelConnectListClient, projectId: string | undefined): boolean { - if (projectId === undefined) return false; - if (!Array.isArray(raw.projects)) return false; - return raw.projects.some( - (project) => - typeof project === "object" && - project !== null && - (project as { id?: unknown }).id === projectId, - ); + const connector: ConnectConnectorRef = { uid: value["uid"], id: value["id"] }; + if (typeof value["name"] === "string") connector.name = value["name"]; + return connector; } -/** - * Finds the connector to wire into the generated connection. The list is - * expected to be scoped to the requested service server-side (via - * `--service`), since `vercel connect list -F json` does not include a - * `service` field per connector. Prefers, in order: the connector already - * attached to this project, then the newest connector. When Connect does - * report a `service` field, mismatches are still skipped defensively. - */ -export function pickConnectConnector( - listJson: unknown, - service: string, - projectId: string | undefined, -): ConnectConnectorRef | undefined { - if (typeof listJson !== "object" || listJson === null) return undefined; - const response = listJson as VercelConnectListResponse; - const connectors = response.connectors ?? response.clients; - if (!Array.isArray(connectors)) return undefined; - - let attached: { ref: ConnectConnectorRef; createdAt: number } | undefined; - let newest: { ref: ConnectConnectorRef; createdAt: number } | undefined; - - for (const raw of connectors as VercelConnectListClient[]) { - if (typeof raw.service === "string" && raw.service !== service) continue; - if (typeof raw.uid !== "string" || typeof raw.id !== "string") continue; - - const ref: ConnectConnectorRef = { uid: raw.uid, id: raw.id }; - const createdAt = typeof raw.createdAt === "number" ? raw.createdAt : 0; - - if (!newest || createdAt > newest.createdAt) { - newest = { ref, createdAt }; - } - if (attachedToProject(raw, projectId) && (!attached || createdAt > attached.createdAt)) { - attached = { ref, createdAt }; - } - } +/** Parses a created connector that can issue user credentials. */ +export function parseCreatedConnector(stdout: string): ConnectConnectorRef | undefined { + const value = parseTerminalJsonObject(stdout); + const connector = parseConnectorRef(value); + if (!isRecord(value) || connector === undefined) return undefined; + const subjects = value["supportedSubjectTypes"]; + return Array.isArray(subjects) && subjects.includes("user") ? connector : undefined; +} + +/** Parses the service-scoped connector inventory returned by the Vercel CLI. */ +export function parseConnectors(value: unknown, service: string): ConnectConnectorRef[] { + if (!isRecord(value)) return []; + const candidates = value["connectors"] ?? value["clients"]; + if (!Array.isArray(candidates)) return []; - return (attached ?? newest)?.ref; + const connectors: ConnectConnectorRef[] = []; + for (const candidate of candidates) { + if (!isRecord(candidate)) continue; + if (typeof candidate["service"] === "string" && candidate["service"] !== service) continue; + const connector = parseConnectorRef(candidate); + if (connector !== undefined) connectors.push(connector); + } + return connectors; } -async function readProjectId(projectRoot: string): Promise { +async function readProjectLink(projectRoot: string): Promise { try { - const raw = await readFile(join(projectRoot, ".vercel", "project.json"), "utf8"); - const parsed = JSON.parse(raw) as { projectId?: unknown }; - return typeof parsed.projectId === "string" ? parsed.projectId : undefined; + const value: unknown = JSON.parse( + await readFile(join(projectRoot, ".vercel", "project.json"), "utf8"), + ); + return isRecord(value) && + typeof value["projectId"] === "string" && + typeof value["orgId"] === "string" + ? { projectId: value["projectId"], orgId: value["orgId"] } + : undefined; } catch { return undefined; } } -/** - * Connect attach requires a linked Vercel project. The connection step can run - * before one exists — the gateway step used an API key or a local provider, or - * `eve connections add` ran in a fresh checkout — so link one first. Returns the - * resolved project id, or `undefined` when linking did not complete. - */ -async function ensureLinkedProject( - log: ChannelSetupLog, - projectRoot: string, - onOutput: ReturnType, -): Promise { - const existing = await readProjectId(projectRoot); - if (existing) return existing; - log.message("Linking a Vercel project for Connect..."); - await runVercel(["link"], { cwd: projectRoot, onOutput }); - return readProjectId(projectRoot); -} - -async function findConnector( - projectRoot: string, - service: string, - projectId: string | undefined, - onOutput: ReturnType, -): Promise { - const result = await captureVercel( - ["connect", "list", "-F", "json", "--all-projects", "--service", service], - { - cwd: projectRoot, - onOutput, - }, - ); - if (!result.ok) return undefined; - try { - return pickConnectConnector(JSON.parse(result.stdout), service, projectId); - } catch { - return undefined; +async function ensureLinkedProject(options: SetupConnectionConnectorOptions): Promise { + const expectedProjectId = await options.linkProject(); + const project = await readProjectLink(options.projectRoot); + if (project === undefined || project.projectId !== expectedProjectId) { + throw new Error("A linked Vercel project is required. Run `eve link`, then retry /connect."); } + return project; } -/** - * Creates a Vercel Connect OAuth connector for a connection and rewrites the - * generated `connect("…")` call to the connector UID Connect assigns. The - * `vercel connect create` step is interactive (it opens a browser to complete - * the OAuth grant); callers should only invoke this from an interactive flow. - */ -export async function setupConnectionConnector( +async function listConnectors( options: SetupConnectionConnectorOptions, -): Promise { - const { log, projectRoot, slug, service, connectionFilePath } = options; - const onOutput = createPromptCommandOutput(log); + onOutput: ProcessOutputHandler, +): Promise { + const connectors: ConnectConnectorRef[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + do { + const args = ["connect", "list", "-F", "json", "--all-projects", "--service", options.service]; + if (cursor !== undefined) args.push("--next", cursor); + const result = await captureVercel(args, { + cwd: options.projectRoot, + onOutput, + signal: options.signal, + }); + if (!result.ok) throw new Error(result.failure.message); + const page = parseTerminalJsonObject(result.stdout); + if (!isRecord(page) || !Array.isArray(page["connectors"] ?? page["clients"])) { + throw new Error(`Vercel returned an invalid connector list for ${options.service}.`); + } + connectors.push(...parseConnectors(page, options.service)); + const next = typeof page["cursor"] === "string" ? page["cursor"] : undefined; + if (next !== undefined && seenCursors.has(next)) { + throw new Error(`The connector list repeated cursor ${next}.`); + } + if (next !== undefined) seenCursors.add(next); + cursor = next; + } while (cursor !== undefined); + return connectors; +} - const projectId = options.linkProject - ? await options.linkProject() - : await ensureLinkedProject(log, projectRoot, onOutput); +async function supportsUserAuthorization( + options: SetupConnectionConnectorOptions, + project: ProjectLink, + connector: ConnectConnectorRef, + onOutput: ProcessOutputHandler, +): Promise { + const endpoint = `/v1/connect/connectors/${encodeURIComponent(connector.id)}`; + const result = await captureVercel(["api", endpoint, "--scope", project.orgId, "--raw"], { + cwd: options.projectRoot, + onOutput, + signal: options.signal, + }); + if (!result.ok) throw new Error(`Could not verify connector ${connector.uid}.`); + const value = parseTerminalJsonObject(result.stdout); + if ( + !isRecord(value) || + value["id"] !== connector.id || + value["uid"] !== connector.uid || + (typeof value["service"] === "string" && value["service"] !== options.service) + ) { + throw new Error(`Vercel returned invalid details for connector ${connector.uid}.`); + } + const subjects = value["supportedSubjectTypes"]; + return Array.isArray(subjects) && subjects.includes("user"); +} + +function connectorNames(connectors: readonly ConnectConnectorRef[]): Set { + const names = new Set(); + for (const connector of connectors) { + if (connector.name !== undefined) names.add(connector.name.toLowerCase()); + const uidName = connector.uid.slice(connector.uid.lastIndexOf("/") + 1).trim(); + if (uidName.length > 0) names.add(uidName.toLowerCase()); + } + return names; +} + +function nextConnectorName(slug: string, names: ReadonlySet): string { + if (!names.has(slug.toLowerCase())) return slug; + let suffix = 2; + while (names.has(`${slug}-${suffix}`.toLowerCase())) suffix += 1; + return `${slug}-${suffix}`; +} - log.message(`Connecting ${slug} via Vercel Connect...`); - const create = await runVercelCaptureStdout( - ["connect", "create", service, "--name", slug, "-F", "json"], - { cwd: projectRoot, onOutput }, +/** Removes a connector created by this setup attempt. */ +export async function cleanupCreatedConnectionConnector(input: { + log: ChannelSetupLog; + projectRoot: string; + connectorId: string; +}): Promise { + const removed = await runVercel( + ["connect", "remove", input.connectorId, "--disconnect-all", "--yes"], + { cwd: input.projectRoot, onOutput: createPromptCommandOutput(input.log) }, ); - if (!create.ok) { - log.warning( - `Could not create the connector. Run \`vercel connect create ${service} --name ${slug}\`, then set the UID in agent/connections/${slug}.ts.`, + if (!removed) { + throw new Error( + `Could not remove connector ${input.connectorId}; run \`vercel connect remove ${input.connectorId} --disconnect-all --yes\`.`, ); - return { kind: "create-failed", created: false }; } +} - // Authoritative path: the just-created connector's UID is on `create` stdout. - // Fall back to a service-scoped `connect list` only when the CLI emits no - // parseable JSON (e.g. an older build without `-F json` on `create`). - let ref = parseCreatedConnector(create.stdout); - if (!ref) { - // The `Connecting ...` status stays active so this fallback lookup reads - // as part of the same step rather than a separate line. - ref = await findConnector(projectRoot, service, projectId, onOutput); - } - if (!ref) { - log.warning( - `Could not locate the connector. Run \`vercel connect list --all-projects\` to find its UID, then set it in agent/connections/${slug}.ts.`, - ); - return { kind: "connector-unresolved", created: true }; +async function cleanupThenThrow( + options: SetupConnectionConnectorOptions, + connectorId: string, + message: string, +): Promise { + try { + await cleanupCreatedConnectionConnector({ + log: options.log, + projectRoot: options.projectRoot, + connectorId, + }); + } catch (error) { + const cleanup = error instanceof Error ? error.message : String(error); + throw new Error(`${message} ${cleanup}`); } + options.signal?.throwIfAborted(); + throw new Error(message); +} - // Attach the connector to the linked project so the agent can call it from - // its builds and runtime. `vercel connect create` only creates the connector; - // without this attach it shows "No projects connected yet" in the dashboard. - if (!projectId) { - log.warning( - `Created connector ${ref.uid} but no Vercel project is linked, so it isn't attached. Run \`vercel link\`, then \`vercel connect attach ${ref.uid} --yes\`.`, - ); - } else { - const attached = await runVercel(["connect", "attach", ref.uid, "--yes"], { - cwd: projectRoot, - onOutput, +async function attach( + options: SetupConnectionConnectorOptions, + connectorUid: string, + onOutput: ProcessOutputHandler, +): Promise { + return runVercel(["connect", "attach", connectorUid, "--yes"], { + cwd: options.projectRoot, + onOutput, + signal: options.signal, + }); +} + +async function resolveFallbackConnector( + options: SetupConnectionConnectorOptions, + project: ProjectLink, + onOutput: ProcessOutputHandler, + initialNotice: string, +): Promise { + let notice = initialNotice; + while (true) { + const choice = await options.prompter.select<"find" | "create">({ + message: `Which connector should ${options.slug} use?`, + hintLayout: "inline", + notices: [{ tone: "warning", text: notice }], + options: [ + { value: "find", label: "Find a new one", hint: "Browse existing connectors" }, + { value: "create", label: "Create a new one", hint: "Register another connector" }, + ], }); - if (!attached) { - log.warning( - `Created connector ${ref.uid} but could not attach it to this project. Run \`vercel connect attach ${ref.uid} --yes\`.`, - ); + const connectors = await listConnectors(options, onOutput); + + if (choice === "find") { + const supported: ConnectConnectorRef[] = []; + for (const connector of connectors) { + if (await supportsUserAuthorization(options, project, connector, onOutput)) { + supported.push(connector); + } + } + if (supported.length === 0) { + notice = `No existing ${options.service} connectors support user authorization.`; + continue; + } + const byUid = new Map(supported.map((connector) => [connector.uid, connector])); + const uid = await options.prompter.select({ + message: `Select a connector for ${options.slug}`, + search: true, + placeholder: "type to search connectors", + options: supported.map((connector) => ({ + value: connector.uid, + label: connector.uid, + hint: connector.name ?? connector.id, + })), + }); + const connector = byUid.get(uid); + if (connector === undefined) throw new Error(`Connector ${uid} is no longer available.`); + return { kind: "existing", connector }; } - } - const { patched } = await updateConnectionConnectorUid(connectionFilePath, ref.uid); - if (!patched) { - log.warning( - `Created connector ${ref.uid}. Update \`connect("…")\` in agent/connections/${slug}.ts to "${ref.uid}".`, + const names = connectorNames(connectors); + const name = ( + await options.prompter.text({ + message: "New connector name", + defaultValue: nextConnectorName(options.slug, names), + validate: (value) => { + const normalized = value.trim().toLowerCase(); + if (normalized.length === 0) return "A name is required."; + return names.has(normalized) ? "A connector with this name already exists." : undefined; + }, + }) + ).trim(); + const transcript: string[] = []; + const createOutput: ProcessOutputHandler = (line) => { + transcript.push(line.text); + onOutput(line); + }; + const created = await withPhase( + options.log, + "Waiting for you to complete setup in the browser…", + () => + runVercelCaptureStdout( + ["connect", "create", options.service, "--name", name, "-F", "json"], + { cwd: options.projectRoot, onOutput: createOutput, signal: options.signal }, + ), + { kind: "external-action", emphasis: "browser" }, ); - return { kind: "patch-failed", created: true, connectorUid: ref.uid }; + const raw = parseConnectorRef(parseTerminalJsonObject(created.stdout)); + const ownedId = raw?.id ?? CREATED_CONNECTOR.exec(transcript.join("\n"))?.[1]; + const connector = created.ok ? parseCreatedConnector(created.stdout) : undefined; + if (connector !== undefined) return { kind: "created", connector }; + const message = created.ok + ? `The ${options.service} connector does not support user authorization.` + : `Could not create the ${options.service} connector.`; + if (ownedId !== undefined) return cleanupThenThrow(options, ownedId, message); + throw new Error(message); + } +} + +/** Attaches the canonical connector first, then offers explicit Find/Create fallbacks. */ +export async function setupConnectionConnector( + options: SetupConnectionConnectorOptions, +): Promise { + const onOutput = createPromptCommandOutput(options.log); + const project = await ensureLinkedProject(options); + + if (await attach(options, options.canonicalConnectorUid, onOutput)) { + options.log.success(`Attached ${options.canonicalConnectorUid} connector`); + return { kind: "existing", connectorUid: options.canonicalConnectorUid }; } + options.signal?.throwIfAborted(); - log.success(`Linked ${slug} to ${ref.uid}`); - return { kind: "patched", created: true, connectorUid: ref.uid }; + const resolution = await resolveFallbackConnector( + options, + project, + onOutput, + `Could not attach ${options.canonicalConnectorUid}.`, + ); + if (!(await attach(options, resolution.connector.uid, onOutput))) { + if (resolution.kind === "created") { + return cleanupThenThrow( + options, + resolution.connector.id, + `Could not attach ${resolution.connector.uid} to the linked project.`, + ); + } + throw new Error(`Could not attach ${resolution.connector.uid} to the linked project.`); + } + options.log.success(`Attached ${resolution.connector.uid} connector`); + return resolution.kind === "created" + ? { + kind: "created", + connectorUid: resolution.connector.uid, + connectorId: resolution.connector.id, + } + : { kind: "existing", connectorUid: resolution.connector.uid }; } diff --git a/packages/eve/src/setup/flows/connections.test.ts b/packages/eve/src/setup/flows/connections.test.ts new file mode 100644 index 000000000..be437056e --- /dev/null +++ b/packages/eve/src/setup/flows/connections.test.ts @@ -0,0 +1,205 @@ +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, SelectOption, 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(picks: ReadonlyArray) { + const queue = [...picks]; + const paints: SelectOption[][] = []; + const requests: SingleSelectOptions[] = []; + return { + paints, + requests, + single(options: SingleSelectOptions): PrompterValue { + if (options.message !== CONNECTIONS_PROMPT_MESSAGE) { + throw new Error(`Unexpected select: ${options.message}`); + } + requests.push(options); + paints.push(options.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 () => ({ + kind: "existing", + connectorUid: "mcp.linear.app/linear", + })), + listAuthoredConnections: vi.fn(async () => []), + cleanupCreatedConnectionConnector: vi.fn(async () => {}), + }; +} + +function flowDeps(overrides: Partial = {}): ConnectionsFlowDeps { + return { + detectDeployment: vi.fn(async () => LINKED), + detectPackageManager: vi.fn(async () => ({ + kind: "pnpm", + source: "default", + })), + ensureConnectionDependencies: vi.fn(async () => []), + getVercelAuthStatus: vi.fn( + async () => "authenticated", + ), + listAuthoredConnections: vi.fn(async () => []), + runPackageManagerInstall: vi.fn(async () => true), + addConnections: addConnectionDeps(), + ...overrides, + }; +} + +describe("runConnectionsFlow", () => { + it("adds a catalog connection and repaints the searchable task list", async () => { + const listAuthoredConnections = vi + .fn(async () => [] as string[]) + .mockResolvedValueOnce([]) + .mockResolvedValue(["linear"]); + const list = scriptConnectionList(["linear", "done"]); + const fake = createFakePrompter({ single: list.single }); + const addConnections = addConnectionDeps(); + const ensureConnectionDependencies = vi.fn(async () => []); + const runPackageManagerInstall = vi.fn(async () => true); + + await expect( + runConnectionsFlow({ + appRoot: APP_ROOT, + prompter: fake.prompter, + deps: flowDeps({ + ensureConnectionDependencies, + listAuthoredConnections, + runPackageManagerInstall, + addConnections, + }), + }), + ).resolves.toEqual({ kind: "done", addedConnections: ["linear"] }); + + expect(list.requests[0]).toMatchObject({ + hintLayout: "inline", + search: true, + placeholder: "type to search MCP servers", + }); + expect(list.paints[0]?.map((row) => row.value)).toEqual(["linear", "notion", "done"]); + expect(list.paints[1]?.find((row) => row.value === "linear")).toMatchObject({ + completed: true, + focusHint: "Already added", + }); + expect(addConnections.setupConnectionConnector).toHaveBeenCalledWith( + expect.objectContaining({ + canonicalConnectorUid: "mcp.linear.app/linear", + service: "mcp.linear.app", + }), + ); + expect(runPackageManagerInstall).toHaveBeenCalledWith("pnpm", APP_ROOT, expect.any(Object)); + expect(ensureConnectionDependencies).toHaveBeenCalledWith({ projectRoot: APP_ROOT }); + expect(runPackageManagerInstall.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(addConnections.ensureConnection).mock.invocationCallOrder[0]!, + ); + expect( + vi.mocked(addConnections.setupConnectionConnector).mock.invocationCallOrder[0], + ).toBeLessThan(runPackageManagerInstall.mock.invocationCallOrder[0]!); + }); + + it("defaults to Done when every catalog connection is already authored", async () => { + const list = scriptConnectionList(["done"]); + await runConnectionsFlow({ + appRoot: APP_ROOT, + prompter: createFakePrompter({ single: list.single }).prompter, + deps: flowDeps({ + listAuthoredConnections: vi.fn(async () => ["linear", "notion"]), + }), + }); + + expect(list.requests[0]?.initialValue).toBe("done"); + }); + + it("points blocked rows at login or eve link and treats Esc as cancellation", async () => { + const loggedOutList = scriptConnectionList(["cancel"]); + const loggedOut = createFakePrompter({ single: loggedOutList.single }); + await expect( + runConnectionsFlow({ + appRoot: APP_ROOT, + prompter: loggedOut.prompter, + deps: flowDeps({ + detectDeployment: vi.fn(async () => ({ + state: "unlinked", + })), + getVercelAuthStatus: vi.fn( + async () => "logged-out", + ), + }), + }), + ).resolves.toEqual({ kind: "cancelled" }); + expect(loggedOutList.paints[0]?.find((row) => row.value === "linear")).toMatchObject({ + disabled: true, + disabledReason: "Log in to Vercel first, see /vc:login", + }); + + const unlinkedList = scriptConnectionList(["done"]); + await runConnectionsFlow({ + appRoot: APP_ROOT, + prompter: createFakePrompter({ single: unlinkedList.single }).prompter, + deps: flowDeps({ + detectDeployment: vi.fn(async () => ({ + state: "unlinked", + })), + }), + }); + expect(unlinkedList.paints[0]?.find((row) => row.value === "linear")).toMatchObject({ + disabled: true, + disabledReason: "Run eve link first", + }); + }); + + it("does not mutate dependencies when connector selection is cancelled", async () => { + const list = scriptConnectionList(["linear"]); + const addConnections = addConnectionDeps(); + vi.mocked(addConnections.setupConnectionConnector).mockRejectedValueOnce( + new WizardCancelledError(), + ); + const ensureConnectionDependencies = vi.fn(async () => []); + const runPackageManagerInstall = vi.fn(async () => true); + + await expect( + runConnectionsFlow({ + appRoot: APP_ROOT, + prompter: createFakePrompter({ single: list.single }).prompter, + deps: flowDeps({ + addConnections, + ensureConnectionDependencies, + runPackageManagerInstall, + }), + }), + ).resolves.toEqual({ kind: "cancelled" }); + + expect(ensureConnectionDependencies).not.toHaveBeenCalled(); + expect(runPackageManagerInstall).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/setup/flows/connections.ts b/packages/eve/src/setup/flows/connections.ts new file mode 100644 index 000000000..eb8a050aa --- /dev/null +++ b/packages/eve/src/setup/flows/connections.ts @@ -0,0 +1,229 @@ +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 { createDefaultSetupState, 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 { prompterSink } from "./in-project.js"; + +export const CONNECTIONS_PROMPT_MESSAGE = + "Select an MCP server to add to your agent through Vercel Connect"; + +const USER_AUTH_CONNECTIONS = CONNECTION_CATALOG.filter( + (entry) => entry.slug === "linear" || entry.slug === "notion", +); +const USER_AUTH_CONNECTION_SLUGS = new Set(USER_AUTH_CONNECTIONS.map((entry) => entry.slug)); + +export interface ConnectionsFlowDeps { + detectDeployment: typeof detectDeployment; + detectPackageManager: typeof detectPackageManager; + getVercelAuthStatus: typeof getVercelAuthStatus; + 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 connectionBlocker( + authStatus: VercelAuthStatus, + projectLinked: boolean, +): string | undefined { + return vercelAuthBlockerReason(authStatus) ?? (projectLinked ? undefined : "Run eve link first"); +} + +function connectionRows( + authored: ReadonlySet, + authStatus: VercelAuthStatus, + projectLinked: boolean, +): SelectOption[] { + const blocker = connectionBlocker(authStatus, projectLinked); + const rows: SelectOption[] = USER_AUTH_CONNECTIONS.map((entry) => { + if (authored.has(entry.slug)) { + return { + value: entry.slug, + label: entry.label, + completed: true, + focusHint: "Already added", + }; + } + if (blocker !== undefined) { + return { + value: entry.slug, + label: entry.label, + disabled: true, + disabledReason: blocker, + disabledReasonTone: "warning", + }; + } + return { value: entry.slug, label: entry.label, hint: entry.hint }; + }); + rows.push({ value: "done", label: "Done" }); + return rows; +} + +async function pickConnection(input: { + authored: ReadonlySet; + authStatus: VercelAuthStatus; + projectLinked: boolean; + prompter: Prompter; +}): Promise { + const options = connectionRows(input.authored, input.authStatus, input.projectLinked); + 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 input.prompter.select(request); + } catch (error) { + if (error instanceof WizardCancelledError) return undefined; + throw error; + } +} + +async function installConnectionDependencies(input: { + appRoot: string; + deps: ConnectionsFlowDeps; + prompter: Prompter; + signal?: AbortSignal; +}): Promise { + const packageManager = await input.deps.detectPackageManager(input.appRoot); + await input.deps.ensureConnectionDependencies({ projectRoot: input.appRoot }); + const installed = await withPhase( + input.prompter.log, + `Installing connection dependencies (${packageManager.kind} install)...`, + () => + input.deps.runPackageManagerInstall(packageManager.kind, input.appRoot, { + onOutput: createPromptCommandOutput(input.prompter.log), + signal: input.signal, + }), + ); + if (!installed) { + throw new Error(`Dependency installation failed. Run \`${packageManager.kind} install\`.`); + } +} + +/** Runs the searchable `/connect` task list and existing connection setup boxes. */ +export async function runConnectionsFlow(input: { + appRoot: string; + prompter: Prompter; + signal?: AbortSignal; + deps?: Partial; +}): Promise { + const { appRoot, prompter, signal } = input; + const deps: ConnectionsFlowDeps = { + detectDeployment, + detectPackageManager, + ensureConnectionDependencies, + getVercelAuthStatus, + listAuthoredConnections, + 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: SetupState = { + ...createDefaultSetupState(), + project: projectResolutionFromDeployment(deployment), + projectPath: { kind: "resolved", inPlace: true, path: appRoot }, + }; + let authored = new Set(initialAuthored); + const added: string[] = []; + let dependenciesReady = false; + + while (true) { + const selected = await pickConnection({ + authored, + authStatus, + projectLinked: isProjectResolved(state.project), + prompter, + }); + if (selected === undefined || selected === "done") { + return added.length === 0 && selected === undefined + ? { kind: "cancelled" } + : { kind: "done", addedConnections: added }; + } + if (authored.has(selected) || !USER_AUTH_CONNECTION_SLUGS.has(selected)) continue; + + const boxes: AnySetupBox[] = [ + selectConnections({ asker: interactiveAsker(prompter), presetConnections: [selected] }), + addConnections({ + prompter, + signal, + deps: deps.addConnections, + beforeScaffold: async () => { + if (dependenciesReady) return; + await installConnectionDependencies({ appRoot, deps, prompter, signal }); + dependenciesReady = true; + }, + }), + ]; + try { + const result = await runInteractive(boxes, state, prompterSink(prompter), { + snapshot: snapshotSetupState, + signal, + }); + if (result.kind !== "done") { + return added.length === 0 + ? { kind: "cancelled" } + : { kind: "done", addedConnections: added }; + } + state = result.state; + authored = new Set(await deps.listAuthoredConnections(appRoot)); + if (!authored.has(selected)) continue; + added.push(selected); + } catch (error) { + authored = new Set(await deps.listAuthoredConnections(appRoot)); + if (!authored.has(selected)) throw error; + if (!added.includes(selected)) added.push(selected); + return { kind: "failed", addedConnections: added, message: toErrorMessage(error) }; + } + } +} diff --git a/packages/eve/src/setup/scaffold/connections/catalog.test.ts b/packages/eve/src/setup/scaffold/connections/catalog.test.ts index ee5f07275..d93a18a44 100644 --- a/packages/eve/src/setup/scaffold/connections/catalog.test.ts +++ b/packages/eve/src/setup/scaffold/connections/catalog.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { + canonicalConnectorUidForEntry, catalogSlugs, CONNECTION_CATALOG, connectorServiceForEntry, @@ -41,6 +42,7 @@ describe("catalog integrity", () => { test("every Connect entry resolves a `vercel connect create` service", () => { for (const entry of CONNECTION_CATALOG) { expect(connectorServiceForEntry(entry)).toBeTruthy(); + expect(canonicalConnectorUidForEntry(entry)).toBeTruthy(); } }); }); @@ -71,6 +73,18 @@ describe("connectorServiceForEntry", () => { }); }); +describe("canonicalConnectorUidForEntry", () => { + test("keeps catalog UIDs and derives custom UIDs", () => { + expect(canonicalConnectorUidForEntry(getCatalogEntry("notion")!)).toBe("mcp.notion.com/notion"); + expect( + canonicalConnectorUidForEntry({ + mcp: { url: "https://mcp.example.com/mcp" }, + auth: { kind: "connect", connector: "custom" }, + }), + ).toBe("mcp.example.com/custom"); + }); +}); + describe("mcpServiceHost", () => { test("extracts the host from a URL", () => { expect(mcpServiceHost("https://mcp.linear.app/sse")).toBe("mcp.linear.app"); diff --git a/packages/eve/src/setup/scaffold/connections/catalog.ts b/packages/eve/src/setup/scaffold/connections/catalog.ts index f9ec660cd..c9ac14695 100644 --- a/packages/eve/src/setup/scaffold/connections/catalog.ts +++ b/packages/eve/src/setup/scaffold/connections/catalog.ts @@ -194,6 +194,17 @@ export function connectorServiceForEntry( return mcpServiceHost(entry.mcp?.url); } +/** Concrete connector UID attempted before connector discovery or creation. */ +export function canonicalConnectorUidForEntry(entry: { + mcp?: McpEndpoint; + auth?: ConnectionAuthSpec; +}): string | undefined { + if (entry.auth?.kind !== "connect") return undefined; + if (entry.auth.connector.includes("/")) return entry.auth.connector; + const service = entry.auth.service ?? mcpServiceHost(entry.mcp?.url); + return service === undefined ? undefined : `${service}/${entry.auth.connector}`; +} + /** Extracts the bare host from an MCP URL, or `undefined` when unparseable. */ export function mcpServiceHost(url: string | undefined): string | undefined { if (!url) return undefined; diff --git a/packages/eve/src/setup/scaffold/index.ts b/packages/eve/src/setup/scaffold/index.ts index f3661b865..b5c469a37 100644 --- a/packages/eve/src/setup/scaffold/index.ts +++ b/packages/eve/src/setup/scaffold/index.ts @@ -27,11 +27,13 @@ export { SCAFFOLDABLE_CHANNELS, type ScaffoldableChannel } from "./channels-cata export { ensureConnection, + ensureConnectionDependencies, listAuthoredConnections, type ConnectionInput, type ConnectionMutationAction, type ConnectionMutationResult, type EnsureConnectionOptions, + type EnsureConnectionDependenciesOptions, } from "./update/connections.js"; export { diff --git a/packages/eve/src/setup/scaffold/update/connections.ts b/packages/eve/src/setup/scaffold/update/connections.ts index 2f2e057bf..ee6e7f606 100644 --- a/packages/eve/src/setup/scaffold/update/connections.ts +++ b/packages/eve/src/setup/scaffold/update/connections.ts @@ -53,6 +53,11 @@ export interface EnsureConnectionOptions { connectPackageVersion?: string; } +export interface EnsureConnectionDependenciesOptions { + projectRoot: string; + connectPackageVersion?: string; +} + function resolveAuth(entry: ConnectionInput): ConnectionAuthSpec { return entry.auth ?? { kind: "none" }; } @@ -158,6 +163,17 @@ async function ensureConnectDependency( ]; } +/** Adds the package dependency required by a Connect-auth connection. */ +export async function ensureConnectionDependencies( + options: EnsureConnectionDependenciesOptions, +): Promise { + const version = resolveVersionToken( + "connectPackageVersion", + options.connectPackageVersion ?? DEFAULT_CONNECT_PACKAGE_VERSION, + ); + return ensureConnectDependency(join(options.projectRoot, "package.json"), version); +} + function envKeyPresent(source: string, key: string): boolean { const pattern = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`, "m"); return pattern.test(source); From 1dccdb2f2b5109e5874b4b76c6f5935be1d21641 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Wed, 24 Jun 2026 22:13:02 -0400 Subject: [PATCH 02/11] fix(eve): allow searching the connect catalog Signed-off-by: Rui Conti --- packages/eve/src/setup/flows/connections.test.ts | 4 ++-- packages/eve/src/setup/flows/connections.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/eve/src/setup/flows/connections.test.ts b/packages/eve/src/setup/flows/connections.test.ts index be437056e..912350865 100644 --- a/packages/eve/src/setup/flows/connections.test.ts +++ b/packages/eve/src/setup/flows/connections.test.ts @@ -77,7 +77,7 @@ function flowDeps(overrides: Partial = {}): ConnectionsFlow } describe("runConnectionsFlow", () => { - it("adds a catalog connection and repaints the searchable task list", async () => { + it("adds a catalog connection and repaints the searchable list", async () => { const listAuthoredConnections = vi .fn(async () => [] as string[]) .mockResolvedValueOnce([]) @@ -102,10 +102,10 @@ describe("runConnectionsFlow", () => { ).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]).not.toHaveProperty("hintLayout"); expect(list.paints[0]?.map((row) => row.value)).toEqual(["linear", "notion", "done"]); expect(list.paints[1]?.find((row) => row.value === "linear")).toMatchObject({ completed: true, diff --git a/packages/eve/src/setup/flows/connections.ts b/packages/eve/src/setup/flows/connections.ts index eb8a050aa..6e0f0e713 100644 --- a/packages/eve/src/setup/flows/connections.ts +++ b/packages/eve/src/setup/flows/connections.ts @@ -99,7 +99,6 @@ async function pickConnection(input: { const request: SingleSelectOptions = { message: CONNECTIONS_PROMPT_MESSAGE, options, - hintLayout: "inline", search: true, placeholder: "type to search MCP servers", }; From 33372f40063e9cae62c47738dba7de293017d71c Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 25 Jun 2026 08:28:24 -0400 Subject: [PATCH 03/11] feat(eve): add /vc:link to the dev TUI Signed-off-by: Rui Conti --- .changeset/connect-command.md | 2 +- docs/guides/dev-tui.md | 7 +++-- .../src/cli/dev/tui/command-typeahead.test.ts | 2 +- .../eve/src/cli/dev/tui/command-typeahead.ts | 2 +- .../src/cli/dev/tui/prompt-commands.test.ts | 7 +++++ .../eve/src/cli/dev/tui/prompt-commands.ts | 11 ++++++- .../src/cli/dev/tui/setup-commands.test.ts | 31 ++++++++++++++++++- .../eve/src/cli/dev/tui/setup-commands.ts | 19 ++++++++++++ .../eve/src/setup/flows/connections.test.ts | 2 +- packages/eve/src/setup/flows/connections.ts | 2 +- 10 files changed, 75 insertions(+), 10 deletions(-) diff --git a/.changeset/connect-command.md b/.changeset/connect-command.md index 342101b27..cb88dfa60 100644 --- a/.changeset/connect-command.md +++ b/.changeset/connect-command.md @@ -2,4 +2,4 @@ "eve": patch --- -Add a searchable `/connect` flow to the local dev TUI. It scaffolds an MCP connection, tries the provider's canonical Vercel Connect connector first, and offers explicit Find or Create paths when attachment fails. +Add searchable `/connect` and `/vc:link` flows to the local dev TUI. `/connect` scaffolds an MCP connection and resolves its Vercel Connect connector; `/vc:link` selects and links an existing Vercel project without leaving the TUI. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index 54c43cdb3..c300f59b6 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -30,7 +30,7 @@ Errors render compactly with docs links highlighted. A code bug escaping your ag ## 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, channel, connection, and the Vercel CLI commands (`/vc:install`, `/vc:login`, `/vc:link`) 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. | Command | Does | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -40,12 +40,13 @@ Each command echoes as an invocation line, asks through a bordered panel that ta | `/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. | | `/vc:login` | Logs in to Vercel locally. On a remote session, resolves the deployment's project, refreshes its OIDC token, and confirms any required Trusted Sources rule. | +| `/vc:link` | Selects an existing Vercel team and project, then links the local agent directory. | | `/loglevel` | Switches which logs the transcript shows. See [Control what logs show](#control-what-logs-show). | | `/new` | Starts a fresh session. | | `/exit` | Quits the TUI. | | `/help` | Lists the commands available for the current local or remote session. | -`/model`, `/channels`, `/connect`, 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`, `/channels`, `/connect`, `/deploy`, and `/vc:link` 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 @@ -61,7 +62,7 @@ The provider row demands attention (a bold yellow "Configure model access" with ### Add a connection -`/connect` shows a searchable list of MCP servers available through Vercel Connect. Already-authored connections remain checked. Vercel login stays in `/vc:login`, and team or project selection stays in `eve link`; unlinked or logged-out projects point to those commands instead of opening another selection flow. +`/connect` shows a searchable list of MCP servers available through Vercel Connect. Already-authored connections remain checked. Vercel login stays in `/vc:login`, and `/vc:link` selects an existing team and project without leaving the TUI. Unlinked or logged-out projects point to those commands instead of opening another selection flow. 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. 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, and installs the new dependency so the dev server can load it. diff --git a/packages/eve/src/cli/dev/tui/command-typeahead.test.ts b/packages/eve/src/cli/dev/tui/command-typeahead.test.ts index 5817813fc..825802075 100644 --- a/packages/eve/src/cli/dev/tui/command-typeahead.test.ts +++ b/packages/eve/src/cli/dev/tui/command-typeahead.test.ts @@ -172,7 +172,7 @@ describe("renderCommandSuggestions", () => { const many = Array.from({ length: 14 }, (_, index) => spec(`command-${index}`)); const state = { ...typeaheadFor(many, "/"), selectedIndex: 13 }; const rows = renderCommandSuggestions(state, theme, 80).map(stripAnsi); - expect(rows).toHaveLength(10); + expect(rows).toHaveLength(11); expect(rows.some((row) => row.includes("command-13"))).toBe(true); expect(rows.some((row) => row.includes("command-0"))).toBe(false); expect(rows.some((row) => row.includes("commands, showing"))).toBe(false); diff --git a/packages/eve/src/cli/dev/tui/command-typeahead.ts b/packages/eve/src/cli/dev/tui/command-typeahead.ts index 2c31dd968..c274c88fa 100644 --- a/packages/eve/src/cli/dev/tui/command-typeahead.ts +++ b/packages/eve/src/cli/dev/tui/command-typeahead.ts @@ -17,7 +17,7 @@ import { renderCursorRow } from "#setup/cli/option-row.js"; * a command (e.g. `/exit`) out of view — windowing is for longer future lists. * Keep this >= the number of entries in `PROMPT_COMMANDS`. */ -const MAX_VISIBLE_SUGGESTIONS = 10; +const MAX_VISIBLE_SUGGESTIONS = 11; export interface CommandTypeaheadState { /** The prompt text the matches were derived from. */ 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 0f2256517..d9ecbe947 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.test.ts @@ -45,6 +45,11 @@ describe("parsePromptCommand", () => { name: "vc:login", argument: "", }); + expect(parsePromptCommand("/vc:link")).toEqual({ + type: "extension", + name: "vc:link", + argument: "", + }); expect(parsePromptCommand("/channels")).toEqual({ type: "extension", name: "channels", @@ -103,6 +108,7 @@ describe("promptCommandsFor", () => { expect(names).toContain("deploy"); expect(names).toContain("vc:install"); expect(names).toContain("vc:login"); + expect(names).toContain("vc:link"); expect(names).not.toContain("vc:auth"); }); @@ -110,6 +116,7 @@ describe("promptCommandsFor", () => { const names = promptCommandsFor("remote").map((command) => command.name); expect(names).toContain("vc:install"); expect(names).toContain("vc:login"); + expect(names).not.toContain("vc:link"); expect(names).not.toContain("vc:auth"); expect(names).not.toContain("model"); expect(names).not.toContain("channels"); diff --git a/packages/eve/src/cli/dev/tui/prompt-commands.ts b/packages/eve/src/cli/dev/tui/prompt-commands.ts index be7bac701..3ce4b1a71 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.ts @@ -4,7 +4,8 @@ export type PromptCommandExtensionName = | "connect" | "deploy" | "vc:install" - | "vc:login"; + | "vc:login" + | "vc:link"; type PromptCommandTarget = "local" | "remote"; @@ -79,6 +80,14 @@ const PROMPT_COMMAND_DEFINITIONS = [ build: () => ({ type: "extension", name: "vc:login", argument: "" }), targets: ["local", "remote"], }, + { + name: "vc:link", + aliases: [], + description: "Link this agent to a Vercel project", + takesArgument: false, + build: () => ({ type: "extension", name: "vc:link", argument: "" }), + targets: ["local"], + }, { name: "model", aliases: [], 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 a4e98aa21..dd6b2d2ca 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.test.ts @@ -54,6 +54,7 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { kind: "installed", })), runLoginFlow: vi.fn(async () => ({ kind: "logged-in" })), + runLinkFlow: vi.fn(async () => ({ kind: "done" })), runModelFlow: vi.fn(async () => ({ kind: "done", modelMessage: "Model changed to openai/gpt-5.5. Live on your next prompt.", @@ -75,7 +76,7 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { } function run(input: { - command: "vc:install" | "vc:login" | "model" | "channels" | "connect" | "deploy"; + command: "vc:install" | "vc:login" | "vc:link" | "model" | "channels" | "connect" | "deploy"; flows: TuiSetupFlows; renderer?: TuiSetupCommandRenderer; initialModelStep?: "provider"; @@ -103,6 +104,7 @@ describe("runTuiSetupCommand", () => { ).toEqual({ "vc:install": "pulse", "vc:login": "pulse", + "vc:link": "pulse", model: "pulse", channels: "pulse", connect: "pulse", @@ -110,6 +112,33 @@ describe("runTuiSetupCommand", () => { }); }); + it("links an existing Vercel project from the TUI", async () => { + const flows = fakeFlows(); + + await expect(run({ command: "vc:link", flows })).resolves.toEqual({ + message: "Project linked.", + preserveFlowDiagnostics: true, + effect: { kind: "model-access-changed" }, + }); + expect(flows.runLinkFlow).toHaveBeenCalledWith( + expect.objectContaining({ + appRoot: APP_ROOT, + projectSelection: "existing-only", + }), + ); + }); + + it("reports a cancelled Vercel project link", async () => { + const flows = fakeFlows({ + runLinkFlow: vi.fn(async () => ({ kind: "cancelled" })), + }); + + await expect(run({ command: "vc:link", flows })).resolves.toEqual({ + message: "/vc:link cancelled.", + preserveFlowDiagnostics: true, + }); + }); + it("surfaces the model flow's apply line as the outcome", async () => { const flows = fakeFlows(); await expect(run({ command: "model", flows })).resolves.toEqual({ diff --git a/packages/eve/src/cli/dev/tui/setup-commands.ts b/packages/eve/src/cli/dev/tui/setup-commands.ts index 6f3ae9d63..5d631d618 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.ts @@ -7,6 +7,7 @@ import { type InstallVercelCliResult, } from "#setup/flows/install-vercel-cli.js"; import { runLoginFlow, type LoginFlowResult } from "#setup/flows/login.js"; +import { runLinkFlow } from "#setup/flows/link.js"; import { runModelFlow, type ModelProviderOutcome } from "#setup/flows/model.js"; import { runProviderFlow, type ProviderPicker } from "#setup/flows/provider.js"; import { openUrl } from "#setup/primitives/open-url.js"; @@ -29,6 +30,7 @@ export type TuiSetupCommand = PromptCommandExtensionName; export const SETUP_FLOW_CONFIG = { "vc:install": { title: "Install the Vercel CLI", indicator: "pulse" }, "vc:login": { title: "Log in to Vercel", indicator: "pulse" }, + "vc:link": { title: "Link a Vercel project", indicator: "pulse" }, model: { title: "Configure the agent model", indicator: "pulse" }, channels: { title: "Agent channels", indicator: "pulse" }, connect: { title: "Agent connections", indicator: "pulse" }, @@ -59,6 +61,7 @@ export interface TuiSetupCommandInput { export interface TuiSetupFlows { runInstallVercelCliFlow: typeof runInstallVercelCliFlow; runLoginFlow: typeof runLoginFlow; + runLinkFlow: typeof runLinkFlow; runModelFlow: typeof runModelFlow; runChannelsFlow: typeof runChannelsFlow; runConnectionsFlow: typeof runConnectionsFlow; @@ -159,6 +162,7 @@ async function executeSetupCommand( const flows: TuiSetupFlows = { runInstallVercelCliFlow, runLoginFlow, + runLinkFlow, runModelFlow, runChannelsFlow, runConnectionsFlow, @@ -176,6 +180,21 @@ async function executeSetupCommand( case "vc:login": { return loginResultMessage(await flows.runLoginFlow({ appRoot, prompter, signal })); } + case "vc:link": { + const result = await flows.runLinkFlow({ + appRoot, + prompter, + signal, + projectSelection: "existing-only", + }); + return result.kind === "cancelled" + ? { message: "/vc:link cancelled.", preserveFlowDiagnostics: true } + : { + message: "Project linked.", + preserveFlowDiagnostics: true, + effect: { kind: "model-access-changed" }, + }; + } case "model": { const pickProvider: ProviderPicker = (request) => renderer.readProviderPicker(request); const modelInput: Parameters[0] = { diff --git a/packages/eve/src/setup/flows/connections.test.ts b/packages/eve/src/setup/flows/connections.test.ts index 912350865..bc7f0c727 100644 --- a/packages/eve/src/setup/flows/connections.test.ts +++ b/packages/eve/src/setup/flows/connections.test.ts @@ -174,7 +174,7 @@ describe("runConnectionsFlow", () => { }); expect(unlinkedList.paints[0]?.find((row) => row.value === "linear")).toMatchObject({ disabled: true, - disabledReason: "Run eve link first", + disabledReason: "Run /vc:link first", }); }); diff --git a/packages/eve/src/setup/flows/connections.ts b/packages/eve/src/setup/flows/connections.ts index 6e0f0e713..9a7f7c21e 100644 --- a/packages/eve/src/setup/flows/connections.ts +++ b/packages/eve/src/setup/flows/connections.ts @@ -56,7 +56,7 @@ function connectionBlocker( authStatus: VercelAuthStatus, projectLinked: boolean, ): string | undefined { - return vercelAuthBlockerReason(authStatus) ?? (projectLinked ? undefined : "Run eve link first"); + return vercelAuthBlockerReason(authStatus) ?? (projectLinked ? undefined : "Run /vc:link first"); } function connectionRows( From 109eab368455436120c25c849defe35309183d52 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 25 Jun 2026 13:37:37 -0400 Subject: [PATCH 04/11] refactor(eve): fold project linking into /connect Signed-off-by: Rui Conti --- .changeset/connect-command.md | 2 +- docs/guides/dev-tui.md | 7 +- .../src/cli/dev/tui/command-typeahead.test.ts | 2 +- .../eve/src/cli/dev/tui/command-typeahead.ts | 2 +- .../src/cli/dev/tui/prompt-commands.test.ts | 7 -- .../eve/src/cli/dev/tui/prompt-commands.ts | 11 +-- .../src/cli/dev/tui/setup-commands.test.ts | 40 ++------- .../eve/src/cli/dev/tui/setup-commands.ts | 29 ++----- .../eve/src/setup/flows/connections.test.ts | 82 +++++++++++++++---- packages/eve/src/setup/flows/connections.ts | 39 ++++++--- 10 files changed, 117 insertions(+), 104 deletions(-) diff --git a/.changeset/connect-command.md b/.changeset/connect-command.md index cb88dfa60..0e7afa69f 100644 --- a/.changeset/connect-command.md +++ b/.changeset/connect-command.md @@ -2,4 +2,4 @@ "eve": patch --- -Add searchable `/connect` and `/vc:link` flows to the local dev TUI. `/connect` scaffolds an MCP connection and resolves its Vercel Connect connector; `/vc:link` selects and links an existing Vercel project without leaving the TUI. +Add a searchable `/connect` flow to the local dev TUI. It scaffolds an MCP connection, resolves its Vercel Connect connector, and reuses the model setup flow to create or link a Vercel project when needed. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index c300f59b6..799858d06 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -30,7 +30,7 @@ Errors render compactly with docs links highlighted. A code bug escaping your ag ## 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`, `/vc:link`) 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, 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. | Command | Does | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -40,13 +40,12 @@ Each command echoes as an invocation line, asks through a bordered panel that ta | `/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. | | `/vc:login` | Logs in to Vercel locally. On a remote session, resolves the deployment's project, refreshes its OIDC token, and confirms any required Trusted Sources rule. | -| `/vc:link` | Selects an existing Vercel team and project, then links the local agent directory. | | `/loglevel` | Switches which logs the transcript shows. See [Control what logs show](#control-what-logs-show). | | `/new` | Starts a fresh session. | | `/exit` | Quits the TUI. | | `/help` | Lists the commands available for the current local or remote session. | -`/model`, `/channels`, `/connect`, `/deploy`, and `/vc:link` 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`, `/channels`, `/connect`, 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 @@ -62,7 +61,7 @@ The provider row demands attention (a bold yellow "Configure model access" with ### Add a connection -`/connect` shows a searchable list of MCP servers available through Vercel Connect. Already-authored connections remain checked. Vercel login stays in `/vc:login`, and `/vc:link` selects an existing team and project without leaving the TUI. Unlinked or logged-out projects point to those commands instead of opening another selection flow. +`/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. 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, and installs the new dependency so the dev server can load it. diff --git a/packages/eve/src/cli/dev/tui/command-typeahead.test.ts b/packages/eve/src/cli/dev/tui/command-typeahead.test.ts index 825802075..5817813fc 100644 --- a/packages/eve/src/cli/dev/tui/command-typeahead.test.ts +++ b/packages/eve/src/cli/dev/tui/command-typeahead.test.ts @@ -172,7 +172,7 @@ describe("renderCommandSuggestions", () => { const many = Array.from({ length: 14 }, (_, index) => spec(`command-${index}`)); const state = { ...typeaheadFor(many, "/"), selectedIndex: 13 }; const rows = renderCommandSuggestions(state, theme, 80).map(stripAnsi); - expect(rows).toHaveLength(11); + expect(rows).toHaveLength(10); expect(rows.some((row) => row.includes("command-13"))).toBe(true); expect(rows.some((row) => row.includes("command-0"))).toBe(false); expect(rows.some((row) => row.includes("commands, showing"))).toBe(false); diff --git a/packages/eve/src/cli/dev/tui/command-typeahead.ts b/packages/eve/src/cli/dev/tui/command-typeahead.ts index c274c88fa..2c31dd968 100644 --- a/packages/eve/src/cli/dev/tui/command-typeahead.ts +++ b/packages/eve/src/cli/dev/tui/command-typeahead.ts @@ -17,7 +17,7 @@ import { renderCursorRow } from "#setup/cli/option-row.js"; * a command (e.g. `/exit`) out of view — windowing is for longer future lists. * Keep this >= the number of entries in `PROMPT_COMMANDS`. */ -const MAX_VISIBLE_SUGGESTIONS = 11; +const MAX_VISIBLE_SUGGESTIONS = 10; export interface CommandTypeaheadState { /** The prompt text the matches were derived from. */ 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 d9ecbe947..0f2256517 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.test.ts @@ -45,11 +45,6 @@ describe("parsePromptCommand", () => { name: "vc:login", argument: "", }); - expect(parsePromptCommand("/vc:link")).toEqual({ - type: "extension", - name: "vc:link", - argument: "", - }); expect(parsePromptCommand("/channels")).toEqual({ type: "extension", name: "channels", @@ -108,7 +103,6 @@ describe("promptCommandsFor", () => { expect(names).toContain("deploy"); expect(names).toContain("vc:install"); expect(names).toContain("vc:login"); - expect(names).toContain("vc:link"); expect(names).not.toContain("vc:auth"); }); @@ -116,7 +110,6 @@ describe("promptCommandsFor", () => { const names = promptCommandsFor("remote").map((command) => command.name); expect(names).toContain("vc:install"); expect(names).toContain("vc:login"); - expect(names).not.toContain("vc:link"); expect(names).not.toContain("vc:auth"); expect(names).not.toContain("model"); expect(names).not.toContain("channels"); diff --git a/packages/eve/src/cli/dev/tui/prompt-commands.ts b/packages/eve/src/cli/dev/tui/prompt-commands.ts index 3ce4b1a71..be7bac701 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.ts @@ -4,8 +4,7 @@ export type PromptCommandExtensionName = | "connect" | "deploy" | "vc:install" - | "vc:login" - | "vc:link"; + | "vc:login"; type PromptCommandTarget = "local" | "remote"; @@ -80,14 +79,6 @@ const PROMPT_COMMAND_DEFINITIONS = [ build: () => ({ type: "extension", name: "vc:login", argument: "" }), targets: ["local", "remote"], }, - { - name: "vc:link", - aliases: [], - description: "Link this agent to a Vercel project", - takesArgument: false, - build: () => ({ type: "extension", name: "vc:link", argument: "" }), - targets: ["local"], - }, { name: "model", aliases: [], 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 dd6b2d2ca..2264b285c 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.test.ts @@ -54,7 +54,6 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { kind: "installed", })), runLoginFlow: vi.fn(async () => ({ kind: "logged-in" })), - runLinkFlow: vi.fn(async () => ({ kind: "done" })), runModelFlow: vi.fn(async () => ({ kind: "done", modelMessage: "Model changed to openai/gpt-5.5. Live on your next prompt.", @@ -76,7 +75,7 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { } function run(input: { - command: "vc:install" | "vc:login" | "vc:link" | "model" | "channels" | "connect" | "deploy"; + command: "vc:install" | "vc:login" | "model" | "channels" | "connect" | "deploy"; flows: TuiSetupFlows; renderer?: TuiSetupCommandRenderer; initialModelStep?: "provider"; @@ -104,7 +103,6 @@ describe("runTuiSetupCommand", () => { ).toEqual({ "vc:install": "pulse", "vc:login": "pulse", - "vc:link": "pulse", model: "pulse", channels: "pulse", connect: "pulse", @@ -112,33 +110,6 @@ describe("runTuiSetupCommand", () => { }); }); - it("links an existing Vercel project from the TUI", async () => { - const flows = fakeFlows(); - - await expect(run({ command: "vc:link", flows })).resolves.toEqual({ - message: "Project linked.", - preserveFlowDiagnostics: true, - effect: { kind: "model-access-changed" }, - }); - expect(flows.runLinkFlow).toHaveBeenCalledWith( - expect.objectContaining({ - appRoot: APP_ROOT, - projectSelection: "existing-only", - }), - ); - }); - - it("reports a cancelled Vercel project link", async () => { - const flows = fakeFlows({ - runLinkFlow: vi.fn(async () => ({ kind: "cancelled" })), - }); - - await expect(run({ command: "vc:link", flows })).resolves.toEqual({ - message: "/vc:link cancelled.", - preserveFlowDiagnostics: true, - }); - }); - it("surfaces the model flow's apply line as the outcome", async () => { const flows = fakeFlows(); await expect(run({ command: "model", flows })).resolves.toEqual({ @@ -366,6 +337,7 @@ describe("runTuiSetupCommand", () => { await expect(run({ command: "connect", flows })).resolves.toEqual({ message: "Connections added: linear, notion.", preserveFlowDiagnostics: true, + effect: { kind: "model-access-changed" }, }); expect(flows.runConnectionsFlow).toHaveBeenCalledWith( expect.objectContaining({ appRoot: APP_ROOT }), @@ -376,6 +348,7 @@ describe("runTuiSetupCommand", () => { await expect(run({ command: "connect", flows: fakeFlows() })).resolves.toEqual({ message: "No connections added.", preserveFlowDiagnostics: true, + effect: { kind: "model-access-changed" }, }); await expect( run({ @@ -384,7 +357,11 @@ describe("runTuiSetupCommand", () => { runConnectionsFlow: async () => ({ kind: "cancelled" }), }), }), - ).resolves.toEqual({ message: "/connect cancelled.", preserveFlowDiagnostics: true }); + ).resolves.toEqual({ + message: "/connect cancelled.", + preserveFlowDiagnostics: true, + effect: { kind: "model-access-changed" }, + }); await expect( run({ command: "connect", @@ -399,6 +376,7 @@ describe("runTuiSetupCommand", () => { ).resolves.toEqual({ message: "Connection files changed, but /connect failed: install failed", preserveFlowDiagnostics: true, + effect: { kind: "model-access-changed" }, }); }); diff --git a/packages/eve/src/cli/dev/tui/setup-commands.ts b/packages/eve/src/cli/dev/tui/setup-commands.ts index 5d631d618..8fef468ef 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.ts @@ -7,7 +7,6 @@ import { type InstallVercelCliResult, } from "#setup/flows/install-vercel-cli.js"; import { runLoginFlow, type LoginFlowResult } from "#setup/flows/login.js"; -import { runLinkFlow } from "#setup/flows/link.js"; import { runModelFlow, type ModelProviderOutcome } from "#setup/flows/model.js"; import { runProviderFlow, type ProviderPicker } from "#setup/flows/provider.js"; import { openUrl } from "#setup/primitives/open-url.js"; @@ -30,7 +29,6 @@ export type TuiSetupCommand = PromptCommandExtensionName; export const SETUP_FLOW_CONFIG = { "vc:install": { title: "Install the Vercel CLI", indicator: "pulse" }, "vc:login": { title: "Log in to Vercel", indicator: "pulse" }, - "vc:link": { title: "Link a Vercel project", indicator: "pulse" }, model: { title: "Configure the agent model", indicator: "pulse" }, channels: { title: "Agent channels", indicator: "pulse" }, connect: { title: "Agent connections", indicator: "pulse" }, @@ -61,7 +59,6 @@ export interface TuiSetupCommandInput { export interface TuiSetupFlows { runInstallVercelCliFlow: typeof runInstallVercelCliFlow; runLoginFlow: typeof runLoginFlow; - runLinkFlow: typeof runLinkFlow; runModelFlow: typeof runModelFlow; runChannelsFlow: typeof runChannelsFlow; runConnectionsFlow: typeof runConnectionsFlow; @@ -114,7 +111,7 @@ function muteableRenderer( } /** - * Runs one TUI setup command (/model, /channels, /deploy) over the + * Runs one TUI setup command (/model, /channels, /connect, /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 @@ -162,7 +159,6 @@ async function executeSetupCommand( const flows: TuiSetupFlows = { runInstallVercelCliFlow, runLoginFlow, - runLinkFlow, runModelFlow, runChannelsFlow, runConnectionsFlow, @@ -180,21 +176,6 @@ async function executeSetupCommand( case "vc:login": { return loginResultMessage(await flows.runLoginFlow({ appRoot, prompter, signal })); } - case "vc:link": { - const result = await flows.runLinkFlow({ - appRoot, - prompter, - signal, - projectSelection: "existing-only", - }); - return result.kind === "cancelled" - ? { message: "/vc:link cancelled.", preserveFlowDiagnostics: true } - : { - message: "Project linked.", - preserveFlowDiagnostics: true, - effect: { kind: "model-access-changed" }, - }; - } case "model": { const pickProvider: ProviderPicker = (request) => renderer.readProviderPicker(request); const modelInput: Parameters[0] = { @@ -262,11 +243,16 @@ async function executeSetupCommand( const result = await flows.runConnectionsFlow({ appRoot, prompter, signal }); switch (result.kind) { case "cancelled": - return { message: "/connect cancelled.", preserveFlowDiagnostics: true }; + return { + message: "/connect cancelled.", + preserveFlowDiagnostics: true, + effect: { kind: "model-access-changed" }, + }; case "failed": return { message: `Connection files changed, but /connect failed: ${result.message}`, preserveFlowDiagnostics: true, + effect: { kind: "model-access-changed" }, }; case "done": return { @@ -275,6 +261,7 @@ async function executeSetupCommand( ? "No connections added." : `Connections added: ${result.addedConnections.join(", ")}.`, preserveFlowDiagnostics: true, + effect: { kind: "model-access-changed" }, }; } } diff --git a/packages/eve/src/setup/flows/connections.test.ts b/packages/eve/src/setup/flows/connections.test.ts index bc7f0c727..c8dd57ac2 100644 --- a/packages/eve/src/setup/flows/connections.test.ts +++ b/packages/eve/src/setup/flows/connections.test.ts @@ -14,6 +14,7 @@ import { const APP_ROOT = "/app/agent"; const LINKED: DeploymentInfo = { state: "linked", projectId: "prj_1", orgId: "org_1" }; +const UNLINKED: DeploymentInfo = { state: "unlinked" }; function scriptConnectionList(picks: ReadonlyArray) { const queue = [...picks]; @@ -70,6 +71,7 @@ function flowDeps(overrides: Partial = {}): ConnectionsFlow async () => "authenticated", ), listAuthoredConnections: vi.fn(async () => []), + runLinkFlow: vi.fn(async () => ({ kind: "done" })), runPackageManagerInstall: vi.fn(async () => true), addConnections: addConnectionDeps(), ...overrides, @@ -140,20 +142,15 @@ describe("runConnectionsFlow", () => { expect(list.requests[0]?.initialValue).toBe("done"); }); - it("points blocked rows at login or eve link and treats Esc as cancellation", async () => { + it("blocks logged-out rows but leaves unlinked rows selectable", async () => { const loggedOutList = scriptConnectionList(["cancel"]); - const loggedOut = createFakePrompter({ single: loggedOutList.single }); await expect( runConnectionsFlow({ appRoot: APP_ROOT, - prompter: loggedOut.prompter, + prompter: createFakePrompter({ single: loggedOutList.single }).prompter, deps: flowDeps({ - detectDeployment: vi.fn(async () => ({ - state: "unlinked", - })), - getVercelAuthStatus: vi.fn( - async () => "logged-out", - ), + detectDeployment: vi.fn(async () => UNLINKED), + getVercelAuthStatus: vi.fn(async (): Promise<"logged-out"> => "logged-out"), }), }), ).resolves.toEqual({ kind: "cancelled" }); @@ -167,15 +164,70 @@ describe("runConnectionsFlow", () => { appRoot: APP_ROOT, prompter: createFakePrompter({ single: unlinkedList.single }).prompter, deps: flowDeps({ - detectDeployment: vi.fn(async () => ({ - state: "unlinked", - })), + detectDeployment: vi.fn(async () => UNLINKED), }), }); - expect(unlinkedList.paints[0]?.find((row) => row.value === "linear")).toMatchObject({ - disabled: true, - disabledReason: "Run /vc:link first", + expect(unlinkedList.paints[0]?.find((row) => row.value === "linear")).not.toHaveProperty( + "disabled", + ); + }); + + 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", "done"]); + const fake = createFakePrompter({ single: list.single }); + const addConnections = addConnectionDeps(); + const deps = flowDeps({ + detectDeployment, + listAuthoredConnections, + runLinkFlow, + addConnections, }); + + await expect( + runConnectionsFlow({ appRoot: APP_ROOT, prompter: fake.prompter, deps }), + ).resolves.toEqual({ kind: "done", addedConnections: ["linear"] }); + + expect(runLinkFlow).toHaveBeenCalledWith({ + appRoot: APP_ROOT, + prompter: fake.prompter, + signal: undefined, + projectSelection: "create-or-link", + }); + expect(detectDeployment).toHaveBeenCalledTimes(2); + expect(addConnections.setupConnectionConnector).toHaveBeenCalledOnce(); + }); + + it("returns to the connection list when project linking is cancelled", async () => { + const runLinkFlow = vi.fn(async () => ({ + kind: "cancelled", + })); + const list = scriptConnectionList(["linear", "done"]); + const addConnections = addConnectionDeps(); + const deps = flowDeps({ + detectDeployment: vi.fn(async () => UNLINKED), + runLinkFlow, + addConnections, + }); + + await expect( + runConnectionsFlow({ + appRoot: APP_ROOT, + prompter: createFakePrompter({ single: list.single }).prompter, + deps, + }), + ).resolves.toEqual({ kind: "done", addedConnections: [] }); + + expect(addConnections.setupConnectionConnector).not.toHaveBeenCalled(); + expect(list.requests).toHaveLength(2); }); it("does not mutate dependencies when connector selection is cancelled", async () => { diff --git a/packages/eve/src/setup/flows/connections.ts b/packages/eve/src/setup/flows/connections.ts index 9a7f7c21e..3747ac0e9 100644 --- a/packages/eve/src/setup/flows/connections.ts +++ b/packages/eve/src/setup/flows/connections.ts @@ -28,6 +28,7 @@ import { import { withSpinner } from "../with-spinner.js"; import { 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"; @@ -41,6 +42,7 @@ export interface ConnectionsFlowDeps { detectDeployment: typeof detectDeployment; detectPackageManager: typeof detectPackageManager; getVercelAuthStatus: typeof getVercelAuthStatus; + runLinkFlow: typeof runLinkFlow; ensureConnectionDependencies: typeof ensureConnectionDependencies; listAuthoredConnections: typeof listAuthoredConnections; runPackageManagerInstall: typeof runPackageManagerInstall; @@ -52,19 +54,11 @@ export type ConnectionsFlowResult = | { kind: "cancelled" } | { kind: "failed"; addedConnections: readonly string[]; message: string }; -function connectionBlocker( - authStatus: VercelAuthStatus, - projectLinked: boolean, -): string | undefined { - return vercelAuthBlockerReason(authStatus) ?? (projectLinked ? undefined : "Run /vc:link first"); -} - function connectionRows( authored: ReadonlySet, authStatus: VercelAuthStatus, - projectLinked: boolean, ): SelectOption[] { - const blocker = connectionBlocker(authStatus, projectLinked); + const blocker = vercelAuthBlockerReason(authStatus); const rows: SelectOption[] = USER_AUTH_CONNECTIONS.map((entry) => { if (authored.has(entry.slug)) { return { @@ -92,10 +86,9 @@ function connectionRows( async function pickConnection(input: { authored: ReadonlySet; authStatus: VercelAuthStatus; - projectLinked: boolean; prompter: Prompter; }): Promise { - const options = connectionRows(input.authored, input.authStatus, input.projectLinked); + const options = connectionRows(input.authored, input.authStatus); const request: SingleSelectOptions = { message: CONNECTIONS_PROMPT_MESSAGE, options, @@ -139,7 +132,7 @@ async function installConnectionDependencies(input: { } } -/** Runs the searchable `/connect` task list and existing connection setup boxes. */ +/** Runs `/connect`, linking a project on first selection when needed. */ export async function runConnectionsFlow(input: { appRoot: string; prompter: Prompter; @@ -153,6 +146,7 @@ export async function runConnectionsFlow(input: { ensureConnectionDependencies, getVercelAuthStatus, listAuthoredConnections, + runLinkFlow, runPackageManagerInstall, ...input.deps, }; @@ -181,7 +175,6 @@ export async function runConnectionsFlow(input: { const selected = await pickConnection({ authored, authStatus, - projectLinked: isProjectResolved(state.project), prompter, }); if (selected === undefined || selected === "done") { @@ -191,6 +184,26 @@ export async function runConnectionsFlow(input: { } if (authored.has(selected) || !USER_AUTH_CONNECTION_SLUGS.has(selected)) continue; + if (!isProjectResolved(state.project)) { + const link = await deps.runLinkFlow({ + appRoot, + prompter, + signal, + projectSelection: "create-or-link", + }); + if (link.kind === "cancelled") { + if (signal?.aborted) return { kind: "cancelled" }; + continue; + } + + 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 boxes: AnySetupBox[] = [ selectConnections({ asker: interactiveAsker(prompter), presetConnections: [selected] }), addConnections({ From 77e72c8fc8945110622506b9b421aff23f4111eb Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 25 Jun 2026 14:10:59 -0400 Subject: [PATCH 05/11] fix(eve): clarify connect project linking Signed-off-by: Rui Conti --- .../src/setup/boxes/resolve-provisioning.ts | 4 +- .../eve/src/setup/flows/connections.test.ts | 96 ++++++++----------- packages/eve/src/setup/flows/connections.ts | 2 + packages/eve/src/setup/flows/link.test.ts | 10 ++ packages/eve/src/setup/flows/link.ts | 4 +- 5 files changed, 56 insertions(+), 60 deletions(-) diff --git a/packages/eve/src/setup/boxes/resolve-provisioning.ts b/packages/eve/src/setup/boxes/resolve-provisioning.ts index 9abdda65d..3660f5f72 100644 --- a/packages/eve/src/setup/boxes/resolve-provisioning.ts +++ b/packages/eve/src/setup/boxes/resolve-provisioning.ts @@ -77,6 +77,7 @@ export interface ResolveProvisioningOptions { * team selection to the existing-project picker. */ projectSelection?: "create-or-link" | "existing-only"; + teamSelectMessage?: (currentTeam: string) => string; deps?: ResolveProvisioningDeps; } @@ -292,7 +293,8 @@ export function resolveProvisioning( if (deployVercel) { await deps.requireAuth(parent(), prompter, { signal }); - const team = await deps.pickTeam(prompter, parent(), undefined, { signal }); + const teamOptions = { signal, selectMessage: options.teamSelectMessage }; + const team = await deps.pickTeam(prompter, parent(), undefined, teamOptions); const projectOptions = [ { value: "new" as const, diff --git a/packages/eve/src/setup/flows/connections.test.ts b/packages/eve/src/setup/flows/connections.test.ts index c8dd57ac2..633fd8386 100644 --- a/packages/eve/src/setup/flows/connections.test.ts +++ b/packages/eve/src/setup/flows/connections.test.ts @@ -78,6 +78,18 @@ function flowDeps(overrides: Partial = {}): ConnectionsFlow }; } +function runConnectionFlow( + list: ReturnType, + deps: Partial = {}, + prompter = createFakePrompter({ single: list.single }).prompter, +) { + return runConnectionsFlow({ + appRoot: APP_ROOT, + prompter, + deps: flowDeps(deps), + }); +} + describe("runConnectionsFlow", () => { it("adds a catalog connection and repaints the searchable list", async () => { const listAuthoredConnections = vi @@ -91,16 +103,16 @@ describe("runConnectionsFlow", () => { const runPackageManagerInstall = vi.fn(async () => true); await expect( - runConnectionsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: flowDeps({ + runConnectionFlow( + list, + { ensureConnectionDependencies, listAuthoredConnections, runPackageManagerInstall, addConnections, - }), - }), + }, + fake.prompter, + ), ).resolves.toEqual({ kind: "done", addedConnections: ["linear"] }); expect(list.requests[0]).toMatchObject({ @@ -119,11 +131,8 @@ describe("runConnectionsFlow", () => { service: "mcp.linear.app", }), ); - expect(runPackageManagerInstall).toHaveBeenCalledWith("pnpm", APP_ROOT, expect.any(Object)); - expect(ensureConnectionDependencies).toHaveBeenCalledWith({ projectRoot: APP_ROOT }); - expect(runPackageManagerInstall.mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(addConnections.ensureConnection).mock.invocationCallOrder[0]!, - ); + const ensureOrder = vi.mocked(addConnections.ensureConnection).mock.invocationCallOrder[0]!; + expect(runPackageManagerInstall.mock.invocationCallOrder[0]).toBeLessThan(ensureOrder); expect( vi.mocked(addConnections.setupConnectionConnector).mock.invocationCallOrder[0], ).toBeLessThan(runPackageManagerInstall.mock.invocationCallOrder[0]!); @@ -131,45 +140,25 @@ describe("runConnectionsFlow", () => { it("defaults to Done when every catalog connection is already authored", async () => { const list = scriptConnectionList(["done"]); - await runConnectionsFlow({ - appRoot: APP_ROOT, - prompter: createFakePrompter({ single: list.single }).prompter, - deps: flowDeps({ - listAuthoredConnections: vi.fn(async () => ["linear", "notion"]), - }), + await runConnectionFlow(list, { + listAuthoredConnections: vi.fn(async () => ["linear", "notion"]), }); expect(list.requests[0]?.initialValue).toBe("done"); }); - it("blocks logged-out rows but leaves unlinked rows selectable", async () => { + it("blocks logged-out rows", async () => { const loggedOutList = scriptConnectionList(["cancel"]); await expect( - runConnectionsFlow({ - appRoot: APP_ROOT, - prompter: createFakePrompter({ single: loggedOutList.single }).prompter, - deps: flowDeps({ - detectDeployment: vi.fn(async () => UNLINKED), - getVercelAuthStatus: vi.fn(async (): Promise<"logged-out"> => "logged-out"), - }), + runConnectionFlow(loggedOutList, { + detectDeployment: vi.fn(async () => UNLINKED), + getVercelAuthStatus: vi.fn(async (): Promise<"logged-out"> => "logged-out"), }), ).resolves.toEqual({ kind: "cancelled" }); expect(loggedOutList.paints[0]?.find((row) => row.value === "linear")).toMatchObject({ disabled: true, disabledReason: "Log in to Vercel first, see /vc:login", }); - - const unlinkedList = scriptConnectionList(["done"]); - await runConnectionsFlow({ - appRoot: APP_ROOT, - prompter: createFakePrompter({ single: unlinkedList.single }).prompter, - deps: flowDeps({ - detectDeployment: vi.fn(async () => UNLINKED), - }), - }); - expect(unlinkedList.paints[0]?.find((row) => row.value === "linear")).not.toHaveProperty( - "disabled", - ); }); it("runs the shared create-or-link flow before configuring an unlinked project", async () => { @@ -196,12 +185,17 @@ describe("runConnectionsFlow", () => { runConnectionsFlow({ appRoot: APP_ROOT, prompter: fake.prompter, deps }), ).resolves.toEqual({ kind: "done", addedConnections: ["linear"] }); + expect(list.paints[0]?.find((row) => row.value === "linear")).not.toHaveProperty("disabled"); expect(runLinkFlow).toHaveBeenCalledWith({ appRoot: APP_ROOT, prompter: fake.prompter, signal: undefined, projectSelection: "create-or-link", + teamSelectMessage: expect.any(Function), }); + expect(runLinkFlow.mock.calls[0]?.[0].teamSelectMessage?.("Acme")).toBe( + "You need to link to a project to use Vercel Connect.\n\nSelect your team", + ); expect(detectDeployment).toHaveBeenCalledTimes(2); expect(addConnections.setupConnectionConnector).toHaveBeenCalledOnce(); }); @@ -211,23 +205,13 @@ describe("runConnectionsFlow", () => { kind: "cancelled", })); const list = scriptConnectionList(["linear", "done"]); - const addConnections = addConnectionDeps(); - const deps = flowDeps({ - detectDeployment: vi.fn(async () => UNLINKED), - runLinkFlow, - addConnections, - }); await expect( - runConnectionsFlow({ - appRoot: APP_ROOT, - prompter: createFakePrompter({ single: list.single }).prompter, - deps, + runConnectionFlow(list, { + detectDeployment: vi.fn(async () => UNLINKED), + runLinkFlow, }), ).resolves.toEqual({ kind: "done", addedConnections: [] }); - - expect(addConnections.setupConnectionConnector).not.toHaveBeenCalled(); - expect(list.requests).toHaveLength(2); }); it("does not mutate dependencies when connector selection is cancelled", async () => { @@ -240,14 +224,10 @@ describe("runConnectionsFlow", () => { const runPackageManagerInstall = vi.fn(async () => true); await expect( - runConnectionsFlow({ - appRoot: APP_ROOT, - prompter: createFakePrompter({ single: list.single }).prompter, - deps: flowDeps({ - addConnections, - ensureConnectionDependencies, - runPackageManagerInstall, - }), + runConnectionFlow(list, { + addConnections, + ensureConnectionDependencies, + runPackageManagerInstall, }), ).resolves.toEqual({ kind: "cancelled" }); diff --git a/packages/eve/src/setup/flows/connections.ts b/packages/eve/src/setup/flows/connections.ts index 3747ac0e9..1837bd56d 100644 --- a/packages/eve/src/setup/flows/connections.ts +++ b/packages/eve/src/setup/flows/connections.ts @@ -190,6 +190,8 @@ export async function runConnectionsFlow(input: { 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") { if (signal?.aborted) return { kind: "cancelled" }; diff --git a/packages/eve/src/setup/flows/link.test.ts b/packages/eve/src/setup/flows/link.test.ts index 774850c0c..b9ae1516c 100644 --- a/packages/eve/src/setup/flows/link.test.ts +++ b/packages/eve/src/setup/flows/link.test.ts @@ -129,6 +129,8 @@ describe("runLinkFlow", () => { }); it("offers create when the caller passes create-or-link (the /model branch)", async () => { + const teamSelectMessage = () => + "You need to link to a project to use Vercel Connect.\n\nSelect your team"; const { prompter } = createFakePrompter({ single: (opts) => { if (stripVTControlCharacters(opts.message) === "Vercel project") return "new"; @@ -142,6 +144,7 @@ describe("runLinkFlow", () => { prompter, deps, projectSelection: "create-or-link", + teamSelectMessage, }); expect(result).toEqual({ kind: "done", credential: "VERCEL_OIDC_TOKEN" }); @@ -149,6 +152,13 @@ describe("runLinkFlow", () => { // existing-project picker — the opposite of the existing-only default. expect(deps.resolveProvisioning?.pickNewProjectName).toHaveBeenCalled(); expect(deps.resolveProvisioning?.pickProject).not.toHaveBeenCalled(); + expect(deps.resolveProvisioning?.pickTeam).toHaveBeenCalledWith( + expect.anything(), + APP_ROOT, + undefined, + expect.objectContaining({ selectMessage: teamSelectMessage }), + ); + expect(prompter.log.message).not.toHaveBeenCalled(); }); it("runs the pickers on re-link even when the on-disk link is adoptable", async () => { diff --git a/packages/eve/src/setup/flows/link.ts b/packages/eve/src/setup/flows/link.ts index 27088d42d..3db9afd63 100644 --- a/packages/eve/src/setup/flows/link.ts +++ b/packages/eve/src/setup/flows/link.ts @@ -64,6 +64,7 @@ export async function runLinkFlow(input: { * branch, where a fresh agent has no project yet). */ projectSelection?: "create-or-link" | "existing-only"; + teamSelectMessage?: (currentTeam: string) => string; deps?: Partial; }): Promise { const { appRoot, prompter, signal, projectSelection = "existing-only" } = input; @@ -87,7 +88,7 @@ export async function runLinkFlow(input: { deps.findEnvFileWithKey(appRoot, "VERCEL_OIDC_TOKEN"), ]); const credentialFile = gatewayKey ?? oidc; - if (credentialFile !== undefined) { + if (credentialFile !== undefined && input.teamSelectMessage === undefined) { prompter.log.message( `This directory is not linked to a Vercel project yet — the model currently runs on credentials from ${credentialFile}.`, ); @@ -134,6 +135,7 @@ export async function runLinkFlow(input: { // agent can create its first project here instead of dead-ending on an // empty project list. projectSelection, + teamSelectMessage: input.teamSelectMessage, deps: deps.resolveProvisioning, }), linkVercelProject({ prompter, deps: deps.linkProject }), From e76527568cc5cbc2cf7b441588d56b84db427da2 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 25 Jun 2026 15:26:29 -0400 Subject: [PATCH 06/11] fix(eve): normalize setup task lists Signed-off-by: Rui Conti --- .../src/cli/dev/tui/setup-commands.test.ts | 65 +++------ packages/eve/src/cli/dev/tui/setup-flow.ts | 1 + .../eve/src/cli/dev/tui/setup-panel.test.ts | 36 +++-- packages/eve/src/cli/dev/tui/setup-panel.ts | 35 +++-- .../src/cli/dev/tui/terminal-renderer.test.ts | 14 +- .../eve/src/cli/dev/tui/terminal-renderer.ts | 2 +- .../eve/src/cli/dev/tui/tui-prompter.test.ts | 2 + packages/eve/src/cli/dev/tui/tui-prompter.ts | 5 +- .../src/setup/boxes/add-connections.test.ts | 78 +++++------ packages/eve/src/setup/cli/prompt-ui.ts | 2 + .../eve/src/setup/cli/select-state.test.ts | 16 ++- packages/eve/src/setup/cli/select-state.ts | 15 ++- .../connection-connector.integration.test.ts | 60 +++------ .../eve/src/setup/connection-connector.ts | 31 +---- packages/eve/src/setup/flows/channels.test.ts | 2 +- packages/eve/src/setup/flows/channels.ts | 2 +- .../eve/src/setup/flows/connections.test.ts | 123 +++++------------- packages/eve/src/setup/flows/connections.ts | 88 +++++-------- packages/eve/src/setup/prompter.ts | 2 + 19 files changed, 232 insertions(+), 347 deletions(-) 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 2264b285c..83c41b016 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.test.ts @@ -326,58 +326,29 @@ describe("runTuiSetupCommand", () => { }); }); - it("reports configured connections", async () => { - const flows = fakeFlows({ - runConnectionsFlow: vi.fn(async () => ({ - kind: "done", - addedConnections: ["linear", "notion"], - })), - }); - - await expect(run({ command: "connect", flows })).resolves.toEqual({ - message: "Connections added: linear, notion.", - preserveFlowDiagnostics: true, - effect: { kind: "model-access-changed" }, - }); - expect(flows.runConnectionsFlow).toHaveBeenCalledWith( - expect.objectContaining({ appRoot: APP_ROOT }), - ); - }); - - it("reports empty, cancelled, and partially failed connection flows", async () => { - await expect(run({ command: "connect", flows: fakeFlows() })).resolves.toEqual({ - message: "No connections added.", - preserveFlowDiagnostics: true, - effect: { kind: "model-access-changed" }, - }); + it.each([ + [ + "configured", + { kind: "done", addedConnections: ["linear", "notion"] }, + "Connections added: linear, notion.", + ], + ["empty", { kind: "done", addedConnections: [] }, "No connections added."], + ["cancelled", { kind: "cancelled" }, "/connect cancelled."], + [ + "partially failed", + { kind: "failed", addedConnections: ["linear"], message: "install failed" }, + "Connection files changed, but /connect failed: install failed", + ], + ] as const)("reports %s connection flows", async (_case, result, message) => { + const runConnectionsFlow = vi.fn(async () => result); await expect( - run({ - command: "connect", - flows: fakeFlows({ - runConnectionsFlow: async () => ({ kind: "cancelled" }), - }), - }), - ).resolves.toEqual({ - message: "/connect cancelled.", - preserveFlowDiagnostics: true, - effect: { kind: "model-access-changed" }, - }); - await expect( - run({ - command: "connect", - flows: fakeFlows({ - runConnectionsFlow: async () => ({ - kind: "failed", - addedConnections: ["linear"], - message: "install failed", - }), - }), - }), + run({ command: "connect", flows: fakeFlows({ runConnectionsFlow }) }), ).resolves.toEqual({ - message: "Connection files changed, but /connect failed: install failed", + message, preserveFlowDiagnostics: true, effect: { kind: "model-access-changed" }, }); + expect(runConnectionsFlow).toHaveBeenCalledWith(expect.objectContaining({ appRoot: APP_ROOT })); }); it("keeps deploy pending when channel files landed before a sub-flow failure", async () => { diff --git a/packages/eve/src/cli/dev/tui/setup-flow.ts b/packages/eve/src/cli/dev/tui/setup-flow.ts index 2df002cfb..affbf616a 100644 --- a/packages/eve/src/cli/dev/tui/setup-flow.ts +++ b/packages/eve/src/cli/dev/tui/setup-flow.ts @@ -32,6 +32,7 @@ interface SetupSearchAction extends SearchActionOption { interface SetupSearchSelectRequest extends SetupSelectRequestBase { kind: "search"; + layout?: "task-list"; initialValue?: string; placeholder?: string; searchAction?: SetupSearchAction; diff --git a/packages/eve/src/cli/dev/tui/setup-panel.test.ts b/packages/eve/src/cli/dev/tui/setup-panel.test.ts index 85a5150be..64a40c4b6 100644 --- a/packages/eve/src/cli/dev/tui/setup-panel.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-panel.test.ts @@ -247,11 +247,12 @@ describe("renderSelectQuestion", () => { focusHint: "Already installed", }, { value: "slack", label: "Slack", hint: "Creates slackbot and deploys to Vercel" }, - { value: "done", label: "Done" }, + { value: "done", label: "Done", trailingAction: true }, ]; const rows = renderSelectQuestion( { - kind: "task-list", + kind: "search", + layout: "task-list", message: "Where will you chat with your agent?", options, notices: [ @@ -269,16 +270,19 @@ describe("renderSelectQuestion", () => { expect(rows).not.toContain(" ✓ Terminal UI"); // An unfocused completed row keeps its check. expect(rows).toContain(" ✓ Web Chat"); - expect(rows).toContain(" ◦ Slack · Creates slackbot and deploys to Vercel"); expect(rows).toContain(" Done"); - expect(rows).toContain(" ⚠ Overwrote /tmp/weather-agent"); - expect(rows).toContain(" ✓ Scaffolded channel: web"); - expect(rows.indexOf(" Done")).toBeLessThan( - rows.indexOf(" ⚠ Overwrote /tmp/weather-agent"), + const warning = rows.indexOf(" ⚠ Overwrote /tmp/weather-agent"); + const success = rows.indexOf(" ✓ Scaffolded channel: web"); + const done = rows.indexOf(" Done"); + expect(rows.indexOf(" ◦ Slack · Creates slackbot and deploys to Vercel")).toBeLessThan( + warning, ); + expect(warning).toBeLessThan(success); + expect(success).toBeLessThan(done); + expect([rows[warning - 1], rows[done - 1]]).toEqual(["", ""]); expect(rows.at(-1)).toContain("↑/↓ move · enter to select · esc to cancel"); - const coloredRow = renderSelectQuestion( + const coloredRows = renderSelectQuestion( { kind: "task-list", message: "Where will you chat with your agent?", @@ -287,7 +291,9 @@ describe("renderSelectQuestion", () => { }, colorTheme, 80, - ).find((row) => row.includes("Terminal UI")); + ); + const coloredRow = coloredRows.find((row) => row.includes("Terminal UI")); + expect(coloredRows[coloredRows.findIndex((row) => row.includes("Done")) - 1]).toBe(""); // Focused completed row: dim pointer matching the dim label, never green or cyan. expect(coloredRow).toContain("\x1b[2m▷\x1b[22m"); expect(coloredRow).toContain("\x1b[2mTerminal UI\x1b[22m"); @@ -394,11 +400,14 @@ describe("renderSelectQuestion", () => { it("wraps a long notice with a hanging indent under the glyph", () => { const plain = createTheme({ color: false }); - const options = [{ value: "done", label: "Done" }]; + const options = [ + { value: "find", label: "Find" }, + { value: "create", label: "Create" }, + ]; const rows = renderSelectQuestion( { - kind: "single", - message: "Configure the agent model", + kind: "task-list", + message: "Select a connector", options, select: initialSelectState({ options }), notices: [ @@ -411,6 +420,9 @@ describe("renderSelectQuestion", () => { const first = rows.find((line) => line.includes("⚠")); expect(first).toMatch(/⚠ alpha/); + expect(rows.findIndex((line) => line.includes("Create"))).toBeLessThan( + rows.indexOf(first ?? ""), + ); // A continuation line is indented and carries no glyph. const continuation = rows.find((line) => !line.includes("⚠") && /^\s{3,}\S/.test(line)); expect(continuation).toBeDefined(); diff --git a/packages/eve/src/cli/dev/tui/setup-panel.ts b/packages/eve/src/cli/dev/tui/setup-panel.ts index 936bc4165..414187efd 100644 --- a/packages/eve/src/cli/dev/tui/setup-panel.ts +++ b/packages/eve/src/cli/dev/tui/setup-panel.ts @@ -95,7 +95,11 @@ interface SetupInlineEditRow { */ type SetupOptionSelectPanelState = | (SetupSelectPanelBase & { kind: "single" }) - | (SetupSelectPanelBase & { kind: "search"; placeholder?: string }) + | (SetupSelectPanelBase & { + kind: "search"; + layout?: "task-list"; + placeholder?: string; + }) | (SetupSelectPanelBase & { kind: "multi" }) | (SetupSelectPanelBase & { kind: "searchable-multi"; placeholder?: string }) | (SetupSelectPanelBase & { kind: "stacked" }) @@ -350,7 +354,7 @@ function selectPresentation(state: SetupOptionSelectPanelState): SelectPresentat return { selection: "single", filter: { placeholder: state.placeholder }, - layout: "plain", + layout: state.layout ?? "plain", edit: undefined, }; case "multi": @@ -501,13 +505,10 @@ function optionWithoutStackedHint( function optionUsesPlaceholder( presentation: SelectPresentation, - index: number, - optionCount: number, + isTrailingTaskAction: boolean, ): boolean { // A type-ahead list draws no placeholder dots — the filter row leads instead. - const isFiltered = presentation.filter !== undefined; - // The task list's trailing action (Done) reads as a button, not an option. - const isTrailingTaskAction = presentation.layout === "task-list" && index === optionCount - 1; + const isFiltered = presentation.filter !== undefined && presentation.layout !== "task-list"; // Checklists and the explicit menu layouts (stacked, task-list) present every // row as a pickable option, so each carries the placeholder dot. const isMultiSelect = presentation.selection === "multiple"; @@ -527,7 +528,7 @@ function appendSelectOptionRows(input: { visibleLabelWidth: number; width: number; theme: Theme; -}): void { +}): boolean { const { rows, state, @@ -541,11 +542,18 @@ function appendSelectOptionRows(input: { theme, } = input; const c = theme.colors; + let renderedTrailingTaskAction = false; for (let index = start; index < end; index += 1) { const option = visible[index]!; const isCursor = index === cursor; - if (presentation.layout === "task-list" && index === end - 1 && index > start) { + const isTrailingTaskAction = + presentation.layout === "task-list" && option.trailingAction === true; + if (isTrailingTaskAction) { + appendSelectNotices(rows, state.notices, presentation.layout, theme, width); + renderedTrailingTaskAction = true; + } + if (isTrailingTaskAction && (index > start || (state.notices?.length ?? 0) > 0)) { rows.push(""); } @@ -563,7 +571,7 @@ function appendSelectOptionRows(input: { option: rowOption, isCursor, isChecked: presentation.selection === "multiple" && state.select.selected.has(option.value), - placeholder: optionUsesPlaceholder(presentation, index, visible.length), + placeholder: optionUsesPlaceholder(presentation, isTrailingTaskAction), hintPadding: Math.max(0, visibleLabelWidth - rowOption.label.length), theme, })}`, @@ -598,6 +606,7 @@ function appendSelectOptionRows(input: { } if (presentation.layout === "stacked" && index < end - 1) rows.push(""); } + return renderedTrailingTaskAction; } function appendSubmitRow(rows: string[], cursor: number, submitIndex: number, theme: Theme): void { @@ -746,7 +755,7 @@ export function renderSelectQuestion( rows.push(` ${c.dim("(no matches)")}`); } - appendSelectOptionRows({ + const renderedTrailingTaskAction = appendSelectOptionRows({ rows, state, presentation, @@ -764,7 +773,9 @@ export function renderSelectQuestion( rows.push(` ${c.dim(`↑↓ ${visible.length} options, showing ${start + 1}–${end}`)}`); } - appendSelectNotices(rows, state.notices, presentation.layout, theme, width); + if (!renderedTrailingTaskAction) { + appendSelectNotices(rows, state.notices, presentation.layout, theme, width); + } if (state.error !== undefined) { rows.push("", ` ${c.red(state.error)}`); diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index 772cc99ba..e01688ab3 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -2055,7 +2055,7 @@ describe("TerminalRenderer setup flow session", () => { focusHint: "Already installed", }, { value: "slack", label: "Slack", hint: "Creates slackbot and deploys to Vercel" }, - { value: "done", label: "Done" }, + { value: "done", label: "Done", trailingAction: true }, ]; renderer.setupFlow.begin("Agent channels"); @@ -2074,7 +2074,8 @@ describe("TerminalRenderer setup flow session", () => { "warning", ); const second = renderer.setupFlow.readSelect({ - kind: "task-list", + kind: "search", + layout: "task-list", message: "Where will you chat with your agent?", options, }); @@ -2101,11 +2102,14 @@ describe("TerminalRenderer setup flow session", () => { expect(snapshot).not.toContain("✓ Terminal UI"); expect(snapshot).toContain("✓ Web Chat"); expect(snapshot).toContain("Slack · Creates slackbot and deploys to Vercel"); - expect(snapshot.indexOf("Done")).toBeLessThan(snapshot.indexOf("Overwrote /tmp/weather-agent")); + expect(snapshot.indexOf("Slack · Creates slackbot and deploys to Vercel")).toBeLessThan( + snapshot.indexOf("Overwrote /tmp/weather-agent"), + ); expect(snapshot.indexOf("Overwrote /tmp/weather-agent")).toBeLessThan( snapshot.indexOf("Scaffolded channel: web"), ); - expect(snapshot.indexOf("Scaffolded channel: web")).toBeLessThan(snapshot.indexOf("↑/↓ move")); + expect(snapshot.indexOf("Scaffolded channel: web")).toBeLessThan(snapshot.indexOf("Done")); + expect(snapshot.indexOf("Done")).toBeLessThan(snapshot.indexOf("↑/↓ move")); expect(snapshot).toContain("Dependency installation failed."); input.send("\x1b"); @@ -2126,7 +2130,7 @@ describe("TerminalRenderer setup flow session", () => { completed: true, focusHint: "Already installed", }, - { value: "done", label: "Done" }, + { value: "done", label: "Done", trailingAction: true }, ], }); let settled = false; diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 1aee06c58..323a41f31 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -1303,7 +1303,7 @@ export class TerminalRenderer implements AgentTUIRenderer { }; let notices = opts.notices; - if (opts.kind === "task-list") { + if (opts.kind === "task-list" || (opts.kind === "search" && opts.layout === "task-list")) { const start = flow.taskListLineStart ?? flow.lines.length; const outcomes: SelectNotice[] = flow.lines .slice(start) diff --git a/packages/eve/src/cli/dev/tui/tui-prompter.test.ts b/packages/eve/src/cli/dev/tui/tui-prompter.test.ts index 7887fb634..d975340f2 100644 --- a/packages/eve/src/cli/dev/tui/tui-prompter.test.ts +++ b/packages/eve/src/cli/dev/tui/tui-prompter.test.ts @@ -48,6 +48,7 @@ describe("createTuiPrompter", () => { prompter.select({ message: "Project to link", search: true, + hintLayout: "inline", searchAction: { label: (query) => `Search for '${query}'`, value: (query) => `search:${query}`, @@ -58,6 +59,7 @@ describe("createTuiPrompter", () => { expect(renderer.readSelect).toHaveBeenCalledWith( expect.objectContaining({ kind: "search", + layout: "task-list", searchAction: { label: expect.any(Function) }, }), ); diff --git a/packages/eve/src/cli/dev/tui/tui-prompter.ts b/packages/eve/src/cli/dev/tui/tui-prompter.ts index fe0bdc5ca..f231b3880 100644 --- a/packages/eve/src/cli/dev/tui/tui-prompter.ts +++ b/packages/eve/src/cli/dev/tui/tui-prompter.ts @@ -60,13 +60,14 @@ function setupSelectRequest( return withNotices(request); } - if (opts.search === true && opts.hintLayout !== undefined) { - throw new Error("Searchable setup questions do not support a hint layout."); + if (opts.search === true && opts.hintLayout === "stacked") { + throw new Error("Searchable setup questions do not support a stacked hint layout."); } let request: SetupSelectRequest; if (opts.search === true) { request = { ...base, kind: "search" }; + if (opts.hintLayout === "inline") request.layout = "task-list"; if (opts.placeholder !== undefined) request.placeholder = opts.placeholder; if (opts.searchAction !== undefined) { request.searchAction = { label: opts.searchAction.label }; diff --git a/packages/eve/src/setup/boxes/add-connections.test.ts b/packages/eve/src/setup/boxes/add-connections.test.ts index d945bb116..7eddaf1ac 100644 --- a/packages/eve/src/setup/boxes/add-connections.test.ts +++ b/packages/eve/src/setup/boxes/add-connections.test.ts @@ -75,6 +75,28 @@ function createDeps() { }; } +function createdConnectorDeps() { + const deps = createDeps(); + deps.setupConnectionConnector.mockResolvedValueOnce({ + kind: "created", + connectorUid: "linear/new", + connectorId: "scl_new", + }); + return deps; +} + +function runCreatedConnector( + deps: ReturnType, + beforeScaffold: (projectRoot: string) => Promise = async () => {}, +) { + return runInteractive( + makeBoxes({ prompter: createPrompter(), presetConnections: ["linear"], deps, beforeScaffold }), + resolvedState(), + silentSink, + snapshot, + ); +} + function resolvedState(): SetupState { const state = createDefaultSetupState(); state.projectPath = { kind: "resolved", inPlace: false, path: "/tmp/project" }; @@ -310,46 +332,23 @@ describe("selectConnections + addConnections boxes", () => { }); test("removes a connector created before a scaffold failure", async () => { - const deps = createDeps(); - deps.setupConnectionConnector.mockResolvedValueOnce({ - kind: "created", - connectorUid: "linear/new", - connectorId: "scl_new", - }); + const deps = createdConnectorDeps(); deps.ensureConnection.mockRejectedValueOnce(new Error("write failed")); - const boxes = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["linear"], - deps, - }); - await expect(runInteractive(boxes, resolvedState(), silentSink, snapshot)).rejects.toThrow( - "write failed", - ); + await expect(runCreatedConnector(deps)).rejects.toThrow("write failed"); expect(deps.cleanupCreatedConnectionConnector).toHaveBeenCalledWith( expect.objectContaining({ connectorId: "scl_new" }), ); }); test("removes a created connector when pre-scaffold preparation fails", async () => { - const deps = createDeps(); - deps.setupConnectionConnector.mockResolvedValueOnce({ - kind: "created", - connectorUid: "linear/new", - connectorId: "scl_new", - }); - const boxes = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["linear"], - deps, - beforeScaffold: async () => { - throw new Error("install failed"); - }, - }); + const deps = createdConnectorDeps(); - await expect(runInteractive(boxes, resolvedState(), silentSink, snapshot)).rejects.toThrow( - "install failed", - ); + await expect( + runCreatedConnector(deps, async () => { + throw new Error("install failed"); + }), + ).rejects.toThrow("install failed"); expect(deps.ensureConnection).not.toHaveBeenCalled(); expect(deps.cleanupCreatedConnectionConnector).toHaveBeenCalledWith( expect.objectContaining({ connectorId: "scl_new" }), @@ -357,25 +356,12 @@ describe("selectConnections + addConnections boxes", () => { }); test("reports both scaffold and connector cleanup failures", async () => { - const deps = createDeps(); - deps.setupConnectionConnector.mockResolvedValueOnce({ - kind: "created", - connectorUid: "linear/new", - connectorId: "scl_new", - }); + const deps = createdConnectorDeps(); deps.ensureConnection.mockRejectedValueOnce(new Error("write failed")); deps.cleanupCreatedConnectionConnector.mockRejectedValueOnce( new Error("remove scl_new manually"), ); - const boxes = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["linear"], - deps, - }); - - await expect(runInteractive(boxes, resolvedState(), silentSink, snapshot)).rejects.toThrow( - "write failed 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 () => { diff --git a/packages/eve/src/setup/cli/prompt-ui.ts b/packages/eve/src/setup/cli/prompt-ui.ts index 795ec442e..2553b5993 100644 --- a/packages/eve/src/setup/cli/prompt-ui.ts +++ b/packages/eve/src/setup/cli/prompt-ui.ts @@ -34,6 +34,8 @@ export interface PromptColors { export interface PromptOption { value: T; label: string; + /** Completion action kept after searchable results instead of being filtered. */ + trailingAction?: boolean; /** Supporting copy; stacked prompts render newline-separated text on separate rows. */ hint?: string; /** Short inline annotation shown dimmed only while the cursor is on this row. */ diff --git a/packages/eve/src/setup/cli/select-state.test.ts b/packages/eve/src/setup/cli/select-state.test.ts index 1b6fd213a..0f7863d33 100644 --- a/packages/eve/src/setup/cli/select-state.test.ts +++ b/packages/eve/src/setup/cli/select-state.test.ts @@ -49,15 +49,21 @@ describe("filterOptions", () => { }); it("returns nothing when no option matches", () => { - expect(filterOptions(OPTIONS, "zzz")).toEqual([]); + const done = { value: "done", label: "Done", trailingAction: true }; + expect(filterOptions([...OPTIONS, done], "zzz")).toEqual([done]); }); it("appends the search action after local matches", () => { - const visible = filterOptions([{ value: "veto", label: "veto" }], "v", { - label: (query) => `Search for '${query}'`, - }); + const visible = filterOptions( + [ + { value: "veto", label: "veto" }, + { value: "done", label: "Done", trailingAction: true }, + ], + "v", + { label: (query) => `Search for '${query}'` }, + ); - expect(visible.map((option) => option.label)).toEqual(["veto", "Search for 'v'"]); + expect(visible.map((option) => option.label)).toEqual(["veto", "Search for 'v'", "Done"]); expect(searchActionQuery(visible[1]?.value ?? "")).toBe("v"); }); diff --git a/packages/eve/src/setup/cli/select-state.ts b/packages/eve/src/setup/cli/select-state.ts index 8f8289d0d..88dd53cf0 100644 --- a/packages/eve/src/setup/cli/select-state.ts +++ b/packages/eve/src/setup/cli/select-state.ts @@ -76,13 +76,16 @@ export function filterOptions( const normalizedQuery = query.toLowerCase(); const matches = options.filter( (option) => - option.label.toLowerCase().includes(normalizedQuery) || - option.value.toLowerCase().includes(normalizedQuery) || - (option.hint?.toLowerCase().includes(normalizedQuery) ?? false) || - (option.focusHint?.toLowerCase().includes(normalizedQuery) ?? false), + option.trailingAction !== true && + (option.label.toLowerCase().includes(normalizedQuery) || + option.value.toLowerCase().includes(normalizedQuery) || + (option.hint?.toLowerCase().includes(normalizedQuery) ?? false) || + (option.focusHint?.toLowerCase().includes(normalizedQuery) ?? false)), ); - if (searchAction === undefined) return matches; - return [...matches, { value: searchActionValue(query), label: searchAction.label(query) }]; + if (searchAction !== undefined) { + matches.push({ value: searchActionValue(query), label: searchAction.label(query) }); + } + return [...matches, ...options.filter((option) => option.trailingAction === true)]; } /** A row the cursor can land on: neither disabled nor locked. */ diff --git a/packages/eve/src/setup/connection-connector.integration.test.ts b/packages/eve/src/setup/connection-connector.integration.test.ts index d65ca9bb1..b83fe44cf 100644 --- a/packages/eve/src/setup/connection-connector.integration.test.ts +++ b/packages/eve/src/setup/connection-connector.integration.test.ts @@ -25,6 +25,14 @@ const create = vi.mocked(runVercelCaptureStdout); const SERVICE = "mcp.linear.app"; const CANONICAL_UID = "mcp.linear.app/linear"; +function jsonResult(value: unknown) { + return { ok: true as const, stdout: JSON.stringify(value) }; +} + +function connectorResult(uid: string, id: string, subject: "app" | "user") { + return jsonResult({ uid, id, service: SERVICE, supportedSubjectTypes: [subject] }); +} + describe("connector response parsing", () => { it("parses terminal JSON and rejects created connectors without user support", () => { const response = { uid: "linear/acme", id: "scl_1", supportedSubjectTypes: ["user"] }; @@ -94,35 +102,15 @@ describe("setupConnectionConnector", () => { it("paginates and offers only existing connectors that support user authorization", async () => { run.mockResolvedValueOnce(false).mockResolvedValueOnce(true); capture - .mockResolvedValueOnce({ - ok: true, - stdout: JSON.stringify({ + .mockResolvedValueOnce( + jsonResult({ connectors: [{ uid: "linear/app", id: "scl_app" }], cursor: "next_page", }), - }) - .mockResolvedValueOnce({ - ok: true, - stdout: JSON.stringify({ connectors: [{ uid: "linear/user", id: "scl_user" }] }), - }) - .mockResolvedValueOnce({ - ok: true, - stdout: JSON.stringify({ - uid: "linear/app", - id: "scl_app", - service: SERVICE, - supportedSubjectTypes: ["app"], - }), - }) - .mockResolvedValueOnce({ - ok: true, - stdout: JSON.stringify({ - uid: "linear/user", - id: "scl_user", - service: SERVICE, - supportedSubjectTypes: ["user"], - }), - }); + ) + .mockResolvedValueOnce(jsonResult({ connectors: [{ uid: "linear/user", id: "scl_user" }] })) + .mockResolvedValueOnce(connectorResult("linear/app", "scl_app", "app")) + .mockResolvedValueOnce(connectorResult("linear/user", "scl_user", "user")); const answers = ["find", "linear/user"]; const fake = createFakePrompter({ single: () => answers.shift()! }); @@ -142,20 +130,10 @@ describe("setupConnectionConnector", () => { it("removes a created connector when attach fails", async () => { run.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValueOnce(true); - capture.mockResolvedValue({ - ok: true, - stdout: JSON.stringify({ - connectors: [{ uid: CANONICAL_UID, id: "scl_existing", name: "Linear" }], - }), - }); - create.mockResolvedValue({ - ok: true, - stdout: JSON.stringify({ - uid: "linear/linear-2", - id: "scl_created", - supportedSubjectTypes: ["user"], - }), - }); + capture.mockResolvedValue( + jsonResult({ connectors: [{ uid: CANONICAL_UID, id: "scl_existing", name: "Linear" }] }), + ); + create.mockResolvedValue(connectorResult("linear/linear-2", "scl_created", "user")); const fake = createFakePrompter({ single: () => "create", text: (input) => input.defaultValue!, @@ -176,7 +154,7 @@ describe("setupConnectionConnector", () => { it("recovers a partially created connector id from CLI progress and removes it", async () => { run.mockResolvedValueOnce(false).mockResolvedValueOnce(true); - capture.mockResolvedValue({ ok: true, stdout: JSON.stringify({ connectors: [] }) }); + capture.mockResolvedValue(jsonResult({ connectors: [] })); create.mockImplementation(async (_args, createOptions) => { createOptions.onOutput?.({ stream: "stderr", text: "Connector created: scl_partial" }); return { ok: false, stdout: "" }; diff --git a/packages/eve/src/setup/connection-connector.ts b/packages/eve/src/setup/connection-connector.ts index dd7efa188..aea6e399b 100644 --- a/packages/eve/src/setup/connection-connector.ts +++ b/packages/eve/src/setup/connection-connector.ts @@ -1,10 +1,9 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; import { createPromptCommandOutput, type ChannelSetupLog, withPhase } from "#setup/cli/index.js"; import type { ProcessOutputHandler } from "#setup/primitives/process-output.js"; import type { Prompter } from "#setup/prompter.js"; +import { readProjectLink, type VercelProjectReference } from "#setup/project-resolution.js"; import { captureVercel, runVercel, runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; /** Controls connector selection while adding a Connect-backed connection. */ @@ -30,11 +29,6 @@ export type SetupConnectionConnectorResult = | { kind: "existing"; connectorUid: string } | { kind: "created"; connectorUid: string; connectorId: string }; -interface ProjectLink { - projectId: string; - orgId: string; -} - type ConnectorResolution = | { kind: "existing"; connector: ConnectConnectorRef } | { kind: "created"; connector: ConnectConnectorRef }; @@ -92,22 +86,9 @@ export function parseConnectors(value: unknown, service: string): ConnectConnect return connectors; } -async function readProjectLink(projectRoot: string): Promise { - try { - const value: unknown = JSON.parse( - await readFile(join(projectRoot, ".vercel", "project.json"), "utf8"), - ); - return isRecord(value) && - typeof value["projectId"] === "string" && - typeof value["orgId"] === "string" - ? { projectId: value["projectId"], orgId: value["orgId"] } - : undefined; - } catch { - return undefined; - } -} - -async function ensureLinkedProject(options: SetupConnectionConnectorOptions): Promise { +async function ensureLinkedProject( + options: SetupConnectionConnectorOptions, +): Promise { const expectedProjectId = await options.linkProject(); const project = await readProjectLink(options.projectRoot); if (project === undefined || project.projectId !== expectedProjectId) { @@ -149,7 +130,7 @@ async function listConnectors( async function supportsUserAuthorization( options: SetupConnectionConnectorOptions, - project: ProjectLink, + project: VercelProjectReference, connector: ConnectConnectorRef, onOutput: ProcessOutputHandler, ): Promise { @@ -240,7 +221,7 @@ async function attach( async function resolveFallbackConnector( options: SetupConnectionConnectorOptions, - project: ProjectLink, + project: VercelProjectReference, onOutput: ProcessOutputHandler, initialNotice: string, ): Promise { diff --git a/packages/eve/src/setup/flows/channels.test.ts b/packages/eve/src/setup/flows/channels.test.ts index a9748d800..878f1441e 100644 --- a/packages/eve/src/setup/flows/channels.test.ts +++ b/packages/eve/src/setup/flows/channels.test.ts @@ -152,7 +152,7 @@ describe("runChannelsFlow", () => { expect(result).toEqual({ kind: "done", addedChannels: [] }); expect(addChannelsDeps.ensureChannel).not.toHaveBeenCalled(); - expect(listPaints[0]?.at(-1)).toMatchObject({ value: "done", label: "Done" }); + expect(listPaints[0]?.at(-1)).toMatchObject({ value: "done", trailingAction: true }); }); it("defaults the cursor to Done when every channel is already added or unavailable", async () => { diff --git a/packages/eve/src/setup/flows/channels.ts b/packages/eve/src/setup/flows/channels.ts index 1fb7fc8fa..05156335c 100644 --- a/packages/eve/src/setup/flows/channels.ts +++ b/packages/eve/src/setup/flows/channels.ts @@ -216,7 +216,7 @@ function channelListRows( if (channel.hint !== undefined) row.hint = channel.hint; rows.push(row); } - rows.push({ value: "done", label: "Done" }); + rows.push({ value: "done", label: "Done", trailingAction: true }); return rows; } diff --git a/packages/eve/src/setup/flows/connections.test.ts b/packages/eve/src/setup/flows/connections.test.ts index 633fd8386..7801d54e5 100644 --- a/packages/eve/src/setup/flows/connections.test.ts +++ b/packages/eve/src/setup/flows/connections.test.ts @@ -3,7 +3,7 @@ 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, SelectOption, SingleSelectOptions } from "#setup/prompter.js"; +import type { PrompterValue, SingleSelectOptions } from "#setup/prompter.js"; import { WizardCancelledError } from "#setup/step.js"; import { @@ -14,21 +14,16 @@ import { const APP_ROOT = "/app/agent"; const LINKED: DeploymentInfo = { state: "linked", projectId: "prj_1", orgId: "org_1" }; -const UNLINKED: DeploymentInfo = { state: "unlinked" }; -function scriptConnectionList(picks: ReadonlyArray) { - const queue = [...picks]; - const paints: SelectOption[][] = []; +function scriptConnectionList(queue: Array) { const requests: SingleSelectOptions[] = []; return { - paints, requests, single(options: SingleSelectOptions): PrompterValue { if (options.message !== CONNECTIONS_PROMPT_MESSAGE) { throw new Error(`Unexpected select: ${options.message}`); } requests.push(options); - paints.push(options.options); const next = queue.shift(); if (next === undefined) throw new Error("Connection list exhausted its scripted picks."); if (next === "cancel") throw new WizardCancelledError(); @@ -50,43 +45,32 @@ function addConnectionDeps(): AddConnectionsDeps { envKeysAdded: [], envKeysRequired: [], })), - setupConnectionConnector: vi.fn(async () => ({ - kind: "existing", - connectorUid: "mcp.linear.app/linear", - })), + setupConnectionConnector: vi.fn(async () => + Object.freeze({ kind: "existing", connectorUid: "mcp.linear.app/linear" }), + ), listAuthoredConnections: vi.fn(async () => []), cleanupCreatedConnectionConnector: vi.fn(async () => {}), }; } -function flowDeps(overrides: Partial = {}): ConnectionsFlowDeps { - return { +function runConnectionFlow( + list: ReturnType, + deps: Partial = {}, +) { + const defaults: ConnectionsFlowDeps = { detectDeployment: vi.fn(async () => LINKED), - detectPackageManager: vi.fn(async () => ({ - kind: "pnpm", - source: "default", - })), + detectPackageManager: vi.fn(async () => Object.freeze({ kind: "pnpm", source: "default" })), ensureConnectionDependencies: vi.fn(async () => []), - getVercelAuthStatus: vi.fn( - async () => "authenticated", - ), + getVercelAuthStatus: vi.fn(() => Promise.resolve<"authenticated">("authenticated")), listAuthoredConnections: vi.fn(async () => []), - runLinkFlow: vi.fn(async () => ({ kind: "done" })), + runLinkFlow: vi.fn(async () => Object.freeze({ kind: "done" })), runPackageManagerInstall: vi.fn(async () => true), addConnections: addConnectionDeps(), - ...overrides, }; -} - -function runConnectionFlow( - list: ReturnType, - deps: Partial = {}, - prompter = createFakePrompter({ single: list.single }).prompter, -) { return runConnectionsFlow({ appRoot: APP_ROOT, - prompter, - deps: flowDeps(deps), + prompter: createFakePrompter({ single: list.single }).prompter, + deps: { ...defaults, ...deps }, }); } @@ -97,45 +81,21 @@ describe("runConnectionsFlow", () => { .mockResolvedValueOnce([]) .mockResolvedValue(["linear"]); const list = scriptConnectionList(["linear", "done"]); - const fake = createFakePrompter({ single: list.single }); - const addConnections = addConnectionDeps(); - const ensureConnectionDependencies = vi.fn(async () => []); - const runPackageManagerInstall = vi.fn(async () => true); - await expect( - runConnectionFlow( - list, - { - ensureConnectionDependencies, - listAuthoredConnections, - runPackageManagerInstall, - addConnections, - }, - fake.prompter, - ), - ).resolves.toEqual({ kind: "done", addedConnections: ["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]).not.toHaveProperty("hintLayout"); - expect(list.paints[0]?.map((row) => row.value)).toEqual(["linear", "notion", "done"]); - expect(list.paints[1]?.find((row) => row.value === "linear")).toMatchObject({ + expect(list.requests[0]?.options.map((row) => row.value)).toEqual(["linear", "notion", "done"]); + expect(list.requests[1]?.options.find((row) => row.value === "linear")).toMatchObject({ completed: true, - focusHint: "Already added", }); - expect(addConnections.setupConnectionConnector).toHaveBeenCalledWith( - expect.objectContaining({ - canonicalConnectorUid: "mcp.linear.app/linear", - service: "mcp.linear.app", - }), - ); - const ensureOrder = vi.mocked(addConnections.ensureConnection).mock.invocationCallOrder[0]!; - expect(runPackageManagerInstall.mock.invocationCallOrder[0]).toBeLessThan(ensureOrder); - expect( - vi.mocked(addConnections.setupConnectionConnector).mock.invocationCallOrder[0], - ).toBeLessThan(runPackageManagerInstall.mock.invocationCallOrder[0]!); }); it("defaults to Done when every catalog connection is already authored", async () => { @@ -151,11 +111,11 @@ describe("runConnectionsFlow", () => { const loggedOutList = scriptConnectionList(["cancel"]); await expect( runConnectionFlow(loggedOutList, { - detectDeployment: vi.fn(async () => UNLINKED), + detectDeployment: vi.fn(() => Promise.resolve({ state: "unlinked" })), getVercelAuthStatus: vi.fn(async (): Promise<"logged-out"> => "logged-out"), }), ).resolves.toEqual({ kind: "cancelled" }); - expect(loggedOutList.paints[0]?.find((row) => row.value === "linear")).toMatchObject({ + expect(loggedOutList.requests[0]?.options.find((row) => row.value === "linear")).toMatchObject({ disabled: true, disabledReason: "Log in to Vercel first, see /vc:login", }); @@ -172,43 +132,28 @@ describe("runConnectionsFlow", () => { .mockResolvedValueOnce([]) .mockResolvedValue(["linear"]); const list = scriptConnectionList(["linear", "done"]); - const fake = createFakePrompter({ single: list.single }); - const addConnections = addConnectionDeps(); - const deps = flowDeps({ - detectDeployment, - listAuthoredConnections, - runLinkFlow, - addConnections, - }); - await expect( - runConnectionsFlow({ appRoot: APP_ROOT, prompter: fake.prompter, deps }), + runConnectionFlow(list, { + detectDeployment, + listAuthoredConnections, + runLinkFlow, + }), ).resolves.toEqual({ kind: "done", addedConnections: ["linear"] }); - expect(list.paints[0]?.find((row) => row.value === "linear")).not.toHaveProperty("disabled"); - expect(runLinkFlow).toHaveBeenCalledWith({ - appRoot: APP_ROOT, - prompter: fake.prompter, - signal: undefined, - projectSelection: "create-or-link", - teamSelectMessage: expect.any(Function), - }); - expect(runLinkFlow.mock.calls[0]?.[0].teamSelectMessage?.("Acme")).toBe( + 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", ); - expect(detectDeployment).toHaveBeenCalledTimes(2); - expect(addConnections.setupConnectionConnector).toHaveBeenCalledOnce(); }); it("returns to the connection list when project linking is cancelled", async () => { - const runLinkFlow = vi.fn(async () => ({ - kind: "cancelled", - })); + const runLinkFlow = vi.fn(async () => Object.freeze({ kind: "cancelled" })); const list = scriptConnectionList(["linear", "done"]); await expect( runConnectionFlow(list, { - detectDeployment: vi.fn(async () => UNLINKED), + detectDeployment: vi.fn(() => Promise.resolve({ state: "unlinked" })), runLinkFlow, }), ).resolves.toEqual({ kind: "done", addedConnections: [] }); diff --git a/packages/eve/src/setup/flows/connections.ts b/packages/eve/src/setup/flows/connections.ts index 1837bd56d..68466c5b7 100644 --- a/packages/eve/src/setup/flows/connections.ts +++ b/packages/eve/src/setup/flows/connections.ts @@ -18,7 +18,7 @@ import { } from "../project-resolution.js"; import type { Prompter, SelectOption, SingleSelectOptions } from "../prompter.js"; import { runInteractive, type AnySetupBox } from "../runner.js"; -import { createDefaultSetupState, snapshotSetupState, type SetupState } from "../state.js"; +import { snapshotSetupState, type SetupState } from "../state.js"; import { WizardCancelledError } from "../step.js"; import { getVercelAuthStatus, @@ -27,7 +27,7 @@ import { } from "../vercel-project.js"; import { withSpinner } from "../with-spinner.js"; -import { prompterSink } from "./in-project.js"; +import { inProjectSetupState, prompterSink } from "./in-project.js"; import { runLinkFlow } from "./link.js"; export const CONNECTIONS_PROMPT_MESSAGE = @@ -36,7 +36,6 @@ export const CONNECTIONS_PROMPT_MESSAGE = const USER_AUTH_CONNECTIONS = CONNECTION_CATALOG.filter( (entry) => entry.slug === "linear" || entry.slug === "notion", ); -const USER_AUTH_CONNECTION_SLUGS = new Set(USER_AUTH_CONNECTIONS.map((entry) => entry.slug)); export interface ConnectionsFlowDeps { detectDeployment: typeof detectDeployment; @@ -60,38 +59,34 @@ function connectionRows( ): SelectOption[] { const blocker = vercelAuthBlockerReason(authStatus); const rows: SelectOption[] = USER_AUTH_CONNECTIONS.map((entry) => { + const row = { value: entry.slug, label: entry.label }; if (authored.has(entry.slug)) { - return { - value: entry.slug, - label: entry.label, - completed: true, - focusHint: "Already added", - }; + return { ...row, completed: true, focusHint: "Already added" }; } if (blocker !== undefined) { return { - value: entry.slug, - label: entry.label, + ...row, disabled: true, disabledReason: blocker, disabledReasonTone: "warning", }; } - return { value: entry.slug, label: entry.label, hint: entry.hint }; + return { ...row, hint: entry.hint }; }); - rows.push({ value: "done", label: "Done" }); + rows.push({ value: "done", label: "Done", trailingAction: true }); return rows; } -async function pickConnection(input: { - authored: ReadonlySet; - authStatus: VercelAuthStatus; - prompter: Prompter; -}): Promise { - const options = connectionRows(input.authored, input.authStatus); +async function pickConnection( + prompter: Prompter, + authored: ReadonlySet, + authStatus: VercelAuthStatus, +): Promise { + const options = connectionRows(authored, authStatus); const request: SingleSelectOptions = { message: CONNECTIONS_PROMPT_MESSAGE, options, + hintLayout: "inline", search: true, placeholder: "type to search MCP servers", }; @@ -103,35 +98,13 @@ async function pickConnection(input: { request.initialValue = "done"; } try { - return await input.prompter.select(request); + return await prompter.select(request); } catch (error) { if (error instanceof WizardCancelledError) return undefined; throw error; } } -async function installConnectionDependencies(input: { - appRoot: string; - deps: ConnectionsFlowDeps; - prompter: Prompter; - signal?: AbortSignal; -}): Promise { - const packageManager = await input.deps.detectPackageManager(input.appRoot); - await input.deps.ensureConnectionDependencies({ projectRoot: input.appRoot }); - const installed = await withPhase( - input.prompter.log, - `Installing connection dependencies (${packageManager.kind} install)...`, - () => - input.deps.runPackageManagerInstall(packageManager.kind, input.appRoot, { - onOutput: createPromptCommandOutput(input.prompter.log), - signal: input.signal, - }), - ); - if (!installed) { - throw new Error(`Dependency installation failed. Run \`${packageManager.kind} install\`.`); - } -} - /** Runs `/connect`, linking a project on first selection when needed. */ export async function runConnectionsFlow(input: { appRoot: string; @@ -162,27 +135,19 @@ export async function runConnectionsFlow(input: { ); signal?.throwIfAborted(); - let state: SetupState = { - ...createDefaultSetupState(), - project: projectResolutionFromDeployment(deployment), - projectPath: { kind: "resolved", inPlace: true, path: appRoot }, - }; + let state = inProjectSetupState(appRoot, projectResolutionFromDeployment(deployment)); let authored = new Set(initialAuthored); const added: string[] = []; let dependenciesReady = false; while (true) { - const selected = await pickConnection({ - authored, - authStatus, - prompter, - }); + const selected = await pickConnection(prompter, authored, authStatus); if (selected === undefined || selected === "done") { return added.length === 0 && selected === undefined ? { kind: "cancelled" } : { kind: "done", addedConnections: added }; } - if (authored.has(selected) || !USER_AUTH_CONNECTION_SLUGS.has(selected)) continue; + if (authored.has(selected)) continue; if (!isProjectResolved(state.project)) { const link = await deps.runLinkFlow({ @@ -214,7 +179,22 @@ export async function runConnectionsFlow(input: { deps: deps.addConnections, beforeScaffold: async () => { if (dependenciesReady) return; - await installConnectionDependencies({ appRoot, deps, prompter, signal }); + 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\`.`, + ); + } dependenciesReady = true; }, }), diff --git a/packages/eve/src/setup/prompter.ts b/packages/eve/src/setup/prompter.ts index 0f36b75b2..256aaf413 100644 --- a/packages/eve/src/setup/prompter.ts +++ b/packages/eve/src/setup/prompter.ts @@ -33,6 +33,8 @@ export type PrompterValue = string | number | boolean; export interface SelectOption { value: T; label: string; + /** Completion action kept after searchable results instead of being filtered. */ + trailingAction?: boolean; hint?: string; /** Short inline annotation shown dimmed only while the cursor is on this row. */ focusHint?: string; From 5ce8c68e106a602a80cc2102040ca6e378a83b36 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 25 Jun 2026 20:47:45 -0400 Subject: [PATCH 07/11] fix(eve): repair Vercel Connect setup and authorization Use managed service identifiers and linked-project scope, return to chat after setup, and continue parked TUI sessions when OAuth finishes. Signed-off-by: Rui Conti --- docs/guides/dev-tui.md | 4 +- packages/eve/src/cli/dev/tui/runner.test.ts | 51 ++++++ packages/eve/src/cli/dev/tui/runner.ts | 173 ++++++++++++------ .../src/cli/dev/tui/terminal-renderer.test.ts | 58 ++++++ .../eve/src/cli/dev/tui/terminal-renderer.ts | 16 +- .../src/setup/boxes/add-connections.test.ts | 11 +- .../connection-connector.integration.test.ts | 50 ++++- .../eve/src/setup/connection-connector.ts | 120 +++++++++--- .../eve/src/setup/flows/connections.test.ts | 32 +++- packages/eve/src/setup/flows/connections.ts | 64 +++---- .../scaffold/connections/catalog.test.ts | 15 +- .../src/setup/scaffold/connections/catalog.ts | 31 ++-- .../tui-client/tui-connection-auth-states.ts | 77 +++++--- 13 files changed, 520 insertions(+), 182 deletions(-) diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index 799858d06..975b72124 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -63,7 +63,7 @@ The provider row demands attention (a bold yellow "Configure model access" with `/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. 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, and installs the new dependency so the dev server can load it. +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. ## Keyboard shortcuts @@ -90,7 +90,7 @@ When the agent needs something from you, the TUI asks inline. - Tool approvals are a `y` or `n`. - Option questions let you pick with `↑` / `↓` and `Enter`, or you can compose a multi-line freeform answer. -- If a tool needs an authorized [connection](../connections), the URL shows up right in the transcript, and the turn picks back up once you finish the flow. +- If a tool needs an authorized [connection](../connections), the URL shows up right in the transcript, and the turn picks back up once you finish the flow. The same local `eve dev` server owns the callback route, so keep that command running until the browser returns. `eve dev --url` connects to an already-running server and does not start a local callback host. ## Control what logs show diff --git a/packages/eve/src/cli/dev/tui/runner.test.ts b/packages/eve/src/cli/dev/tui/runner.test.ts index cde03bceb..6c50b3950 100644 --- a/packages/eve/src/cli/dev/tui/runner.test.ts +++ b/packages/eve/src/cli/dev/tui/runner.test.ts @@ -661,6 +661,57 @@ describe("EveTUIRunner native continuation state", () => { }); }); +describe("EveTUIRunner connection authorization", () => { + it("continues a parked interactive authorization session until the callback completes", async () => { + const prompts: Array = ["connect linear", undefined]; + const updates: Array<{ name: string; state: string }> = []; + const pendingCounts: number[] = []; + const session = sessionYielding([ + { + type: "authorization.required", + data: { + authorization: { url: "https://connect.vercel.com/authorize/linear" }, + description: "Authorization required for linear", + name: "linear", + webhookUrl: "https://eve.test/connections/linear/callback", + }, + }, + { type: "session.waiting", data: { wait: "connection-authorization" } }, + ]); + vi.spyOn(session, "stream").mockImplementation(async function* () { + yield { + type: "authorization.completed", + data: { name: "linear", outcome: "authorized" }, + } as HandleMessageStreamEvent; + yield { + type: "session.waiting", + data: { wait: "next-user-message" }, + } as HandleMessageStreamEvent; + }); + const renderer: AgentTUIRenderer = { + readPrompt: vi.fn(async () => prompts.shift()), + upsertConnectionAuth: (update) => updates.push({ name: update.name, state: update.state }), + setConnectionAuthPendingCount: (count) => pendingCounts.push(count), + renderStream: vi.fn(async (result) => { + for await (const event of result.events as AsyncIterable) { + void event; + } + }), + }; + + const runner = new EveTUIRunner({ session, renderer, name: "Weather Agent" }); + await runner.run(); + + expect(session.stream).toHaveBeenCalledTimes(1); + expect(updates).toEqual([ + { name: "linear", state: "required" }, + { name: "linear", state: "pending" }, + { name: "linear", state: "authorized" }, + ]); + expect(pendingCounts).toEqual([1, 0]); + }); +}); + describe("EveTUIRunner failure rendering", () => { it("renders one error block for a step/turn/session failure cascade", async () => { const prompts: Array = ["hello", undefined]; diff --git a/packages/eve/src/cli/dev/tui/runner.ts b/packages/eve/src/cli/dev/tui/runner.ts index ac7daf84b..ed1d977e3 100644 --- a/packages/eve/src/cli/dev/tui/runner.ts +++ b/packages/eve/src/cli/dev/tui/runner.ts @@ -715,76 +715,88 @@ export class EveTUIRunner { hasRunTurn = true; } - const result = await this.#streamTurn({ + let result = await this.#streamTurn({ prompt: streamWithoutPrompt ? undefined : prompt, inputResponses: pendingInputResponses, }); + let submittedPrompt = prompt; + let respondedToInputRequest = false; try { - await this.#renderer.renderStream(result, { - title, - submittedPrompt: prompt, - continueSession: Boolean(this.#renderer.readPrompt), - tools: this.#tools, - reasoning: this.#reasoning, - subagents: this.#subagents, - connectionAuth: this.#connectionAuth, - assistantResponseStats: this.#assistantResponseStats, - contextSize: this.#contextSize, - }); + while (true) { + await this.#renderer.renderStream(result, { + title, + submittedPrompt, + continueSession: Boolean(this.#renderer.readPrompt), + tools: this.#tools, + reasoning: this.#reasoning, + subagents: this.#subagents, + connectionAuth: this.#connectionAuth, + assistantResponseStats: this.#assistantResponseStats, + contextSize: this.#contextSize, + }); - const approvalRequests = result.turnState?.pendingApprovals ?? []; - const questionRequests = result.turnState?.pendingQuestions ?? []; + const approvalRequests = result.turnState?.pendingApprovals ?? []; + const questionRequests = result.turnState?.pendingQuestions ?? []; - if (approvalRequests.length > 0 || questionRequests.length > 0) { - const responses: InputResponse[] = []; + if (approvalRequests.length > 0 || questionRequests.length > 0) { + const responses: InputResponse[] = []; - if (approvalRequests.length > 0) { - if (!this.#renderer.readToolApproval) { - throw new Error( - "Tool approval was requested, but the renderer does not support tool approval input.", - ); - } + if (approvalRequests.length > 0) { + if (!this.#renderer.readToolApproval) { + throw new Error( + "Tool approval was requested, but the renderer does not support tool approval input.", + ); + } - for (const request of approvalRequests) { - const response = await this.#renderer.readToolApproval(request, { title }); - responses.push({ - requestId: request.approvalId, - optionId: response.approved ? "approve" : "deny", - }); - this.#pendingInputRequests.delete(request.approvalId); + for (const request of approvalRequests) { + const response = await this.#renderer.readToolApproval(request, { title }); + responses.push({ + requestId: request.approvalId, + optionId: response.approved ? "approve" : "deny", + }); + this.#pendingInputRequests.delete(request.approvalId); + } } - } - if (questionRequests.length > 0) { - if (!this.#renderer.readInputQuestion) { - throw new Error( - "An interactive question was requested, but the renderer does not support input questions.", - ); - } + if (questionRequests.length > 0) { + if (!this.#renderer.readInputQuestion) { + throw new Error( + "An interactive question was requested, but the renderer does not support input questions.", + ); + } - for (const inputRequest of questionRequests) { - const question = toAgentTUIInputQuestion(inputRequest); - const response = await this.#renderer.readInputQuestion(question, { title }); - if (response === undefined) { - continue; + for (const inputRequest of questionRequests) { + const question = toAgentTUIInputQuestion(inputRequest); + const response = await this.#renderer.readInputQuestion(question, { title }); + if (response === undefined) { + continue; + } + const inputResponse: InputResponse = { requestId: inputRequest.requestId }; + if (response.optionId !== undefined) inputResponse.optionId = response.optionId; + if (response.text !== undefined) inputResponse.text = response.text; + responses.push(inputResponse); + this.#pendingInputRequests.delete(inputRequest.requestId); } - const inputResponse: InputResponse = { requestId: inputRequest.requestId }; - if (response.optionId !== undefined) inputResponse.optionId = response.optionId; - if (response.text !== undefined) inputResponse.text = response.text; - responses.push(inputResponse); - this.#pendingInputRequests.delete(inputRequest.requestId); } + + streamWithoutPrompt = true; + pendingInputResponses = responses; + prompt = undefined; + respondedToInputRequest = true; + break; } - streamWithoutPrompt = true; - pendingInputResponses = responses; - prompt = undefined; - continue; - } + if (this.#enterPendingConnectionAuthorization(result)) { + result = this.#streamConnectionAuthorization(); + submittedPrompt = undefined; + continue; + } - if (result.turnState && result.turnState.boundaryEvent === undefined) { - this.#sessionFailed = true; + if (result.turnState && result.turnState.boundaryEvent === undefined) { + this.#sessionFailed = true; + } + break; } } catch (error) { if (isInterruptedError(error)) { @@ -794,6 +806,10 @@ export class EveTUIRunner { throw error; } + if (respondedToInputRequest) { + continue; + } + streamWithoutPrompt = false; pendingInputResponses = undefined; prompt = undefined; @@ -827,6 +843,7 @@ export class EveTUIRunner { this.#pendingInputRequests.clear(); this.#connectionAuthRuns.clear(); this.#pendingConnectionAuths.clear(); + this.#renderer.setConnectionAuthPendingCount?.(0); if (this.#client) { this.#session = this.#client.session(); @@ -947,12 +964,31 @@ export class EveTUIRunner { }; } - const turnState = createTurnState(); + return this.#createTUIStreamResult(response, () => abortController.abort()); + } + + /** + * Follows the same session after an interactive authorization callback. + * `send()` stops at the parked `session.waiting` boundary; the callback's + * completion events arrive in the next durable turn on `session.stream()`. + */ + #streamConnectionAuthorization(): AgentTUIStreamResult { + const abortController = new AbortController(); + return this.#createTUIStreamResult( + this.#session.stream({ signal: abortController.signal }), + () => abortController.abort(), + ); + } + #createTUIStreamResult( + events: AsyncIterable, + abort: () => void, + ): AgentTUIStreamResult { + const turnState = createTurnState(); return { - abort: () => abortController.abort(), + abort, events: eveEventsToTUIStream({ - events: response, + events, pendingInputRequests: this.#pendingInputRequests, subagentRuns: this.#subagentRuns, turnState, @@ -1211,6 +1247,31 @@ export class EveTUIRunner { this.#emitConnectionAuthUpdate(run); } + /** + * Marks framework-owned OAuth challenges as parked only after the current + * turn has reached its `session.waiting` boundary. A `webhookUrl` is the + * runtime's proof that a later callback turn can complete the challenge. + */ + #enterPendingConnectionAuthorization(result: AgentTUIStreamResult): boolean { + if (result.turnState?.boundaryEvent !== "session.waiting") { + return false; + } + + let added = false; + for (const run of this.#connectionAuthRuns.values()) { + if (run.state !== "required" || run.webhookUrl === undefined) continue; + run.state = "pending"; + this.#pendingConnectionAuths.add(run.name); + this.#emitConnectionAuthUpdate(run); + added = true; + } + + if (added) { + this.#renderer.setConnectionAuthPendingCount?.(this.#pendingConnectionAuths.size); + } + return this.#pendingConnectionAuths.size > 0; + } + #handleConnectionAuthCompleted(event: AuthorizationCompletedStreamEvent): void { const existing = this.#connectionAuthRuns.get(event.data.name); const run: ConnectionAuthRun = existing ?? { diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index e01688ab3..dc32f4e03 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -202,6 +202,29 @@ describe("TerminalRenderer (inline scrollback)", () => { renderer.shutdown(); }); + it("keeps the authorization wait status while consuming a callback stream", async () => { + const { screen, renderer } = makeRenderer(); + renderer.setConnectionAuthPendingCount(1); + let streamController: ReadableStreamDefaultController | undefined; + const rendering = renderer.renderStream( + { + events: new ReadableStream({ + start(controller) { + streamController = controller; + }, + }), + }, + { continueSession: true }, + ); + + await Promise.resolve(); + expect(screen.snapshot()).toContain("Waiting for connection authorization…"); + + streamController?.close(); + await rendering; + renderer.shutdown(); + }); + it("uses the turn pulse while waiting for the first stream event", async () => { vi.useFakeTimers(); try { @@ -517,6 +540,41 @@ describe("TerminalRenderer (inline scrollback)", () => { expect(snapshot).toContain("✓ bash"); }); + it("settles an authorization block when its callback arrives in a later stream pass", async () => { + const { screen, renderer } = makeRenderer(); + renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" }); + renderer.upsertConnectionAuth({ + name: "linear", + description: "Authorization required for linear", + state: "required", + challenge: { url: "https://connect.vercel.com/authorize/linear" }, + }); + + await renderer.renderStream(streamOf([{ type: "finish" }]), { continueSession: true }); + + renderer.upsertConnectionAuth({ + name: "linear", + description: "Authorization required for linear", + state: "pending", + challenge: { url: "https://connect.vercel.com/authorize/linear" }, + }); + expect(screen.snapshot()).toContain("linear · authorization · pending"); + + await renderer.renderStream(streamOf([{ type: "finish" }]), { continueSession: true }); + + renderer.upsertConnectionAuth({ + name: "linear", + description: "Authorization required for linear", + state: "authorized", + }); + renderer.shutdown(); + + const snapshot = screen.snapshot(); + expect(snapshot).toContain("linear · authorization · authorized"); + expect(snapshot).not.toContain("linear · authorization · required"); + expect(snapshot).not.toContain("linear · authorization · pending"); + }); + it("does not commit partial live assistant rows while streaming over the viewport", async () => { const { screen, renderer } = makeRenderer(34, 8); const words = Array.from( diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 323a41f31..701352b7a 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -647,7 +647,7 @@ export class TerminalRenderer implements AgentTUIRenderer { if (this.#turnIndicator.kind !== "waiting") { this.#turnIndicator = { kind: "waiting", startedAtMs: Date.now() }; } - this.#status = STATUS.processing; + this.#status = this.#connectionAuthPendingCount > 0 ? STATUS.connectionAuth : STATUS.processing; this.#addSubmittedPrompt(options?.submittedPrompt); this.#interrupted = false; this.#totalTokens = undefined; @@ -2275,10 +2275,16 @@ export class TerminalRenderer implements AgentTUIRenderer { #finalizeAllBlocks() { for (const block of this.#blocks) { - // Blocks awaiting an approval decision or action.result stay live past - // the end of the stream. Committing them here would freeze the pending - // glyph into scrollback before the later decision/result can settle it. - if (block.status === "approval" || block.status === "running") continue; + // Blocks awaiting an approval decision, action.result, or OAuth callback + // stay live past this stream boundary so their later terminal update can + // replace the same transcript block. + if ( + block.status === "approval" || + block.status === "running" || + (block.kind === "connection-auth" && block.live) + ) { + continue; + } block.live = false; } } diff --git a/packages/eve/src/setup/boxes/add-connections.test.ts b/packages/eve/src/setup/boxes/add-connections.test.ts index 7eddaf1ac..709e2458d 100644 --- a/packages/eve/src/setup/boxes/add-connections.test.ts +++ b/packages/eve/src/setup/boxes/add-connections.test.ts @@ -130,7 +130,7 @@ describe("selectConnections + addConnections boxes", () => { // 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.", + "Run `vercel connect create mcp.linear.app/mcp --name linear`, then set the connector UID in agent/connections/linear.ts.", ); }); @@ -147,7 +147,7 @@ describe("selectConnections + addConnections boxes", () => { expect(deps.setupConnectionConnector).toHaveBeenCalledWith( expect.objectContaining({ slug: "linear", - service: "mcp.linear.app", + service: "mcp.linear.app/mcp", projectRoot: "/tmp/project", }), ); @@ -177,7 +177,7 @@ describe("selectConnections + addConnections boxes", () => { expect.objectContaining({ slug: "notion", protocol: "mcp" }), ); expect(deps.setupConnectionConnector).toHaveBeenCalledWith( - expect.objectContaining({ slug: "notion", service: "mcp.notion.com" }), + expect.objectContaining({ slug: "notion", service: "mcp.notion.com/mcp" }), ); // The interactive picker offers only curated catalog entries. expect(presented.length).toBeGreaterThan(0); @@ -226,7 +226,8 @@ describe("selectConnections + addConnections boxes", () => { }), }), ); - // The custom MCP host becomes the Connect service. + // 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" }), ); @@ -278,7 +279,7 @@ describe("selectConnections + addConnections boxes", () => { slug: "linear", protocol: "mcp", entry: expect.objectContaining({ slug: "linear" }), - provision: { kind: "command-hint", service: "mcp.linear.app" }, + provision: { kind: "command-hint", service: "mcp.linear.app/mcp" }, }, ]); expect(deps.ensureConnection).not.toHaveBeenCalled(); diff --git a/packages/eve/src/setup/connection-connector.integration.test.ts b/packages/eve/src/setup/connection-connector.integration.test.ts index b83fe44cf..1a9161a7b 100644 --- a/packages/eve/src/setup/connection-connector.integration.test.ts +++ b/packages/eve/src/setup/connection-connector.integration.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import { captureVercel, runVercel, runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; +import type { PrompterValue, SingleSelectOptions } from "#setup/prompter.js"; import { parseConnectors, @@ -22,7 +23,7 @@ vi.mock("#setup/primitives/run-vercel.js", () => ({ const capture = vi.mocked(captureVercel); const run = vi.mocked(runVercel); const create = vi.mocked(runVercelCaptureStdout); -const SERVICE = "mcp.linear.app"; +const SERVICE = "mcp.linear.app/mcp"; const CANONICAL_UID = "mcp.linear.app/linear"; function jsonResult(value: unknown) { @@ -50,7 +51,7 @@ describe("connector response parsing", () => { { connectors: [ { uid: "linear/acme", id: "scl_1", name: "acme", service: SERVICE }, - { uid: "notion/acme", id: "scl_2", service: "mcp.notion.com" }, + { uid: "notion/acme", id: "scl_2", service: "mcp.notion.com/mcp" }, ], }, SERVICE, @@ -97,6 +98,10 @@ describe("setupConnectionConnector", () => { }); expect(capture).not.toHaveBeenCalled(); expect(create).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledWith( + ["connect", "attach", CANONICAL_UID, "--yes", "--scope", "org_1"], + expect.any(Object), + ); }); it("paginates and offers only existing connectors that support user authorization", async () => { @@ -112,7 +117,13 @@ describe("setupConnectionConnector", () => { .mockResolvedValueOnce(connectorResult("linear/app", "scl_app", "app")) .mockResolvedValueOnce(connectorResult("linear/user", "scl_user", "user")); const answers = ["find", "linear/user"]; - const fake = createFakePrompter({ single: () => answers.shift()! }); + const selectOptions: SingleSelectOptions[] = []; + const fake = createFakePrompter({ + single: (input) => { + selectOptions.push(input); + return answers.shift()!; + }, + }); await expect(setupConnectionConnector(options(fake.prompter))).resolves.toEqual({ kind: "existing", @@ -122,10 +133,37 @@ describe("setupConnectionConnector", () => { expect.arrayContaining(["--next", "next_page"]), expect.any(Object), ); + expect(capture).toHaveBeenCalledWith( + expect.arrayContaining(["--scope", "org_1"]), + expect.any(Object), + ); expect(fake.selectMessages).toEqual([ "Which connector should linear use?", "Select a connector for linear", ]); + expect(selectOptions[0]).toMatchObject({ + hintLayout: "inline", + notices: [{ tone: "warning", text: `Could not attach ${CANONICAL_UID}.` }], + }); + expect(selectOptions[1]).toMatchObject({ + hintLayout: "inline", + placeholder: "type to search connectors", + search: true, + }); + }); + + it("accepts an array connector inventory from the CLI", async () => { + run.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + capture + .mockResolvedValueOnce(jsonResult([{ uid: "linear/user", id: "scl_user" }])) + .mockResolvedValueOnce(connectorResult("linear/user", "scl_user", "user")); + const answers = ["find", "linear/user"]; + const fake = createFakePrompter({ single: () => answers.shift()! }); + + await expect(setupConnectionConnector(options(fake.prompter))).resolves.toEqual({ + kind: "existing", + connectorUid: "linear/user", + }); }); it("removes a created connector when attach fails", async () => { @@ -143,11 +181,11 @@ describe("setupConnectionConnector", () => { "Could not attach linear/linear-2", ); expect(create).toHaveBeenCalledWith( - ["connect", "create", SERVICE, "--name", "linear-2", "-F", "json"], + ["connect", "create", SERVICE, "--name", "linear-2", "-F", "json", "--scope", "org_1"], expect.any(Object), ); expect(run).toHaveBeenLastCalledWith( - ["connect", "remove", "scl_created", "--disconnect-all", "--yes"], + ["connect", "remove", "scl_created", "--disconnect-all", "--yes", "--scope", "org_1"], expect.any(Object), ); }); @@ -165,7 +203,7 @@ describe("setupConnectionConnector", () => { `Could not create the ${SERVICE} connector`, ); expect(run).toHaveBeenLastCalledWith( - ["connect", "remove", "scl_partial", "--disconnect-all", "--yes"], + ["connect", "remove", "scl_partial", "--disconnect-all", "--yes", "--scope", "org_1"], expect.any(Object), ); }); diff --git a/packages/eve/src/setup/connection-connector.ts b/packages/eve/src/setup/connection-connector.ts index aea6e399b..d265718c0 100644 --- a/packages/eve/src/setup/connection-connector.ts +++ b/packages/eve/src/setup/connection-connector.ts @@ -39,14 +39,23 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function parseTerminalJsonObject(source: string): unknown { +function parseTerminalJson(source: string): unknown { const clean = stripVTControlCharacters(source).trim(); - let start = clean.lastIndexOf("{"); - while (start >= 0) { + let objectStart = clean.lastIndexOf("{"); + let arrayStart = clean.lastIndexOf("["); + while (objectStart >= 0 || arrayStart >= 0) { + const useObject = objectStart > arrayStart; + const start = useObject ? objectStart : arrayStart; + if (useObject) { + objectStart = clean.lastIndexOf("{", objectStart - 1); + } else { + arrayStart = clean.lastIndexOf("[", arrayStart - 1); + } try { return JSON.parse(clean.slice(start)); } catch { - start = clean.lastIndexOf("{", start - 1); + // A nested object/array cannot consume the trailing enclosing JSON; + // keep walking toward the enclosing terminal payload. } } return undefined; @@ -61,9 +70,28 @@ function parseConnectorRef(value: unknown): ConnectConnectorRef | undefined { return connector; } +function connectorCandidates(value: unknown): readonly unknown[] | undefined { + if (Array.isArray(value)) return value; + if (!isRecord(value)) return undefined; + const candidates = value["connectors"] ?? value["clients"] ?? value["data"]; + return Array.isArray(candidates) ? candidates : undefined; +} + +function connectorListCursor(value: unknown): string | undefined { + if (!isRecord(value)) return undefined; + if (typeof value["cursor"] === "string") return value["cursor"]; + if (typeof value["next"] === "string") return value["next"]; + const pagination = value["pagination"]; + if (!isRecord(pagination)) return undefined; + if (typeof pagination["cursor"] === "string") return pagination["cursor"]; + if (typeof pagination["next"] === "string") return pagination["next"]; + if (typeof pagination["nextCursor"] === "string") return pagination["nextCursor"]; + return undefined; +} + /** Parses a created connector that can issue user credentials. */ export function parseCreatedConnector(stdout: string): ConnectConnectorRef | undefined { - const value = parseTerminalJsonObject(stdout); + const value = parseTerminalJson(stdout); const connector = parseConnectorRef(value); if (!isRecord(value) || connector === undefined) return undefined; const subjects = value["supportedSubjectTypes"]; @@ -72,9 +100,8 @@ export function parseCreatedConnector(stdout: string): ConnectConnectorRef | und /** Parses the service-scoped connector inventory returned by the Vercel CLI. */ export function parseConnectors(value: unknown, service: string): ConnectConnectorRef[] { - if (!isRecord(value)) return []; - const candidates = value["connectors"] ?? value["clients"]; - if (!Array.isArray(candidates)) return []; + const candidates = connectorCandidates(value); + if (candidates === undefined) return []; const connectors: ConnectConnectorRef[] = []; for (const candidate of candidates) { @@ -86,6 +113,17 @@ export function parseConnectors(value: unknown, service: string): ConnectConnect return connectors; } +function parseConnectorListPage( + value: unknown, + service: string, +): { connectors: ConnectConnectorRef[]; cursor?: string } | undefined { + if (connectorCandidates(value) === undefined) return undefined; + const cursor = connectorListCursor(value); + return cursor === undefined + ? { connectors: parseConnectors(value, service) } + : { connectors: parseConnectors(value, service), cursor }; +} + async function ensureLinkedProject( options: SetupConnectionConnectorOptions, ): Promise { @@ -99,13 +137,24 @@ async function ensureLinkedProject( async function listConnectors( options: SetupConnectionConnectorOptions, + project: VercelProjectReference, onOutput: ProcessOutputHandler, ): Promise { const connectors: ConnectConnectorRef[] = []; const seenCursors = new Set(); let cursor: string | undefined; do { - const args = ["connect", "list", "-F", "json", "--all-projects", "--service", options.service]; + const args = [ + "connect", + "list", + "-F", + "json", + "--all-projects", + "--service", + options.service, + "--scope", + project.orgId, + ]; if (cursor !== undefined) args.push("--next", cursor); const result = await captureVercel(args, { cwd: options.projectRoot, @@ -113,12 +162,13 @@ async function listConnectors( signal: options.signal, }); if (!result.ok) throw new Error(result.failure.message); - const page = parseTerminalJsonObject(result.stdout); - if (!isRecord(page) || !Array.isArray(page["connectors"] ?? page["clients"])) { + const page = parseTerminalJson(result.stdout); + const parsed = parseConnectorListPage(page, options.service); + if (parsed === undefined) { throw new Error(`Vercel returned an invalid connector list for ${options.service}.`); } - connectors.push(...parseConnectors(page, options.service)); - const next = typeof page["cursor"] === "string" ? page["cursor"] : undefined; + connectors.push(...parsed.connectors); + const next = parsed.cursor; if (next !== undefined && seenCursors.has(next)) { throw new Error(`The connector list repeated cursor ${next}.`); } @@ -141,7 +191,7 @@ async function supportsUserAuthorization( signal: options.signal, }); if (!result.ok) throw new Error(`Could not verify connector ${connector.uid}.`); - const value = parseTerminalJsonObject(result.stdout); + const value = parseTerminalJson(result.stdout); if ( !isRecord(value) || value["id"] !== connector.id || @@ -176,11 +226,16 @@ export async function cleanupCreatedConnectionConnector(input: { log: ChannelSetupLog; projectRoot: string; connectorId: string; + /** The linked Vercel owner; inferred from the project link when omitted. */ + orgId?: string; }): Promise { - const removed = await runVercel( - ["connect", "remove", input.connectorId, "--disconnect-all", "--yes"], - { cwd: input.projectRoot, onOutput: createPromptCommandOutput(input.log) }, - ); + const orgId = input.orgId ?? (await readProjectLink(input.projectRoot))?.orgId; + const args = ["connect", "remove", input.connectorId, "--disconnect-all", "--yes"]; + if (orgId !== undefined) args.push("--scope", orgId); + const removed = await runVercel(args, { + cwd: input.projectRoot, + onOutput: createPromptCommandOutput(input.log), + }); if (!removed) { throw new Error( `Could not remove connector ${input.connectorId}; run \`vercel connect remove ${input.connectorId} --disconnect-all --yes\`.`, @@ -190,6 +245,7 @@ export async function cleanupCreatedConnectionConnector(input: { async function cleanupThenThrow( options: SetupConnectionConnectorOptions, + project: VercelProjectReference, connectorId: string, message: string, ): Promise { @@ -198,6 +254,7 @@ async function cleanupThenThrow( log: options.log, projectRoot: options.projectRoot, connectorId, + orgId: project.orgId, }); } catch (error) { const cleanup = error instanceof Error ? error.message : String(error); @@ -209,10 +266,11 @@ async function cleanupThenThrow( async function attach( options: SetupConnectionConnectorOptions, + project: VercelProjectReference, connectorUid: string, onOutput: ProcessOutputHandler, ): Promise { - return runVercel(["connect", "attach", connectorUid, "--yes"], { + return runVercel(["connect", "attach", connectorUid, "--yes", "--scope", project.orgId], { cwd: options.projectRoot, onOutput, signal: options.signal, @@ -236,7 +294,7 @@ async function resolveFallbackConnector( { value: "create", label: "Create a new one", hint: "Register another connector" }, ], }); - const connectors = await listConnectors(options, onOutput); + const connectors = await listConnectors(options, project, onOutput); if (choice === "find") { const supported: ConnectConnectorRef[] = []; @@ -252,6 +310,7 @@ async function resolveFallbackConnector( const byUid = new Map(supported.map((connector) => [connector.uid, connector])); const uid = await options.prompter.select({ message: `Select a connector for ${options.slug}`, + hintLayout: "inline", search: true, placeholder: "type to search connectors", options: supported.map((connector) => ({ @@ -287,19 +346,29 @@ async function resolveFallbackConnector( "Waiting for you to complete setup in the browser…", () => runVercelCaptureStdout( - ["connect", "create", options.service, "--name", name, "-F", "json"], + [ + "connect", + "create", + options.service, + "--name", + name, + "-F", + "json", + "--scope", + project.orgId, + ], { cwd: options.projectRoot, onOutput: createOutput, signal: options.signal }, ), { kind: "external-action", emphasis: "browser" }, ); - const raw = parseConnectorRef(parseTerminalJsonObject(created.stdout)); + const raw = parseConnectorRef(parseTerminalJson(created.stdout)); const ownedId = raw?.id ?? CREATED_CONNECTOR.exec(transcript.join("\n"))?.[1]; const connector = created.ok ? parseCreatedConnector(created.stdout) : undefined; if (connector !== undefined) return { kind: "created", connector }; const message = created.ok ? `The ${options.service} connector does not support user authorization.` : `Could not create the ${options.service} connector.`; - if (ownedId !== undefined) return cleanupThenThrow(options, ownedId, message); + if (ownedId !== undefined) return cleanupThenThrow(options, project, ownedId, message); throw new Error(message); } } @@ -311,7 +380,7 @@ export async function setupConnectionConnector( const onOutput = createPromptCommandOutput(options.log); const project = await ensureLinkedProject(options); - if (await attach(options, options.canonicalConnectorUid, onOutput)) { + if (await attach(options, project, options.canonicalConnectorUid, onOutput)) { options.log.success(`Attached ${options.canonicalConnectorUid} connector`); return { kind: "existing", connectorUid: options.canonicalConnectorUid }; } @@ -323,10 +392,11 @@ export async function setupConnectionConnector( onOutput, `Could not attach ${options.canonicalConnectorUid}.`, ); - if (!(await attach(options, resolution.connector.uid, onOutput))) { + if (!(await attach(options, project, resolution.connector.uid, onOutput))) { if (resolution.kind === "created") { return cleanupThenThrow( options, + project, resolution.connector.id, `Could not attach ${resolution.connector.uid} to the linked project.`, ); diff --git a/packages/eve/src/setup/flows/connections.test.ts b/packages/eve/src/setup/flows/connections.test.ts index 7801d54e5..318d4b749 100644 --- a/packages/eve/src/setup/flows/connections.test.ts +++ b/packages/eve/src/setup/flows/connections.test.ts @@ -75,12 +75,12 @@ function runConnectionFlow( } describe("runConnectionsFlow", () => { - it("adds a catalog connection and repaints the searchable list", async () => { + 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", "done"]); + const list = scriptConnectionList(["linear"]); await expect(runConnectionFlow(list, { listAuthoredConnections })).resolves.toEqual({ kind: "done", @@ -93,9 +93,7 @@ describe("runConnectionsFlow", () => { placeholder: "type to search MCP servers", }); expect(list.requests[0]?.options.map((row) => row.value)).toEqual(["linear", "notion", "done"]); - expect(list.requests[1]?.options.find((row) => row.value === "linear")).toMatchObject({ - completed: true, - }); + expect(list.requests).toHaveLength(1); }); it("defaults to Done when every catalog connection is already authored", async () => { @@ -131,7 +129,7 @@ describe("runConnectionsFlow", () => { .fn(async () => [] as string[]) .mockResolvedValueOnce([]) .mockResolvedValue(["linear"]); - const list = scriptConnectionList(["linear", "done"]); + const list = scriptConnectionList(["linear"]); await expect( runConnectionFlow(list, { detectDeployment, @@ -147,16 +145,32 @@ describe("runConnectionsFlow", () => { ); }); - it("returns to the connection list when project linking is cancelled", async () => { + it("returns a terminal cancellation when project linking is cancelled", async () => { const runLinkFlow = vi.fn(async () => Object.freeze({ kind: "cancelled" })); - const list = scriptConnectionList(["linear", "done"]); + const list = scriptConnectionList(["linear"]); await expect( runConnectionFlow(list, { detectDeployment: vi.fn(() => Promise.resolve({ state: "unlinked" })), runLinkFlow, }), - ).resolves.toEqual({ kind: "done", addedConnections: [] }); + ).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("does not mutate dependencies when connector selection is cancelled", async () => { diff --git a/packages/eve/src/setup/flows/connections.ts b/packages/eve/src/setup/flows/connections.ts index 68466c5b7..5a6e23b02 100644 --- a/packages/eve/src/setup/flows/connections.ts +++ b/packages/eve/src/setup/flows/connections.ts @@ -136,19 +136,14 @@ export async function runConnectionsFlow(input: { signal?.throwIfAborted(); let state = inProjectSetupState(appRoot, projectResolutionFromDeployment(deployment)); - let authored = new Set(initialAuthored); - const added: string[] = []; - let dependenciesReady = false; - - while (true) { - const selected = await pickConnection(prompter, authored, authStatus); - if (selected === undefined || selected === "done") { - return added.length === 0 && selected === undefined - ? { kind: "cancelled" } - : { kind: "done", addedConnections: added }; - } - if (authored.has(selected)) continue; + const authored = new Set(initialAuthored); + const selected = await pickConnection(prompter, authored, authStatus); + 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, @@ -158,10 +153,7 @@ export async function runConnectionsFlow(input: { teamSelectMessage: () => "You need to link to a project to use Vercel Connect.\n\nSelect your team", }); - if (link.kind === "cancelled") { - if (signal?.aborted) return { kind: "cancelled" }; - continue; - } + if (link.kind === "cancelled") return { kind: "cancelled" }; const deploymentAfterLink = await withSpinner(prompter, "Checking the project…", () => deps.detectDeployment(appRoot, { signal }), @@ -178,7 +170,6 @@ export async function runConnectionsFlow(input: { signal, deps: deps.addConnections, beforeScaffold: async () => { - if (dependenciesReady) return; const packageManager = await deps.detectPackageManager(appRoot); await deps.ensureConnectionDependencies({ projectRoot: appRoot }); const installed = await withPhase( @@ -195,29 +186,28 @@ export async function runConnectionsFlow(input: { `Dependency installation failed. Run \`${packageManager.kind} install\`.`, ); } - dependenciesReady = true; }, }), ]; - try { - const result = await runInteractive(boxes, state, prompterSink(prompter), { - snapshot: snapshotSetupState, - signal, - }); - if (result.kind !== "done") { - return added.length === 0 - ? { kind: "cancelled" } - : { kind: "done", addedConnections: added }; - } - state = result.state; - authored = new Set(await deps.listAuthoredConnections(appRoot)); - if (!authored.has(selected)) continue; - added.push(selected); - } catch (error) { - authored = new Set(await deps.listAuthoredConnections(appRoot)); - if (!authored.has(selected)) throw error; - if (!added.includes(selected)) added.push(selected); - return { kind: "failed", addedConnections: added, message: toErrorMessage(error) }; + + 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), + }; } } diff --git a/packages/eve/src/setup/scaffold/connections/catalog.test.ts b/packages/eve/src/setup/scaffold/connections/catalog.test.ts index d93a18a44..b4f1d9b98 100644 --- a/packages/eve/src/setup/scaffold/connections/catalog.test.ts +++ b/packages/eve/src/setup/scaffold/connections/catalog.test.ts @@ -48,7 +48,7 @@ describe("catalog integrity", () => { }); describe("connectorServiceForEntry", () => { - test("prefers the explicit service over the MCP host", () => { + test("prefers the explicit service over the MCP endpoint", () => { expect( connectorServiceForEntry({ mcp: { url: "https://mcp.example.com/sse" }, @@ -74,17 +74,26 @@ describe("connectorServiceForEntry", () => { }); describe("canonicalConnectorUidForEntry", () => { - test("keeps catalog UIDs and derives custom UIDs", () => { + test("uses the endpoint host for a UID even when the service includes a path", () => { expect(canonicalConnectorUidForEntry(getCatalogEntry("notion")!)).toBe("mcp.notion.com/notion"); expect( canonicalConnectorUidForEntry({ mcp: { url: "https://mcp.example.com/mcp" }, - auth: { kind: "connect", connector: "custom" }, + auth: { kind: "connect", connector: "custom", service: "mcp.example.com/mcp" }, }), ).toBe("mcp.example.com/custom"); }); }); +describe("curated Connect services", () => { + test("uses the provider's managed service identifier", () => { + expect(connectorServiceForEntry(getCatalogEntry("linear")!)).toBe("mcp.linear.app/mcp"); + expect(connectorServiceForEntry(getCatalogEntry("notion")!)).toBe("mcp.notion.com/mcp"); + expect(connectorServiceForEntry(getCatalogEntry("datadog")!)).toBe("mcp.datadoghq.com/api/mcp"); + expect(connectorServiceForEntry(getCatalogEntry("honeycomb")!)).toBe("mcp.honeycomb.io/mcp"); + }); +}); + describe("mcpServiceHost", () => { test("extracts the host from a URL", () => { expect(mcpServiceHost("https://mcp.linear.app/sse")).toBe("mcp.linear.app"); diff --git a/packages/eve/src/setup/scaffold/connections/catalog.ts b/packages/eve/src/setup/scaffold/connections/catalog.ts index c9ac14695..0f35880d1 100644 --- a/packages/eve/src/setup/scaffold/connections/catalog.ts +++ b/packages/eve/src/setup/scaffold/connections/catalog.ts @@ -42,8 +42,9 @@ export type ConnectionAuthSpec = * It starts as a placeholder and is rewritten to the real connector UID once * the connector is provisioned (see {@link service}). `service` is the * managed-connector identifier passed to `vercel connect create ` - * (e.g. the MCP host `mcp.linear.app`); when omitted, the connector must be - * created out of band and its UID set by hand. + * (e.g. `mcp.linear.app/mcp`); when omitted, eve falls back to the MCP + * endpoint host. It is distinct from the connector UID namespace, which is + * only the endpoint host (e.g. `mcp.linear.app`). */ | { kind: "connect"; connector: string; service?: string } /** Static bearer token read from a single environment variable. */ @@ -102,11 +103,13 @@ export const CUSTOM_CONNECTION_SLUG = "custom"; * when the scaffolder emits its template. Every connection in the catalog must * have an entry here — {@link buildCatalogEntry} throws otherwise. */ +// Vercel's managed service key can include an endpoint path, while connector +// UIDs use only the endpoint host. Keep the provider-owned keys explicit. const CONNECTION_AUTH: Readonly> = { - linear: { kind: "connect", connector: "linear", service: "mcp.linear.app" }, - notion: { kind: "connect", connector: "notion", service: "mcp.notion.com" }, - datadog: { kind: "connect", connector: "datadog", service: "mcp.datadoghq.com" }, - honeycomb: { kind: "connect", connector: "honeycomb", service: "mcp.honeycomb.io" }, + linear: { kind: "connect", connector: "linear", service: "mcp.linear.app/mcp" }, + notion: { kind: "connect", connector: "notion", service: "mcp.notion.com/mcp" }, + datadog: { kind: "connect", connector: "datadog", service: "mcp.datadoghq.com/api/mcp" }, + honeycomb: { kind: "connect", connector: "honeycomb", service: "mcp.honeycomb.io/mcp" }, }; function buildCatalogEntry( @@ -182,9 +185,9 @@ export function isValidConnectionSlug(slug: string): boolean { /** * The `vercel connect create ` identifier for a Connect-backed - * connection: the explicit `auth.service` when set, otherwise the host of the - * MCP endpoint (e.g. `mcp.linear.app`). Returns `undefined` when neither is - * available, in which case the connector must be provisioned out of band. + * connection: the explicit `auth.service` when set, otherwise the MCP host. + * Returns `undefined` when neither is available, in which case the connector + * must be provisioned out of band. */ export function connectorServiceForEntry( entry: Pick, @@ -201,8 +204,14 @@ export function canonicalConnectorUidForEntry(entry: { }): string | undefined { if (entry.auth?.kind !== "connect") return undefined; if (entry.auth.connector.includes("/")) return entry.auth.connector; - const service = entry.auth.service ?? mcpServiceHost(entry.mcp?.url); - return service === undefined ? undefined : `${service}/${entry.auth.connector}`; + const namespace = mcpServiceHost(entry.mcp?.url) ?? serviceHost(entry.auth.service); + return namespace === undefined ? undefined : `${namespace}/${entry.auth.connector}`; +} + +function serviceHost(service: string | undefined): string | undefined { + if (!service) return undefined; + const host = service.split("/", 1)[0]?.trim(); + return host || undefined; } /** Extracts the bare host from an MCP URL, or `undefined` when unparseable. */ diff --git a/packages/eve/test/tui-client/tui-connection-auth-states.ts b/packages/eve/test/tui-client/tui-connection-auth-states.ts index f53af28f5..09be4f109 100644 --- a/packages/eve/test/tui-client/tui-connection-auth-states.ts +++ b/packages/eve/test/tui-client/tui-connection-auth-states.ts @@ -13,15 +13,17 @@ import { theme } from "./lib/theme.ts"; * emit `_completed` in this repo (interactive auth * requires a `principalType: "user"` session, which apps/fixtures/agent-tui-client * doesn't currently provide). This smoke fills that gap with a - * `FakeSession` that returns a synthetic event stream, so we get + * `FakeSession` that separates the parked request stream from the callback + * continuation stream, so we get * deterministic coverage of: * * 1. `_required` with a populated challenge (URL, user code, * instructions). That proves the renderer surfaces all three * challenge fields, which the live smoke can't show. - * 2. `_completed` with `outcome: "authorized"`. That proves the - * right-title transitions, the status bar override clears, - * and the section becomes terminal. + * 2. `_completed` with `outcome: "authorized"` after `session.waiting`. + * That proves the root TUI follows `session.stream()` until the OAuth + * callback resumes the durable workflow, flips the right-title, clears + * the status-bar override, and settles the section. * 3. A second turn with `outcome: "failed"` + a reason. That * proves the failure path renders distinctly and the reason * string surfaces in the section content. @@ -29,9 +31,14 @@ import { theme } from "./lib/theme.ts"; class FakeSession extends ClientSession { readonly #turns: ReadonlyArray; + readonly #continuations: ReadonlyArray; #turnIndex = 0; + #continuationIndex = 0; - constructor(turns: ReadonlyArray) { + constructor(input: { + turns: ReadonlyArray; + continuations: ReadonlyArray; + }) { super( { host: "http://fake.invalid", @@ -41,7 +48,8 @@ class FakeSession extends ClientSession { }, { streamIndex: 0 }, ); - this.#turns = turns; + this.#turns = input.turns; + this.#continuations = input.continuations; } override async send(): Promise> { @@ -50,19 +58,26 @@ class FakeSession extends ClientSession { return new MessageResponse({ sessionId: "fake-session", continuationToken: `fake-token-${this.#turnIndex}`, - createStream: async function* () { - for (const event of events) { - yield event; - // Pacing so the renderer paints between each event AND the - // smoke's `waitForCondition` (50ms poll) has time to observe - // intermediate states. Real streams have natural pacing from - // the HTTP transport; a synchronous yield burst makes - // intermediate states impossible to assert on. - await sleep(200); - } - }, + createStream: () => pacedEvents(events), }); } + + override stream(): AsyncIterable { + const events = this.#continuations[this.#continuationIndex] ?? []; + this.#continuationIndex += 1; + return pacedEvents(events); + } +} + +async function* pacedEvents( + events: readonly HandleMessageStreamEvent[], +): AsyncGenerator { + for (const event of events) { + yield event; + // Pacing gives the renderer and smoke assertions a chance to observe + // each lifecycle state between events, as the HTTP transport does live. + await sleep(200); + } } const turnId = "turn-0"; @@ -88,13 +103,17 @@ const firstTurn: HandleMessageStreamEvent[] = [ sequence: next(), stepIndex, turnId, - webhookUrl: "http://localhost:3000/.well-known/eve/v1/connections/stub-mcp/callback/xyz", + webhookUrl: "http://localhost:3000/eve/v1/connections/stub-mcp/callback/xyz", }, }, - // In a real workflow, the next events arrive only after the webhook - // callback fires and `completeAuthorization` resolves. The synthetic - // stream emits them after a short delay (above, via the per-event - // sleep) so the smoke can observe the intermediate state. + { + type: "step.completed", + data: { finishReason: "stop", sequence: next(), stepIndex, turnId }, + }, + { type: "session.waiting", data: { wait: "next-user-message" } }, +]; + +const firstCallbackTurn: HandleMessageStreamEvent[] = [ { type: "authorization.completed", data: { @@ -127,8 +146,17 @@ const secondTurn: HandleMessageStreamEvent[] = [ sequence: next(), stepIndex, turnId: secondTurnId, + webhookUrl: "http://localhost:3000/eve/v1/connections/other-mcp/callback/xyz", }, }, + { + type: "step.completed", + data: { finishReason: "stop", sequence: next(), stepIndex, turnId: secondTurnId }, + }, + { type: "session.waiting", data: { wait: "next-user-message" } }, +]; + +const secondCallbackTurn: HandleMessageStreamEvent[] = [ { type: "authorization.completed", data: { @@ -150,7 +178,10 @@ const secondTurn: HandleMessageStreamEvent[] = [ process.env.EVE_TUI_UNICODE = "1"; void (async () => { - const session = new FakeSession([firstTurn, secondTurn]); + const session = new FakeSession({ + turns: [firstTurn, secondTurn], + continuations: [firstCallbackTurn, secondCallbackTurn], + }); const screen = new MockScreen({ columns: 100, rows: 40 }); const input = new MockUserInput(); const runner = new EveTUIRunner({ From 6987cd5fcf5db02f71ddf70c3345d62fda5ed610 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 25 Jun 2026 20:47:54 -0400 Subject: [PATCH 08/11] feat(eve): add /connect TUI tip Highlight the local MCP connection flow in the rotating dev TUI header. Signed-off-by: Rui Conti --- .../eve/src/cli/dev/tui/agent-header.test.ts | 20 +++++++++++++++++++ packages/eve/src/cli/dev/tui/agent-header.ts | 13 +++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) 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 d53ca4d92..33d8b34e2 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.test.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.test.ts @@ -3,6 +3,7 @@ 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 "./terminal-text.js"; import { createTheme } from "./theme.js"; const FRAMEWORK_TOOL: AgentInfoToolEntry = { @@ -133,6 +134,25 @@ describe("buildAgentHeader", () => { expect(remote.join("\n")).not.toContain("/channels"); }); + it("renders the /connect tip with a blue command", () => { + const colorTheme = createTheme({ color: true, unicode: false }); + const tip = AGENT_HEADER_TIPS.find((candidate) => candidate.includes("/connect")); + + expect(tip).toBe("Tip: /connect to seamlessly add MCP Connections to your agent"); + if (tip === undefined) return; + + const line = buildAgentHeader({ + name: "weather-agent", + info: INFO, + theme: colorTheme, + width: 120, + tip, + }).at(-1); + + expect(stripAnsi(line ?? "")).toBe(` ${tip}`); + expect(line).toContain(colorTheme.colors.blue("/connect")); + }); + it("keeps the discovery-diagnostics line when the compiler reported problems", () => { const info: AgentInfoResult = { ...INFO, diff --git a/packages/eve/src/cli/dev/tui/agent-header.ts b/packages/eve/src/cli/dev/tui/agent-header.ts index 9035c54d8..8ea3cddda 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.ts @@ -7,6 +7,7 @@ */ import type { AgentInfoResult } from "#client/index.js"; +import { isPromptControlCommand } from "./prompt-commands.js"; import type { Theme } from "./theme.js"; import { truncate } from "./tool-format.js"; @@ -30,6 +31,7 @@ export const AGENT_HEADER_TIPS: readonly string[] = [ "Use /channels to add more ways to reach your agent.", "Use /deploy to see your agent go live.", "Type /help to see every command.", + "Tip: /connect to seamlessly add MCP Connections to your agent", ]; /** Picks one tip; `random` is a test seam over Math.random. */ @@ -75,12 +77,21 @@ export function buildAgentHeader(input: AgentHeaderInput): string[] { } if (input.tip !== undefined) { - lines.push(` ${c.dim(truncate(input.tip, Math.max(8, width - 2)))}`); + lines.push(` ${renderTip(input.tip, Math.max(8, width - 2), theme)}`); } return lines; } +function renderTip(tip: string, width: number, theme: Theme): string { + return truncate(tip, width) + .split(/(\/[a-z:-]+)/u) + .map((part) => + isPromptControlCommand(part) ? theme.colors.blue(part) : theme.colors.dim(part), + ) + .join(""); +} + function plural(count: number): string { return count === 1 ? "" : "s"; } From 3877fadcc1f35061a1d8b7e1f7670d26c1cf98e5 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 25 Jun 2026 20:50:46 -0400 Subject: [PATCH 09/11] fix(eve): clarify terminal connect failures Signed-off-by: Rui Conti --- packages/eve/src/cli/dev/tui/setup-commands.test.ts | 5 +++++ packages/eve/src/cli/dev/tui/setup-commands.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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 83c41b016..645688144 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.test.ts @@ -339,6 +339,11 @@ describe("runTuiSetupCommand", () => { { kind: "failed", addedConnections: ["linear"], message: "install failed" }, "Connection files changed, but /connect failed: install failed", ], + [ + "failed before a connection file was written", + { kind: "failed", addedConnections: [], message: "connector setup failed" }, + "/connect failed: connector setup failed", + ], ] as const)("reports %s connection flows", async (_case, result, message) => { const runConnectionsFlow = vi.fn(async () => result); await expect( diff --git a/packages/eve/src/cli/dev/tui/setup-commands.ts b/packages/eve/src/cli/dev/tui/setup-commands.ts index 8fef468ef..1d65a407e 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.ts @@ -250,7 +250,10 @@ async function executeSetupCommand( }; case "failed": return { - message: `Connection files changed, but /connect failed: ${result.message}`, + message: + result.addedConnections.length === 0 + ? `/connect failed: ${result.message}` + : `Connection files changed, but /connect failed: ${result.message}`, preserveFlowDiagnostics: true, effect: { kind: "model-access-changed" }, }; From 03e47924bd0f2965f22958afef42ddfd98705073 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 25 Jun 2026 21:32:05 -0400 Subject: [PATCH 10/11] fix(eve): replace completed authorization challenge Signed-off-by: Rui Conti --- .../src/cli/dev/tui/terminal-renderer.test.ts | 4 ++ .../eve/src/cli/dev/tui/terminal-renderer.ts | 49 +++++++++++++------ .../tui-client/tui-connection-auth-states.ts | 19 +++++++ 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index 33af23792..dc5afa43f 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -635,13 +635,17 @@ describe("TerminalRenderer (inline scrollback)", () => { name: "linear", description: "Authorization required for linear", state: "authorized", + challenge: { url: "https://connect.vercel.com/authorize/linear" }, }); renderer.shutdown(); const snapshot = screen.snapshot(); expect(snapshot).toContain("linear · authorization · authorized"); + expect(snapshot).toContain("Authorization complete"); expect(snapshot).not.toContain("linear · authorization · required"); expect(snapshot).not.toContain("linear · authorization · pending"); + expect(snapshot).not.toContain("Authorization required for linear"); + expect(snapshot).not.toContain("https://connect.vercel.com/authorize/linear"); }); it("does not commit partial live assistant rows while streaming over the viewport", async () => { diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 8216ee17c..a81b7b81a 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -1013,18 +1013,14 @@ export class TerminalRenderer implements AgentTUIRenderer { upsertConnectionAuth(update: ConnectionAuthUpdate): void { if (this.#connectionAuth === "hidden") return; - const isTerminal = - update.state === "authorized" || - update.state === "declined" || - update.state === "failed" || - update.state === "timed-out"; + const terminalMessage = connectionAuthTerminalMessage(update.state); this.#upsertBlock({ id: connectionAuthSectionId(update.name), kind: "connection-auth", title: `${stripTerminalControls(update.name)} · authorization · ${update.state}`, - body: formatConnectionAuthContent(update), + body: formatConnectionAuthContent(update, terminalMessage), preformatted: true, - live: !isTerminal, + live: terminalMessage === undefined, }); this.#paint(); } @@ -3424,15 +3420,38 @@ function connectionAuthSectionId(connectionName: string): string { return `connection-auth:${connectionName}`; } -function formatConnectionAuthContent(update: ConnectionAuthUpdate): string { +function connectionAuthTerminalMessage(state: ConnectionAuthUpdate["state"]): string | undefined { + switch (state) { + case "authorized": + return "Authorization complete"; + case "declined": + return "Authorization declined"; + case "failed": + return "Authorization failed"; + case "timed-out": + return "Authorization timed out"; + case "required": + case "pending": + return undefined; + } +} + +function formatConnectionAuthContent( + update: ConnectionAuthUpdate, + terminalMessage: string | undefined, +): string { const lines: string[] = []; - const description = stripTerminalControls(update.description); - if (description.length > 0) lines.push(description); - const challenge = update.challenge; - if (challenge?.url) lines.push(`URL: ${stripTerminalControls(challenge.url)}`); - if (challenge?.userCode) lines.push(`Code: ${stripTerminalControls(challenge.userCode)}`); - if (challenge?.expiresAt) lines.push(`Expires: ${stripTerminalControls(challenge.expiresAt)}`); - if (challenge?.instructions) lines.push(stripTerminalControls(challenge.instructions)); + if (terminalMessage !== undefined) { + lines.push(terminalMessage); + } else { + const description = stripTerminalControls(update.description); + if (description.length > 0) lines.push(description); + const challenge = update.challenge; + if (challenge?.url) lines.push(`URL: ${stripTerminalControls(challenge.url)}`); + if (challenge?.userCode) lines.push(`Code: ${stripTerminalControls(challenge.userCode)}`); + if (challenge?.expiresAt) lines.push(`Expires: ${stripTerminalControls(challenge.expiresAt)}`); + if (challenge?.instructions) lines.push(stripTerminalControls(challenge.instructions)); + } if (update.reason !== undefined) { const reason = stripTerminalControls(update.reason); if (reason.length > 0) lines.push(`Reason: ${reason}`); diff --git a/packages/eve/test/tui-client/tui-connection-auth-states.ts b/packages/eve/test/tui-client/tui-connection-auth-states.ts index 09be4f109..5c908ebb7 100644 --- a/packages/eve/test/tui-client/tui-connection-auth-states.ts +++ b/packages/eve/test/tui-client/tui-connection-auth-states.ts @@ -233,6 +233,25 @@ void (async () => { }); console.log(theme.muted("[states] right-title flipped to authorized")); + await waitForCondition( + () => { + const snap = screen.snapshot(); + return ( + snap.includes("Authorization complete") && + !snap.includes("Authorization required for stub-mcp") && + !snap.includes("URL: https://example.com/authorize/stub-mcp") && + !snap.includes("Code: STUB-1234") && + !snap.includes("Visit the URL above") + ); + }, + { + timeoutMs: 5_000, + label: "completed authorization replaces the stale challenge body", + onTimeout: () => screen.snapshot(), + }, + ); + console.log(theme.muted("[states] completed authorization body replaced the challenge")); + await waitForCondition( () => !screen.snapshot().includes("Waiting for connection authorization"), { From 1be767c8456dd4cc43c85c109ae988a0326c34c0 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 25 Jun 2026 21:47:40 -0400 Subject: [PATCH 11/11] fix(eve): authenticate local Vercel Connect users Bind local TUI sessions to the linked project's Vercel OIDC user and refresh the runtime after connection setup. Signed-off-by: Rui Conti --- .changeset/connect-command.md | 2 +- .../weather-agent/agent/connections/notion.ts | 8 + apps/fixtures/weather-agent/package.json | 1 + packages/eve/src/cli/dev/tui/runner.test.ts | 40 ++++ packages/eve/src/cli/dev/tui/runner.ts | 23 ++- .../src/cli/dev/tui/setup-commands.test.ts | 16 +- .../eve/src/cli/dev/tui/setup-commands.ts | 12 +- packages/eve/src/cli/dev/tui/tui.ts | 12 +- packages/eve/src/cli/run.test.ts | 50 +++++ packages/eve/src/cli/run.ts | 26 ++- packages/eve/src/client/client.ts | 3 +- packages/eve/src/internal/nitro/host.ts | 5 +- .../nitro/host/configure-nitro-routes.test.ts | 11 +- .../nitro/host/configure-nitro-routes.ts | 10 - ...v-authored-source-watcher.scenario.test.ts | 21 ++ .../nitro/host/dev-authored-source-watcher.ts | 33 ++-- .../nitro/host/dev-rebuild-registry.ts | 25 --- .../host/start-development-server.test.ts | 74 +++++++ .../nitro/host/start-development-server.ts | 56 ++++++ .../routes/dev-runtime-artifacts.test.ts | 22 --- .../nitro/routes/dev-runtime-artifacts.ts | 8 - .../eve/src/internal/vercel/project-link.ts | 26 +++ packages/eve/src/public/channels/auth.test.ts | 83 ++++++++ packages/eve/src/public/channels/auth.ts | 53 ++++- .../src/runtime/connections/principal.test.ts | 53 +++++ .../eve/src/runtime/connections/principal.ts | 24 ++- packages/eve/src/runtime/connections/types.ts | 5 +- .../runtime/framework-channels/index.test.ts | 181 +++++++++++++++++ .../src/runtime/framework-channels/index.ts | 55 +++++- .../eve/src/runtime/governance/auth/oidc.ts | 95 ++++++--- .../eve/src/runtime/governance/auth/types.ts | 25 ++- ...lve-agent-graph.framework-app-root.test.ts | 45 +++++ .../eve/src/runtime/resolve-agent-graph.ts | 13 +- .../runtime/sessions/compiled-agent-cache.ts | 7 +- packages/eve/src/services/dev-client.test.ts | 61 ++++++ packages/eve/src/services/dev-client.ts | 38 +++- .../dev-client/client-options.test.ts | 18 +- .../src/services/dev-client/client-options.ts | 12 ++ .../dev-client/request-headers.test.ts | 35 +++- .../services/dev-client/request-headers.ts | 13 ++ .../dev-client/runtime-artifacts.test.ts | 14 ++ .../services/dev-client/runtime-artifacts.ts | 2 + packages/eve/src/setup/project-resolution.ts | 26 +-- .../scenarios/dev-server.scenario.test.ts | 182 +++++++++++++++++- pnpm-lock.yaml | 3 + 45 files changed, 1342 insertions(+), 185 deletions(-) create mode 100644 apps/fixtures/weather-agent/agent/connections/notion.ts delete mode 100644 packages/eve/src/internal/nitro/host/dev-rebuild-registry.ts create mode 100644 packages/eve/src/internal/vercel/project-link.ts create mode 100644 packages/eve/src/runtime/framework-channels/index.test.ts create mode 100644 packages/eve/src/runtime/resolve-agent-graph.framework-app-root.test.ts diff --git a/.changeset/connect-command.md b/.changeset/connect-command.md index 0e7afa69f..07effbfc7 100644 --- a/.changeset/connect-command.md +++ b/.changeset/connect-command.md @@ -2,4 +2,4 @@ "eve": patch --- -Add a searchable `/connect` flow to the local dev TUI. It scaffolds an MCP connection, resolves its Vercel Connect connector, and reuses the model setup flow to create or link a Vercel project when needed. +Add a searchable `/connect` flow to the local dev TUI. It scaffolds an MCP connection, resolves its Vercel Connect connector, and reuses the model setup flow to create or link a Vercel project when needed. Local Vercel users now authorize Connect with their Vercel user ID instead of a reserved OIDC issuer. diff --git a/apps/fixtures/weather-agent/agent/connections/notion.ts b/apps/fixtures/weather-agent/agent/connections/notion.ts new file mode 100644 index 000000000..eca1d1cba --- /dev/null +++ b/apps/fixtures/weather-agent/agent/connections/notion.ts @@ -0,0 +1,8 @@ +import { connect } from "@vercel/connect/eve"; +import { defineMcpClientConnection } from "eve/connections"; + +export default defineMcpClientConnection({ + url: "https://mcp.notion.com/mcp", + description: "Notion workspace: search and edit pages and databases.", + auth: connect("mcp.notion.com/notion"), +}); diff --git a/apps/fixtures/weather-agent/package.json b/apps/fixtures/weather-agent/package.json index 4e072ec0c..4f255ed7d 100644 --- a/apps/fixtures/weather-agent/package.json +++ b/apps/fixtures/weather-agent/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@vercel/connect": "0.2.2", "eve": "workspace:*", "zod": "catalog:" } diff --git a/packages/eve/src/cli/dev/tui/runner.test.ts b/packages/eve/src/cli/dev/tui/runner.test.ts index ad40eb86f..113941b5d 100644 --- a/packages/eve/src/cli/dev/tui/runner.test.ts +++ b/packages/eve/src/cli/dev/tui/runner.test.ts @@ -1680,6 +1680,46 @@ 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("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 64d151a61..0565c9a93 100644 --- a/packages/eve/src/cli/dev/tui/runner.ts +++ b/packages/eve/src/cli/dev/tui/runner.ts @@ -304,8 +304,8 @@ export interface PromptCommandHandlerContext { export interface PromptCommandOutcome { /** Outcome line rendered under the echoed command; absent renders nothing. */ message?: string; - /** Post-command work; model access also re-probes the Vercel identity. */ - effect?: VercelStatusEffect | { kind: "model-access-changed" }; + /** Post-command work after setup settles. */ + effect?: VercelStatusEffect | { kind: "connection-added" } | { kind: "model-access-changed" }; } export interface PromptCommandHandler { @@ -1127,6 +1127,13 @@ 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; @@ -1140,6 +1147,18 @@ export class EveTUIRunner { void this.#refreshSetupAttention(this.#agentInfo); } + async #refreshConnectionRuntime(): Promise { + const client = this.#client; + const runtimeArtifacts = this.#runtimeArtifacts; + if (client === undefined || runtimeArtifacts === undefined) return; + + this.#session = await runtimeArtifacts.refreshAfterSourceChange({ + createSession: () => client.session(), + onRuntimeArtifactsChanged: () => this.#handleRuntimeArtifactsChanged(), + session: this.#session, + }); + } + 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 645688144..1e0afbca4 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.test.ts @@ -331,27 +331,35 @@ describe("runTuiSetupCommand", () => { "configured", { kind: "done", addedConnections: ["linear", "notion"] }, "Connections added: linear, notion.", + { kind: "connection-added" }, ], - ["empty", { kind: "done", addedConnections: [] }, "No connections added."], - ["cancelled", { kind: "cancelled" }, "/connect cancelled."], + [ + "empty", + { kind: "done", addedConnections: [] }, + "No connections added.", + { kind: "model-access-changed" }, + ], + ["cancelled", { kind: "cancelled" }, "/connect cancelled.", { 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) => { + ] 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: { kind: "model-access-changed" }, + effect, }); expect(runConnectionsFlow).toHaveBeenCalledWith(expect.objectContaining({ appRoot: APP_ROOT })); }); diff --git a/packages/eve/src/cli/dev/tui/setup-commands.ts b/packages/eve/src/cli/dev/tui/setup-commands.ts index 1d65a407e..3a2dc2e78 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.ts @@ -70,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: "model-access-changed" }; + effect?: VercelStatusEffect | { kind: "connection-added" } | { kind: "model-access-changed" }; } /** @@ -255,7 +255,10 @@ async function executeSetupCommand( ? `/connect failed: ${result.message}` : `Connection files changed, but /connect failed: ${result.message}`, preserveFlowDiagnostics: true, - effect: { kind: "model-access-changed" }, + effect: + result.addedConnections.length === 0 + ? { kind: "model-access-changed" } + : { kind: "connection-added" }, }; case "done": return { @@ -264,7 +267,10 @@ async function executeSetupCommand( ? "No connections added." : `Connections added: ${result.addedConnections.join(", ")}.`, preserveFlowDiagnostics: true, - effect: { kind: "model-access-changed" }, + effect: + result.addedConnections.length === 0 + ? { kind: "model-access-changed" } + : { kind: "connection-added" }, }; } } diff --git a/packages/eve/src/cli/dev/tui/tui.ts b/packages/eve/src/cli/dev/tui/tui.ts index 6e21882ce..ff0dd595f 100644 --- a/packages/eve/src/cli/dev/tui/tui.ts +++ b/packages/eve/src/cli/dev/tui/tui.ts @@ -1,11 +1,14 @@ import { Client } from "#client/index.js"; import type { DevBootProgressReporter } from "#internal/dev-boot-progress.js"; import { - resolveDevelopmentClientOptions, + resolveLocalDevelopmentClientOptions, resolveRemoteDevelopmentClientOptions, } from "#services/dev-client/client-options.js"; import { createDevelopmentCredentialGate } from "#services/dev-client/credential-gate.js"; -import { resolveDevelopmentOidcToken } from "#services/dev-client/request-headers.js"; +import { + resolveDevelopmentOidcToken, + resolveLinkedDevelopmentOidcToken, +} from "#services/dev-client/request-headers.js"; import { isVercelAuthChallenge } from "#services/dev-client/vercel-auth-error.js"; import { resolveVercelDeployment } from "#setup/vercel-deployment.js"; import { toErrorMessage } from "#shared/errors.js"; @@ -80,7 +83,10 @@ export async function runDevelopmentTui(input: RunDevelopmentTuiInput): Promise< const client = new Client( prepared.kind === "local" - ? resolveDevelopmentClientOptions(serverUrl) + ? resolveLocalDevelopmentClientOptions({ + serverUrl, + token: () => resolveLinkedDevelopmentOidcToken(prepared.target.workspaceRoot), + }) : resolveRemoteDevelopmentClientOptions({ serverUrl, credentials: prepared.remote.credentials, diff --git a/packages/eve/src/cli/run.test.ts b/packages/eve/src/cli/run.test.ts index 218a88ef4..79b6c0799 100644 --- a/packages/eve/src/cli/run.test.ts +++ b/packages/eve/src/cli/run.test.ts @@ -117,6 +117,56 @@ describe("eve dev --input", () => { }); describe("eve dev --url protocol", () => { + it("uses the local TUI credential path only for this app's running dev server", async () => { + const runDevelopmentTui = vi.fn(async () => {}); + + await withInteractiveTerminal(() => + runCli( + ["dev", "--url", "http://127.0.0.1:2000"], + { error: () => {}, log: () => {} }, + { + isActiveDevelopmentServerForApp: async () => true, + runDevelopmentTui, + }, + ), + ); + + expect(runDevelopmentTui).toHaveBeenCalledWith( + expect.objectContaining({ + target: { + kind: "local", + serverUrl: "http://127.0.0.1:2000/", + workspaceRoot: process.cwd(), + }, + }), + ); + }); + + it("keeps an unverified loopback URL on the remote credential path", async () => { + const runDevelopmentTui = vi.fn(async () => {}); + + await withInteractiveTerminal(() => + runCli( + ["dev", "--url", "http://127.0.0.1:2000"], + { error: () => {}, log: () => {} }, + { + isActiveDevelopmentServerForApp: async () => false, + runDevelopmentTui, + }, + ), + ); + + expect(runDevelopmentTui).toHaveBeenCalledWith( + expect.objectContaining({ + target: { + kind: "remote", + serverUrl: "http://127.0.0.1:2000/", + workspaceRoot: process.cwd(), + }, + }), + ); + }); + it("rejects an http:// remote URL up front instead of crashing during connect", async () => { await expect( runCli(["dev", "--url", "http://my-app.vercel.app"], { error: () => {}, log: () => {} }), diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index 5cf53e378..05628b9c7 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -64,6 +64,10 @@ interface CliRuntimeDependencies { options: EvalCliOptions, logger: CliLogger, ): Promise; + isActiveDevelopmentServerForApp(input: { + readonly appRoot: string; + readonly serverUrl: string; + }): Promise; startHost(appRoot: string, options?: DevelopmentServerOptions): Promise; startProductionHost( appRoot: string, @@ -134,6 +138,12 @@ async function loadStartHost(): Promise { return (await import("#internal/nitro/host.js")).startDevelopmentServer; } +async function loadIsActiveDevelopmentServerForApp(): Promise< + CliRuntimeDependencies["isActiveDevelopmentServerForApp"] +> { + return (await import("#internal/nitro/host.js")).isActiveDevelopmentServerForApp; +} + async function loadStartProductionHost(): Promise { return (await import("#internal/nitro/host.js")).startProductionServer; } @@ -490,6 +500,16 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm const { loadDevelopmentEnvironmentFiles } = await import("#cli/dev/environment.js"); loadDevelopmentEnvironmentFiles(appRoot); + const existingLocalDevelopmentServer = + remoteServerUrl === undefined + ? false + : await ( + runtime.isActiveDevelopmentServerForApp ?? + (await loadIsActiveDevelopmentServerForApp()) + )({ + appRoot, + serverUrl: remoteServerUrl, + }); const runInteractiveUi = async (serverUrl: string, report?: DevBootProgressReporter) => { const runDevelopmentTui = await devBootPhase( @@ -499,7 +519,7 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm ); const display = resolveTuiDisplayOptions(options); const target: DevelopmentTuiTarget = - remoteServerUrl === undefined + remoteServerUrl === undefined || existingLocalDevelopmentServer ? { kind: "local", serverUrl, workspaceRoot: appRoot } : { kind: "remote", serverUrl, workspaceRoot: appRoot }; const title = resolveTuiTitle({ name: options.name, target }); @@ -514,7 +534,9 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm }; if (remoteServerUrl) { - logger.log(`↗ remote mode targeting ${theme.info(new URL(remoteServerUrl).host)}`); + logger.log( + `↗ ${existingLocalDevelopmentServer ? "local" : "remote"} mode targeting ${theme.info(new URL(remoteServerUrl).host)}`, + ); if (mode === "headless") { logger.log( diff --git a/packages/eve/src/client/client.ts b/packages/eve/src/client/client.ts index 605430386..87aec69b7 100644 --- a/packages/eve/src/client/client.ts +++ b/packages/eve/src/client/client.ts @@ -194,8 +194,7 @@ export class Client { // Skip the header entirely on an empty token rather than emitting a // malformed `Bearer ` value the server has to reject. The dev client's // OIDC resolver returns "" when no token is available locally; the - // request then goes out unauthenticated and the framework's - // `vercelOidc()` channel handler returns a clean 401. + // request then follows the framework channel's local-dev fallback. const token = (await resolveTokenValue(auth.bearer)).trim(); return token.length === 0 ? {} : { authorization: `Bearer ${token}` }; } diff --git a/packages/eve/src/internal/nitro/host.ts b/packages/eve/src/internal/nitro/host.ts index c3d270e54..5be785841 100644 --- a/packages/eve/src/internal/nitro/host.ts +++ b/packages/eve/src/internal/nitro/host.ts @@ -1,5 +1,8 @@ export { buildApplication } from "#internal/nitro/host/build-application.js"; -export { startDevelopmentServer } from "#internal/nitro/host/start-development-server.js"; +export { + isActiveDevelopmentServerForApp, + startDevelopmentServer, +} from "#internal/nitro/host/start-development-server.js"; export { startProductionServer } from "#internal/nitro/host/start-production-server.js"; export type { DevelopmentServerHandle, diff --git a/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts b/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts index 0b60b2917..1fee30bae 100644 --- a/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts +++ b/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts @@ -266,19 +266,14 @@ describe("configureNitroRoutes", () => { method: "GET", route: "/eve/v1/dev/runtime-artifacts", }); - expect(devNitro.options.handlers).toContainEqual({ - handler: "#eve-route/eve/v1/dev/runtime-artifacts/rebuild", - method: "POST", - route: "/eve/v1/dev/runtime-artifacts/rebuild", - }); - expect(prodNitro.options.handlers).not.toContainEqual( + expect(devNitro.options.handlers).not.toContainEqual( expect.objectContaining({ - route: "/eve/v1/dev/runtime-artifacts", + route: "/eve/v1/dev/runtime-artifacts/rebuild", }), ); expect(prodNitro.options.handlers).not.toContainEqual( expect.objectContaining({ - route: "/eve/v1/dev/runtime-artifacts/rebuild", + route: "/eve/v1/dev/runtime-artifacts", }), ); }); diff --git a/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts b/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts index 4c4a82dcb..1c9f2b62e 100644 --- a/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts +++ b/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts @@ -4,7 +4,6 @@ import { dirname, join, relative } from "node:path"; import type { Nitro } from "nitro/types"; import { EVE_DEV_DISPATCH_SCHEDULE_ROUTE_PATTERN, - EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH, EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH, EVE_HEALTH_ROUTE_PATH, EVE_INFO_ROUTE_PATH, @@ -376,15 +375,6 @@ export async function configureNitroRoutes( ), route: EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH, }); - addFrameworkVirtualHandler(nitro, { - args: JSON.stringify({ appRoot: artifactsConfig.appRoot }), - handlerExport: "handleDevRuntimeArtifactsRebuildRequest", - method: "POST", - modulePath: resolvePackageSourceFilePath( - "src/internal/nitro/routes/dev-runtime-artifacts.ts", - ), - route: EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH, - }); addFrameworkVirtualHandler(nitro, { args: JSON.stringify({ appRoot: artifactsConfig.appRoot }), handlerExport: "handleDevScheduleDispatchRequest", diff --git a/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.scenario.test.ts b/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.scenario.test.ts index 0ddb39cbe..8a9a30cb4 100644 --- a/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.scenario.test.ts @@ -176,6 +176,27 @@ afterEach(async () => { }); describe("startAuthoredSourceWatcher", () => { + it("rebuilds when a local setup action knows authored source changed", async () => { + const previousHost = createPreparedHost(); + const nextHost = createPreparedHost(); + const nitroStub = createNitroStub(); + prepareApplicationHostMock.mockResolvedValueOnce(nextHost); + + const watcher = await startAuthoredSourceWatcher({ + nitro: nitroStub.nitro, + preparedHost: previousHost, + }); + + try { + await watcher.rebuild(); + + expect(prepareApplicationHostMock).toHaveBeenCalledWith(previousHost.appRoot, { dev: true }); + expect(clearCompiledRuntimeAgentBundleCacheMock).toHaveBeenCalledTimes(1); + } finally { + await watcher.close(); + } + }); + it("ignores generated output directories while watching authored source roots", async () => { const watcher = await startAuthoredSourceWatcher({ nitro: createNitroStub().nitro, diff --git a/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.ts b/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.ts index 01a12136b..d7d449c14 100644 --- a/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.ts +++ b/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.ts @@ -14,7 +14,6 @@ import { } from "#internal/nitro/host/channel-routes.js"; import { prepareApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; import { resolveNitroCompiledArtifactsSource } from "#internal/nitro/routes/runtime-artifacts.js"; -import { registerDevelopmentRebuildHandle } from "#internal/nitro/host/dev-rebuild-registry.js"; import type { PreparedApplicationHost } from "#internal/nitro/host/types.js"; import { getDevelopmentEnvironmentFilePaths, @@ -72,6 +71,7 @@ interface DevelopmentWatcherNitro { export interface AuthoredSourceWatcherHandle { close(): Promise; flush(): Promise; + rebuild(): Promise; } /** @@ -104,16 +104,11 @@ export async function startAuthoredSourceWatcher(input: { }); const watcherReady = waitForWatcherReady(watcher); - const flush = async () => { + const rebuild = async (force: boolean) => { if (closed) { return; } - if (debounceTimer !== undefined) { - clearTimeout(debounceTimer); - debounceTimer = undefined; - } - queue = queue .then(async () => { if (closed) { @@ -121,7 +116,7 @@ export async function startAuthoredSourceWatcher(input: { } const changeEvents = [...pendingEvents.values()]; - if (changeEvents.length === 0) { + if (!force && changeEvents.length === 0) { return; } @@ -137,7 +132,9 @@ export async function startAuthoredSourceWatcher(input: { previousHost.appRoot, changedPaths, ); - console.log(formatChangeDetectedLogLine(previousHost.appRoot, changeEvents)); + if (changeEvents.length > 0) { + console.log(formatChangeDetectedLogLine(previousHost.appRoot, changeEvents)); + } try { if (hasEnvironmentChange) { @@ -196,8 +193,20 @@ export async function startAuthoredSourceWatcher(input: { }); await queue; }; - const unregisterRebuildHandle = registerDevelopmentRebuildHandle(currentHost.appRoot, { flush }); - + const flush = async () => { + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer); + debounceTimer = undefined; + } + await rebuild(false); + }; + const forceRebuild = async () => { + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer); + debounceTimer = undefined; + } + await rebuild(true); + }; watcher.on("all", (event, changedPath) => { if (closed || !isWatcherReady) { return; @@ -220,7 +229,6 @@ export async function startAuthoredSourceWatcher(input: { return { async close() { closed = true; - unregisterRebuildHandle(); if (debounceTimer !== undefined) { clearTimeout(debounceTimer); @@ -231,6 +239,7 @@ export async function startAuthoredSourceWatcher(input: { await queue; }, flush, + rebuild: forceRebuild, }; } diff --git a/packages/eve/src/internal/nitro/host/dev-rebuild-registry.ts b/packages/eve/src/internal/nitro/host/dev-rebuild-registry.ts deleted file mode 100644 index b3cd82499..000000000 --- a/packages/eve/src/internal/nitro/host/dev-rebuild-registry.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { resolve } from "node:path"; - -export interface DevelopmentRebuildHandle { - flush(): Promise; -} - -const developmentRebuildHandles = new Map(); - -export function registerDevelopmentRebuildHandle( - appRoot: string, - handle: DevelopmentRebuildHandle, -): () => void { - const key = resolve(appRoot); - developmentRebuildHandles.set(key, handle); - - return () => { - if (developmentRebuildHandles.get(key) === handle) { - developmentRebuildHandles.delete(key); - } - }; -} - -export async function flushDevelopmentRebuild(appRoot: string): Promise { - await developmentRebuildHandles.get(resolve(appRoot))?.flush(); -} diff --git a/packages/eve/src/internal/nitro/host/start-development-server.test.ts b/packages/eve/src/internal/nitro/host/start-development-server.test.ts index 409a8a7f7..60164b1b9 100644 --- a/packages/eve/src/internal/nitro/host/start-development-server.test.ts +++ b/packages/eve/src/internal/nitro/host/start-development-server.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => { const authoredSourceWatcher = { close: vi.fn(async () => undefined), flush: vi.fn(async () => undefined), + rebuild: vi.fn(async () => undefined), }; const listenerServer = { close: vi.fn(async () => undefined), @@ -21,10 +22,16 @@ const mocks = vi.hoisted(() => { upgrade: vi.fn(async (_req: unknown, _socket: unknown, _head: unknown) => undefined), }; const files = new Map(); + const devHandlers: Array<{ + handler: (event: { readonly req: { readonly url: string } }) => Promise; + method?: string; + route?: string; + }> = []; const nitro = { close: vi.fn(async () => undefined), options: { buildDir: "/tmp/eve-test/.eve/nitro", + devHandlers, devServer: { hostname: "127.0.0.1", port: 0, @@ -41,6 +48,9 @@ const mocks = vi.hoisted(() => { createDevServer: vi.fn(() => devServer), devServer, files, + handleDevRuntimeArtifactsRequest: vi.fn(() => + Response.json({ revision: "/tmp/eve-test/.eve/dev-runtime/snapshots/current" }), + ), listenerServer, mkdir: vi.fn(async () => undefined), nitro, @@ -109,6 +119,10 @@ vi.mock("#internal/nitro/routes/runtime-artifacts.js", () => ({ resolveNitroCompiledArtifactsSource: mocks.resolveNitroCompiledArtifactsSource, })); +vi.mock("#internal/nitro/routes/dev-runtime-artifacts.js", () => ({ + handleDevRuntimeArtifactsRequest: mocks.handleDevRuntimeArtifactsRequest, +})); + vi.mock("#execution/sandbox/development-prewarm.js", () => ({ startDevelopmentSandboxPrewarmInBackground: mocks.startDevelopmentSandboxPrewarmInBackground, })); @@ -198,6 +212,30 @@ describe("normalizeDevelopmentServerClientUrl", () => { }); }); +describe("isActiveDevelopmentServerForApp", () => { + it("accepts only the live server URL recorded for this app", async () => { + const { isActiveDevelopmentServerForApp } = await import("./start-development-server.js"); + mocks.files.set(developmentProcessIdPath, `${process.pid}\n`); + mocks.files.set( + developmentServerMetadataPath, + JSON.stringify({ pid: process.pid, url: "http://127.0.0.1:42123/" }), + ); + + await expect( + isActiveDevelopmentServerForApp({ + appRoot: "/tmp/eve-test", + serverUrl: "http://127.0.0.1:42123/", + }), + ).resolves.toBe(true); + await expect( + isActiveDevelopmentServerForApp({ + appRoot: "/tmp/eve-test", + serverUrl: "http://127.0.0.1:42124/", + }), + ).resolves.toBe(false); + }); +}); + describe("startDevelopmentServer", () => { beforeEach(() => { vi.clearAllMocks(); @@ -212,6 +250,7 @@ describe("startDevelopmentServer", () => { experimental: {}, features: {}, }); + mocks.nitro.options.devHandlers.splice(0); Object.assign(mocks.nitro.options.devServer, { hostname: "127.0.0.1", port: undefined, @@ -264,6 +303,41 @@ describe("startDevelopmentServer", () => { expect(process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID).toBeUndefined(); }); + it("rebuilds authored artifacts through the host-owned dev handler", async () => { + const { EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH } = await import("#protocol/routes.js"); + const server = await startServer(); + const handler = mocks.nitro.options.devHandlers.find( + (candidate) => candidate.route === EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH, + ); + + expect(handler).toBeDefined(); + if (handler === undefined) { + throw new Error("Expected the development runtime artifacts rebuild handler."); + } + + await handler.handler({ + req: { + url: `http://localhost${EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH}?force=1`, + }, + }); + + expect(mocks.authoredSourceWatcher.rebuild).toHaveBeenCalledTimes(1); + expect(mocks.authoredSourceWatcher.flush).not.toHaveBeenCalled(); + expect(mocks.handleDevRuntimeArtifactsRequest).toHaveBeenCalledWith({ + appRoot: "/tmp/eve-test", + }); + + await handler.handler({ + req: { + url: `http://localhost${EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH}`, + }, + }); + + expect(mocks.authoredSourceWatcher.flush).toHaveBeenCalledTimes(1); + + await server.close(); + }); + it("uses eve's default port when no port is requested", async () => { const { startDevelopmentServer } = await import("./start-development-server.js"); Object.assign(mocks.nitro.options.devServer, { diff --git a/packages/eve/src/internal/nitro/host/start-development-server.ts b/packages/eve/src/internal/nitro/host/start-development-server.ts index 06c0ee49a..d74b22f23 100644 --- a/packages/eve/src/internal/nitro/host/start-development-server.ts +++ b/packages/eve/src/internal/nitro/host/start-development-server.ts @@ -12,6 +12,7 @@ import { createApplicationNitro } from "#internal/nitro/host/create-application- import { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import type { AuthoredSourceWatcherHandle } from "#internal/nitro/host/dev-authored-source-watcher.js"; import { prepareApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; +import { handleDevRuntimeArtifactsRequest } from "#internal/nitro/routes/dev-runtime-artifacts.js"; import { resolveNitroCompiledArtifactsSource } from "#internal/nitro/routes/runtime-artifacts.js"; import { pruneLocalSandboxTemplatesInBackground, @@ -37,6 +38,7 @@ import { import { detectPackageManager, type PackageManagerKind } from "#setup/package-manager.js"; import { eveDevArguments } from "#setup/primitives/index.js"; import { devBootPhase } from "#internal/dev-boot-progress.js"; +import { EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH } from "#protocol/routes.js"; const MAX_ALLOWED_DEVELOPMENT_SERVER_PORT = 65_535; const WORKFLOW_LOCAL_BASE_URL_ENV = "WORKFLOW_LOCAL_BASE_URL"; @@ -90,6 +92,34 @@ function isAddressInUseError(error: unknown): error is NodeJS.ErrnoException { type NitroDevelopmentServer = ReturnType; type NitroDevelopmentServerUpgrade = NitroDevelopmentServer["upgrade"]; +function addDevelopmentRuntimeArtifactsRebuildHandler(input: { + readonly appRoot: string; + readonly getWatcher: () => AuthoredSourceWatcherHandle | undefined; + readonly nitro: Nitro; +}): void { + input.nitro.options.devHandlers.push({ + handler: async (event) => { + const watcher = input.getWatcher(); + if (watcher === undefined) { + return Response.json( + { error: "Development source watcher is not ready." }, + { status: 503 }, + ); + } + + if (new URL(event.req.url).searchParams.get("force") === "1") { + await watcher.rebuild(); + } else { + await watcher.flush(); + } + + return handleDevRuntimeArtifactsRequest({ appRoot: input.appRoot }); + }, + method: "POST", + route: EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH, + }); +} + function resolveDevelopmentServerPort(port: number | string | undefined): number { const resolvedPort = typeof port === "string" ? Number(port) : (port ?? DEFAULT_DEVELOPMENT_SERVER_PORT); @@ -237,6 +267,27 @@ async function readActiveDevelopmentProcess( }; } +/** + * Returns whether `serverUrl` identifies this app's live local development + * server. The PID and URL must both match the metadata written at startup. + */ +export async function isActiveDevelopmentServerForApp(input: { + readonly appRoot: string; + readonly serverUrl: string; +}): Promise { + const activeProcess = await readActiveDevelopmentProcess(input.appRoot); + if (activeProcess?.url === undefined) return false; + + try { + return ( + new URL(activeProcess.url).origin === + new URL(normalizeDevelopmentServerClientUrl(input.serverUrl)).origin + ); + } catch { + return false; + } +} + async function detectDevelopmentCommandPackageManager( appRoot: string, ): Promise { @@ -509,6 +560,11 @@ export async function startDevelopmentServer( options.onBootProgress, ); nitro = activeNitro; + addDevelopmentRuntimeArtifactsRebuildHandler({ + appRoot: preparedHost.appRoot, + getWatcher: () => authoredSourceWatcher, + nitro: activeNitro, + }); devServer = createDevServer(activeNitro); const activeDevServer = devServer; guardDevelopmentServerWebSocketUpgrades(activeNitro, devServer); diff --git a/packages/eve/src/internal/nitro/routes/dev-runtime-artifacts.test.ts b/packages/eve/src/internal/nitro/routes/dev-runtime-artifacts.test.ts index a130044cc..49e9f24b6 100644 --- a/packages/eve/src/internal/nitro/routes/dev-runtime-artifacts.test.ts +++ b/packages/eve/src/internal/nitro/routes/dev-runtime-artifacts.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - flushDevelopmentRebuild: vi.fn(), readDevelopmentRuntimeArtifactsRevision: vi.fn(), })); @@ -15,12 +14,7 @@ vi.mock("#internal/nitro/dev-runtime-artifacts.js", async () => { }; }); -vi.mock("#internal/nitro/host/dev-rebuild-registry.js", () => ({ - flushDevelopmentRebuild: mocks.flushDevelopmentRebuild, -})); - beforeEach(() => { - mocks.flushDevelopmentRebuild.mockReset(); mocks.readDevelopmentRuntimeArtifactsRevision.mockReset(); }); @@ -41,20 +35,4 @@ describe("handleDevRuntimeArtifactsRequest", () => { }); expect(mocks.readDevelopmentRuntimeArtifactsRevision).toHaveBeenCalledWith("/tmp/app"); }); - - it("flushes queued rebuilds before returning the current revision", async () => { - const { handleDevRuntimeArtifactsRebuildRequest } = - await import("#internal/nitro/routes/dev-runtime-artifacts.js"); - mocks.readDevelopmentRuntimeArtifactsRevision.mockReturnValueOnce({ - revision: "/tmp/app/.eve/dev-runtime/snapshots/next", - }); - - const response = await handleDevRuntimeArtifactsRebuildRequest({ appRoot: "/tmp/app" }); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - revision: "/tmp/app/.eve/dev-runtime/snapshots/next", - }); - expect(mocks.flushDevelopmentRebuild).toHaveBeenCalledWith("/tmp/app"); - }); }); diff --git a/packages/eve/src/internal/nitro/routes/dev-runtime-artifacts.ts b/packages/eve/src/internal/nitro/routes/dev-runtime-artifacts.ts index 61674d5f9..f2e3d999a 100644 --- a/packages/eve/src/internal/nitro/routes/dev-runtime-artifacts.ts +++ b/packages/eve/src/internal/nitro/routes/dev-runtime-artifacts.ts @@ -1,5 +1,4 @@ import { readDevelopmentRuntimeArtifactsRevision } from "#internal/nitro/dev-runtime-artifacts.js"; -import { flushDevelopmentRebuild } from "#internal/nitro/host/dev-rebuild-registry.js"; /** * Builds the dev-only runtime artifact revision response. @@ -15,10 +14,3 @@ export function handleDevRuntimeArtifactsRequest(input: { appRoot: string }): Re }, }); } - -export async function handleDevRuntimeArtifactsRebuildRequest(input: { - appRoot: string; -}): Promise { - await flushDevelopmentRebuild(input.appRoot); - return handleDevRuntimeArtifactsRequest(input); -} diff --git a/packages/eve/src/internal/vercel/project-link.ts b/packages/eve/src/internal/vercel/project-link.ts new file mode 100644 index 000000000..da1ecd6e5 --- /dev/null +++ b/packages/eve/src/internal/vercel/project-link.ts @@ -0,0 +1,26 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { z } from "#compiled/zod/index.js"; + +export const VercelProjectLinkSchema = z.object({ + projectId: z.string().min(1), + orgId: z.string().min(1), + projectName: z.string().min(1).optional(), +}); + +/** Validated Vercel owner and project identifiers from `.vercel/project.json`. */ +export type VercelProjectLink = z.infer; + +/** Reads a validated Vercel project link without mutating local project state. */ +export async function readVercelProjectLink( + projectPath: string, +): Promise { + try { + const raw = await readFile(join(projectPath, ".vercel", "project.json"), "utf8"); + const parsed = VercelProjectLinkSchema.safeParse(JSON.parse(raw)); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } +} diff --git a/packages/eve/src/public/channels/auth.test.ts b/packages/eve/src/public/channels/auth.test.ts index d29f8e531..f56deb25b 100644 --- a/packages/eve/src/public/channels/auth.test.ts +++ b/packages/eve/src/public/channels/auth.test.ts @@ -694,6 +694,89 @@ describe("verifyVercelOidc", () => { } }); + it("authenticates a development Vercel user token as a user principal", async () => { + vi.stubEnv("VERCEL_PROJECT_ID", "prj_current"); + vi.stubEnv("VERCEL_TARGET_ENV", "development"); + + const issuer = await installMockedVercelIssuer("development-user-accept"); + try { + const token = await issuer.signToken({ + environment: "development", + owner: "acme", + owner_id: "team_acme", + project: "weather-agent", + project_id: "prj_current", + sub: "owner:acme:project:weather-agent:environment:development", + user_id: "user_ada", + }); + + const result = await verifyVercelOidc(token); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.sessionAuth).toMatchObject({ + authenticator: "oidc", + principalType: "user", + subject: "user_ada", + }); + } + } finally { + issuer.restore(); + } + }); + + it("uses an explicit current-project binding for a development user token", async () => { + vi.stubEnv("VERCEL_PROJECT_ID", ""); + vi.stubEnv("VERCEL_TARGET_ENV", ""); + vi.stubEnv("VERCEL_ENV", ""); + + const issuer = await installMockedVercelIssuer("development-user-explicit-project"); + try { + const token = await issuer.signToken({ + environment: "development", + project_id: "prj_current", + sub: "owner:acme:project:weather-agent:environment:development", + user_id: "user_ada", + }); + + const result = await verifyVercelOidc(token, { + currentVercelProject: { + environment: "development", + projectId: "prj_current", + }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.sessionAuth).toMatchObject({ + principalType: "user", + subject: "user_ada", + }); + } + } finally { + issuer.restore(); + } + }); + + it("rejects a user_id claim outside development", async () => { + vi.stubEnv("VERCEL_PROJECT_ID", "prj_current"); + vi.stubEnv("VERCEL_TARGET_ENV", "preview"); + + const issuer = await installMockedVercelIssuer("non-development-user-id"); + try { + const token = await issuer.signToken({ + environment: "preview", + project_id: "prj_current", + sub: "owner:acme:project:weather-agent:environment:preview", + user_id: "user_ada", + }); + + await expect(verifyVercelOidc(token)).resolves.toEqual({ ok: false }); + } finally { + issuer.restore(); + } + }); + it("rejects a Vercel-issued token whose project_id does not match VERCEL_PROJECT_ID", async () => { // Demonstrates the security fix: a token minted for an unrelated // Vercel project (with a fully valid signature, audience, and issuer diff --git a/packages/eve/src/public/channels/auth.ts b/packages/eve/src/public/channels/auth.ts index 396eb131b..ac6d81434 100644 --- a/packages/eve/src/public/channels/auth.ts +++ b/packages/eve/src/public/channels/auth.ts @@ -268,6 +268,7 @@ export async function verifyOidc( const result = await runOidcVerification(token, { ...config, acceptCurrentVercelProject: false, + currentVercelProject: undefined, }); return result.kind === "authenticated" ? { ok: true, sessionAuth: createRuntimeSessionAuthContext(result.principal) } @@ -284,13 +285,16 @@ export async function verifyOidc( */ async function runOidcVerification( token: string | null, - config: VerifyOidcConfig & { readonly acceptCurrentVercelProject: boolean }, + config: VerifyOidcConfig & { + readonly acceptCurrentVercelProject: boolean; + readonly currentVercelProject: ResolvedOidcAuthStrategy["currentVercelProject"] | undefined; + }, ): Promise { if (token === null || token.length === 0) { return { kind: "not-authenticated" }; } - const strategy: ResolvedOidcAuthStrategy = { + const baseStrategy = { acceptCurrentVercelProject: config.acceptCurrentVercelProject, audiences: [...config.audiences], clockSkewSeconds: config.clockSkewSeconds ?? 30, @@ -300,7 +304,11 @@ async function runOidcVerification( kind: "oidc", ...(config.claims === undefined ? {} : { claims: config.claims }), ...(config.subjects === undefined ? {} : { subjects: config.subjects }), - }; + } satisfies ResolvedOidcAuthStrategy; + const strategy = + config.currentVercelProject === undefined + ? baseStrategy + : { ...baseStrategy, currentVercelProject: config.currentVercelProject }; return await authenticateOidcStrategy({ strategy, token }); } @@ -627,7 +635,8 @@ const LOOPBACK_HOSTNAMES: ReadonlySet = new Set(["localhost", "[::1]"]); */ const LOOPBACK_IPV4_PREFIX = /^127\./; -function isLoopbackRequest(request: Request): boolean { +/** Returns whether a request URL names a loopback host accepted by {@link localDev}. */ +export function isLoopbackRequest(request: Request): boolean { let hostname: string; try { hostname = new URL(request.url).hostname; @@ -679,6 +688,15 @@ const VERCEL_OIDC_AUDIENCE_PREFIX = "https://vercel.com/"; * Options for {@link verifyVercelOidc} and {@link vercelOidc}. */ export interface VerifyVercelOidcOptions { + /** + * Explicit current-project binding for the verified token. When omitted, + * the verifier reads `VERCEL_PROJECT_ID` and `VERCEL_TARGET_ENV` / + * `VERCEL_ENV` from the runtime environment. + */ + readonly currentVercelProject?: { + readonly environment?: string; + readonly projectId: string; + }; /** * Optional `sub` patterns granting callers access on top of the always-on * current-project bypass. Patterns use AWS IAM-style `*` wildcards and may @@ -694,16 +712,19 @@ export interface VerifyVercelOidcOptions { * * Acceptance rule: * - * - Tokens whose `project_id` matches `VERCEL_PROJECT_ID` are **always** + * - Tokens whose `project_id` matches the configured current project are **always** * accepted regardless of `subjects`, so the deployment's own runtime * callers (subagent, internal fetches) authenticate without being * enumerated. * - Tokens with an `external_sub` claim authenticate as - * `principalType: "user"` when they match the current `VERCEL_PROJECT_ID` - * (if set) and `VERCEL_TARGET_ENV` / `VERCEL_ENV` (if set). `external_sub` + * `principalType: "user"` when they match the configured current project + * and environment. `external_sub` * becomes the eve subject, `external_iss` or `connector_id` the eve issuer * when present, and string-valued OIDC profile claims (`name`, `picture`, * `email`) are exposed as auth attributes. + * - Development tokens with a `user_id` claim authenticate as + * `principalType: "user"` only when both the token and configured current + * project environment are `development`. * - Tokens from other Vercel projects are accepted **only** when their `sub` * matches one of {@link VerifyVercelOidcOptions.subjects}. * @@ -751,13 +772,15 @@ export async function verifyVercelOidc( } // `acceptCurrentVercelProject: true` activates the same-project bypass - // inside the OIDC verifier so any token minted for `VERCEL_PROJECT_ID` + // inside the OIDC verifier so any token minted for the configured project // is accepted regardless of `subjects`. The supplied `subjects` // matcher (defaulting to an empty list that matches nothing) // determines which **other** callers are allowed in. + const currentVercelProject = resolveCurrentVercelProject(opts); const result = await runOidcVerification(token, { acceptCurrentVercelProject: true, audiences: claims.audiences, + currentVercelProject, issuer: claims.issuer, subjects: opts.subjects ?? [], }); @@ -781,6 +804,20 @@ export async function verifyVercelOidc( return { ok: false }; } +function resolveCurrentVercelProject( + opts: VerifyVercelOidcOptions, +): NonNullable | undefined { + if (opts.currentVercelProject !== undefined) return opts.currentVercelProject; + + const projectId = process.env.VERCEL_PROJECT_ID?.trim(); + if (projectId === undefined || projectId.length === 0) return undefined; + + const environment = process.env.VERCEL_TARGET_ENV?.trim() || process.env.VERCEL_ENV?.trim(); + return environment === undefined || environment.length === 0 + ? { projectId } + : { environment, projectId }; +} + /** * Allowed values for {@link VercelSubjectInput.environment}. Use `"*"` * to match any of `production`, `preview`, and `development` for the diff --git a/packages/eve/src/runtime/connections/principal.test.ts b/packages/eve/src/runtime/connections/principal.test.ts index 17fcd2313..c131922f2 100644 --- a/packages/eve/src/runtime/connections/principal.test.ts +++ b/packages/eve/src/runtime/connections/principal.test.ts @@ -35,6 +35,11 @@ const userAuthDef: AuthorizationDefinition = { principalType: "user", }; +const connectUserAuthDef: AuthorizationDefinition = { + ...userAuthDef, + vercelConnect: { connector: "mcp.notion.com/notion" }, +}; + describe("principalKey", () => { it("collapses every app principal to the string 'app'", () => { expect(principalKey({ type: "app" })).toBe("app"); @@ -50,6 +55,10 @@ describe("principalKey", () => { const bob = principalKey({ id: "bob", issuer: "idp", type: "user" }); expect(alice).not.toBe(bob); }); + + it("keys an issuerless native Vercel user by its user id", () => { + expect(principalKey({ id: "user_123", type: "user" })).toBe("user:user_123"); + }); }); describe("resolveConnectionPrincipal", () => { @@ -90,6 +99,50 @@ describe("resolveConnectionPrincipal", () => { }); }); + it("projects a Vercel development user without its reserved OIDC issuer", () => { + const ctx = ctxWithAuth( + userAuth({ + attributes: { environment: "development", user_id: "user_123" }, + authenticator: "oidc", + issuer: "https://oidc.vercel.com/team_123", + principalId: "https://oidc.vercel.com/team_123:user_123", + subject: "user_123", + }), + ); + + const principal = contextStorage.run(ctx, () => + resolveConnectionPrincipal("notion", connectUserAuthDef), + ); + + expect(principal).toEqual({ + attributes: { environment: "development", user_id: "user_123" }, + id: "user_123", + type: "user", + }); + }); + + it("preserves a Vercel development user's issuer for non-Connect authorization", () => { + const ctx = ctxWithAuth( + userAuth({ + attributes: { environment: "development", user_id: "user_123" }, + authenticator: "oidc", + issuer: "https://oidc.vercel.com/team_123", + principalId: "https://oidc.vercel.com/team_123:user_123", + subject: "user_123", + }), + ); + + const principal = contextStorage.run(ctx, () => + resolveConnectionPrincipal("custom", userAuthDef), + ); + + expect(principal).toMatchObject({ + id: "https://oidc.vercel.com/team_123:user_123", + issuer: "https://oidc.vercel.com/team_123", + type: "user", + }); + }); + it("preserves the full SessionAuthContext attributes on the principal", () => { const attributes = { email: "a@b.com", roles: ["admin", "viewer"] }; const ctx = ctxWithAuth(userAuth({ attributes })); diff --git a/packages/eve/src/runtime/connections/principal.ts b/packages/eve/src/runtime/connections/principal.ts index a9a689bff..09524dae4 100644 --- a/packages/eve/src/runtime/connections/principal.ts +++ b/packages/eve/src/runtime/connections/principal.ts @@ -11,7 +11,7 @@ */ import { type AlsContext, contextStorage } from "#context/container.js"; -import { AuthKey } from "#context/keys.js"; +import { AuthKey, type SessionAuthContext } from "#context/keys.js"; import { ConnectionAuthorizationFailedError } from "#public/connections/errors.js"; import type { AuthorizationDefinition, ConnectionPrincipal } from "#runtime/connections/types.js"; @@ -24,11 +24,16 @@ import type { AuthorizationDefinition, ConnectionPrincipal } from "#runtime/conn * issuer prefix prevents collisions when the same `id` across * identity providers (for example Slack `U123` vs Google `U123`) * would otherwise alias to the same cache slot. + * - `{ type: "user", id }` → `"user:${id}"`. This is the native + * Vercel Connect user projection. */ export function principalKey(principal: ConnectionPrincipal): string { if (principal.type === "app") { return "app"; } + if (principal.issuer === undefined) { + return `user:${principal.id}`; + } return `user:${principal.issuer}:${principal.id}`; } @@ -91,6 +96,14 @@ export function resolveConnectionPrincipal( }); } + if (authorization.vercelConnect !== undefined && isVercelDevelopmentUser(current)) { + return { + attributes: current.attributes, + id: current.subject ?? current.principalId, + type: "user", + }; + } + return { attributes: current.attributes, id: current.principalId, @@ -99,6 +112,15 @@ export function resolveConnectionPrincipal( }; } +function isVercelDevelopmentUser(current: SessionAuthContext): boolean { + return ( + current.authenticator === "oidc" && + current.issuer?.startsWith("https://oidc.vercel.com/") === true && + current.attributes.environment === "development" && + current.subject === current.attributes.user_id + ); +} + function buildUserPrincipalRequiredMessage( connectionName: string, ctx: AlsContext | undefined, diff --git a/packages/eve/src/runtime/connections/types.ts b/packages/eve/src/runtime/connections/types.ts index 8a281ecb9..453589fd9 100644 --- a/packages/eve/src/runtime/connections/types.ts +++ b/packages/eve/src/runtime/connections/types.ts @@ -83,13 +83,16 @@ export type ToolFilterDefinition = * - `{ type: "user", id, issuer }`: per end-user identity; the token * cache keys on `issuer + id` so the same `id` across different * IdPs (Slack `U123` vs Google `U123`) never collides. + * - `{ type: "user", id }`: Vercel Connect's native user subject, used for a + * verified Vercel development user. The Vercel OIDC issuer is not forwarded + * because Connect rejects it. */ export type ConnectionPrincipal = | { readonly type: "app" } | { readonly type: "user"; readonly id: string; - readonly issuer: string; + readonly issuer?: string; readonly attributes?: Readonly>; }; diff --git a/packages/eve/src/runtime/framework-channels/index.test.ts b/packages/eve/src/runtime/framework-channels/index.test.ts new file mode 100644 index 000000000..948d2e8ac --- /dev/null +++ b/packages/eve/src/runtime/framework-channels/index.test.ts @@ -0,0 +1,181 @@ +import type { AuthFn } from "#public/channels/auth.js"; +import type { EveChannelInput } from "#public/channels/eve.js"; +import type { SessionAuthContext } from "#channel/types.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + readVercelProjectLink: vi.fn(), + vercelOidc: vi.fn(), +})); + +let capturedAuth: EveChannelInput["auth"] | undefined; + +vi.mock("#internal/vercel/project-link.js", () => ({ + readVercelProjectLink: mocks.readVercelProjectLink, +})); + +vi.mock("#public/channels/auth.js", async (importOriginal) => ({ + ...(await importOriginal()), + vercelOidc: mocks.vercelOidc, +})); + +vi.mock("#public/channels/eve.js", () => ({ + eveChannel(input: EveChannelInput) { + capturedAuth = input.auth; + return { adapter: {}, routes: [] }; + }, +})); + +import { getFrameworkChannelDefinitions } from "./index.js"; + +const USER_AUTH: SessionAuthContext = { + attributes: {}, + authenticator: "oidc", + issuer: "https://oidc.vercel.com/acme", + principalId: "https://oidc.vercel.com/acme:user_ada", + principalType: "user", + subject: "user_ada", +}; + +const RUNTIME_AUTH: SessionAuthContext = { + attributes: {}, + authenticator: "oidc", + principalId: "https://oidc.vercel.com/acme:runtime", + principalType: "runtime", +}; + +function frameworkAuth(appRoot: string): readonly AuthFn[] { + capturedAuth = undefined; + getFrameworkChannelDefinitions({ appRoot }); + + if (!Array.isArray(capturedAuth)) throw new Error("Expected ordered framework route auth."); + return capturedAuth; +} + +afterEach(() => { + capturedAuth = undefined; + mocks.readVercelProjectLink.mockReset(); + mocks.vercelOidc.mockReset(); + vi.unstubAllEnvs(); +}); + +describe("framework eve channel auth", () => { + it("uses a linked development project to prefer verified Vercel user auth", async () => { + vi.stubEnv("VERCEL_TARGET_ENV", ""); + vi.stubEnv("VERCEL_ENV", ""); + mocks.readVercelProjectLink.mockResolvedValue({ + orgId: "team_acme", + projectId: "prj_current", + }); + mocks.vercelOidc.mockImplementation( + (options?: { readonly currentVercelProject?: unknown }) => async () => + options?.currentVercelProject === undefined ? null : USER_AUTH, + ); + + const [vercelAuth] = frameworkAuth("/workspace"); + if (vercelAuth === undefined) + throw new Error("Expected Vercel auth before the local fallback."); + + await expect( + vercelAuth( + new Request("http://localhost/eve/v1/session", { + headers: { authorization: "Bearer signed-vercel-oidc-token" }, + }), + ), + ).resolves.toEqual(USER_AUTH); + + expect(mocks.readVercelProjectLink).toHaveBeenCalledWith("/workspace"); + expect(mocks.vercelOidc).toHaveBeenCalledWith({ + currentVercelProject: { + environment: "development", + projectId: "prj_current", + }, + }); + }); + + it("keeps an unlinked local request on the local-dev fallback", async () => { + vi.stubEnv("VERCEL_TARGET_ENV", ""); + vi.stubEnv("VERCEL_ENV", ""); + mocks.readVercelProjectLink.mockResolvedValue(undefined); + mocks.vercelOidc.mockImplementation(() => async () => null); + + const [vercelAuth, localAuth] = frameworkAuth("/workspace"); + if (vercelAuth === undefined || localAuth === undefined) { + throw new Error("Expected Vercel auth followed by the local fallback."); + } + + const request = new Request("http://localhost/eve/v1/session"); + await expect(vercelAuth(request)).resolves.toBeNull(); + expect(localAuth(request)).toMatchObject({ + principalId: "local-dev", + principalType: "local-dev", + }); + }); + + it("does not let a non-user Vercel principal shadow the local fallback", async () => { + vi.stubEnv("VERCEL_TARGET_ENV", ""); + vi.stubEnv("VERCEL_ENV", ""); + mocks.readVercelProjectLink.mockResolvedValue({ + orgId: "team_acme", + projectId: "prj_current", + }); + mocks.vercelOidc.mockImplementation( + (options?: { readonly currentVercelProject?: unknown }) => async () => + options?.currentVercelProject === undefined ? null : RUNTIME_AUTH, + ); + + const [vercelAuth, localAuth] = frameworkAuth("/workspace"); + if (vercelAuth === undefined || localAuth === undefined) { + throw new Error("Expected Vercel auth followed by the local fallback."); + } + + const request = new Request("http://localhost/eve/v1/session", { + headers: { authorization: "Bearer signed-vercel-oidc-token" }, + }); + await expect(vercelAuth(request)).resolves.toBeNull(); + expect(localAuth(request)).toMatchObject({ principalType: "local-dev" }); + }); + + it("does not bind a public non-Vercel request to the local project link", async () => { + vi.stubEnv("VERCEL", ""); + vi.stubEnv("VERCEL_ENV", ""); + mocks.vercelOidc.mockImplementation( + (options?: { readonly currentVercelProject?: unknown }) => async () => + options?.currentVercelProject === undefined ? RUNTIME_AUTH : USER_AUTH, + ); + + const [vercelAuth] = frameworkAuth("/workspace"); + if (vercelAuth === undefined) + throw new Error("Expected Vercel auth before the local fallback."); + + await expect( + vercelAuth( + new Request("https://public.example/eve/v1/session", { + headers: { authorization: "Bearer signed-vercel-oidc-token" }, + }), + ), + ).resolves.toEqual(RUNTIME_AUTH); + + expect(mocks.readVercelProjectLink).not.toHaveBeenCalled(); + }); + + it("keeps deployed runtimes on their environment-backed Vercel OIDC policy", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_ENV", "preview"); + mocks.vercelOidc.mockImplementation(() => async () => USER_AUTH); + + const [vercelAuth] = frameworkAuth("/workspace"); + if (vercelAuth === undefined) + throw new Error("Expected Vercel auth before the local fallback."); + + await expect( + vercelAuth( + new Request("http://localhost/eve/v1/session", { + headers: { authorization: "Bearer signed-vercel-oidc-token" }, + }), + ), + ).resolves.toEqual(USER_AUTH); + + expect(mocks.readVercelProjectLink).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/runtime/framework-channels/index.ts b/packages/eve/src/runtime/framework-channels/index.ts index bd2a20050..a9e4b6c70 100644 --- a/packages/eve/src/runtime/framework-channels/index.ts +++ b/packages/eve/src/runtime/framework-channels/index.ts @@ -1,4 +1,11 @@ -import { localDev, vercelOidc } from "#public/channels/auth.js"; +import { readVercelProjectLink } from "#internal/vercel/project-link.js"; +import { + extractBearerToken, + isLoopbackRequest, + localDev, + vercelOidc, + type AuthFn, +} from "#public/channels/auth.js"; import { eveChannel } from "#public/channels/eve.js"; import type { CompiledChannel } from "#channel/compiled-channel.js"; import { isHttpRouteDefinition } from "#channel/routes.js"; @@ -15,15 +22,16 @@ import type { ResolvedChannelDefinition } from "#runtime/types.js"; const EVE_CHANNEL_NAME = "eve"; /** - * Framework default for the eve channel. Mirrors verbatim the scaffold - * written by eve into `agent/channels/eve.ts`, so the - * behavior of "no authored file" and "scaffolded file untouched" is - * byte-identical — and the local-dev / Vercel branching lives in a - * single named helper (`localDev`) instead of an env check buried in - * the framework. + * Framework default for the eve channel. When the runtime knows the app root, + * local development binds an incoming Vercel OIDC token to that directory's + * current Vercel project link before falling back to unauthenticated loopback. */ -export function getFrameworkChannelDefinitions(): readonly ResolvedChannelDefinition[] { - const compiled = eveChannel({ auth: [localDev(), vercelOidc()] }) as CompiledChannel; +export function getFrameworkChannelDefinitions( + input: { readonly appRoot?: string } = {}, +): readonly ResolvedChannelDefinition[] { + const compiled = eveChannel({ + auth: [createFrameworkVercelOidc(input.appRoot), localDev()], + }) as CompiledChannel; const result: ResolvedChannelDefinition[] = []; @@ -52,6 +60,35 @@ export function getFrameworkChannelDefinitions(): readonly ResolvedChannelDefini return result; } +function createFrameworkVercelOidc(appRoot: string | undefined): AuthFn { + const defaultVercelOidc = vercelOidc(); + if (appRoot === undefined) return defaultVercelOidc; + + return async (request) => { + if (!isLocalDevelopmentRequest(request)) return await defaultVercelOidc(request); + if (extractBearerToken(request.headers.get("authorization")) === null) return null; + + const link = await readVercelProjectLink(appRoot); + if (link === undefined) return await defaultVercelOidc(request); + + const auth = await vercelOidc({ + currentVercelProject: { + environment: "development", + projectId: link.projectId, + }, + })(request); + return auth?.principalType === "user" ? auth : null; + }; +} + +function isLocalDevelopmentRequest(request: Request): boolean { + if (process.env.VERCEL_ENV === "development") return true; + if (process.env.VERCEL_ENV === "preview" || process.env.VERCEL_ENV === "production") { + return false; + } + return isLoopbackRequest(request); +} + export function getAllFrameworkChannelNames(): ReadonlySet { return new Set([ EVE_CHANNEL_NAME, diff --git a/packages/eve/src/runtime/governance/auth/oidc.ts b/packages/eve/src/runtime/governance/auth/oidc.ts index 367b68793..ae83472f4 100644 --- a/packages/eve/src/runtime/governance/auth/oidc.ts +++ b/packages/eve/src/runtime/governance/auth/oidc.ts @@ -56,8 +56,8 @@ export async function authenticateOidcStrategy(input: { if ( typeof verified.payload.external_sub !== "string" || verified.payload.external_sub.length === 0 || - !currentVercelProjectMatches({ payload: verified.payload }) || - !currentVercelEnvironmentMatches({ payload: verified.payload }) + !currentVercelProjectMatches({ payload: verified.payload, strategy: input.strategy }) || + !currentVercelEnvironmentMatches({ payload: verified.payload, strategy: input.strategy }) ) { return { kind: "caller-not-allowed", @@ -76,6 +76,34 @@ export async function authenticateOidcStrategy(input: { }; } + const hasDevelopmentUserSubject = + input.strategy.acceptCurrentVercelProject && + input.strategy.issuer.startsWith("https://oidc.vercel.com/") && + verified.payload.user_id !== undefined; + if (hasDevelopmentUserSubject) { + if ( + typeof verified.payload.user_id !== "string" || + verified.payload.user_id.length === 0 || + verified.payload.environment !== "development" || + !currentVercelProjectMatches({ payload: verified.payload, strategy: input.strategy }) || + !currentVercelEnvironmentMatches({ payload: verified.payload, strategy: input.strategy }) + ) { + return { + kind: "caller-not-allowed", + }; + } + + return { + kind: "authenticated", + principal: createJwtAuthenticatedCallerPrincipal({ + authenticator: "oidc", + payload: verified.payload, + principalType: "user", + subjectClaim: "user_id", + }), + }; + } + if (typeof verified.payload.sub !== "string" || verified.payload.sub.length === 0) { return { kind: "not-authenticated", @@ -87,9 +115,11 @@ export async function authenticateOidcStrategy(input: { isCurrentVercelProjectToken({ issuer: input.strategy.issuer, payload: verified.payload, + strategy: input.strategy, }); const isCurrentVercelRuntimeToken = - isCurrentProjectToken && isCurrentVercelEnvironmentToken({ payload: verified.payload }); + isCurrentProjectToken && + isCurrentVercelEnvironmentToken({ payload: verified.payload, strategy: input.strategy }); if ( !isCurrentProjectToken && @@ -171,47 +201,56 @@ async function getOidcDiscoveryDocument( function isCurrentVercelProjectToken(input: { readonly issuer: string; readonly payload: JWTPayload; + readonly strategy: ResolvedOidcAuthStrategy; }): boolean { if (!input.issuer.startsWith("https://oidc.vercel.com")) { return false; } - const currentProjectId = process.env.VERCEL_PROJECT_ID?.trim(); - if (currentProjectId === undefined || currentProjectId.length === 0) { + const currentProject = input.strategy.currentVercelProject; + if (currentProject === undefined) { return false; } return ( - typeof input.payload.project_id === "string" && input.payload.project_id === currentProjectId + typeof input.payload.project_id === "string" && + input.payload.project_id === currentProject.projectId ); } /** - * Returns whether the token's `project_id` claim matches the deployment's - * `VERCEL_PROJECT_ID`. Fails closed: when `VERCEL_PROJECT_ID` is unset this - * returns `false`, so an external-subject ("user") token cannot authenticate - * on a deployment that has not pinned its project. Mirrors the fail-closed - * {@link isCurrentVercelProjectToken} used by the service/runtime branch. + * Returns whether the token's `project_id` claim matches the configured + * current project. Fails closed when that project is absent, so a user token + * cannot authenticate without an explicit project bind. Mirrors the + * fail-closed {@link isCurrentVercelProjectToken} used by the + * service/runtime branch. */ -function currentVercelProjectMatches(input: { readonly payload: JWTPayload }): boolean { - const currentProjectId = process.env.VERCEL_PROJECT_ID?.trim(); - if (currentProjectId === undefined || currentProjectId.length === 0) { +function currentVercelProjectMatches(input: { + readonly payload: JWTPayload; + readonly strategy: ResolvedOidcAuthStrategy; +}): boolean { + const currentProject = input.strategy.currentVercelProject; + if (currentProject === undefined) { return false; } return ( - typeof input.payload.project_id === "string" && input.payload.project_id === currentProjectId + typeof input.payload.project_id === "string" && + input.payload.project_id === currentProject.projectId ); } /** - * Returns whether a verified JWT's `environment` claim matches the - * current Vercel deployment environment. Combined with + * Returns whether a verified JWT's `environment` claim matches the configured + * current Vercel environment. Combined with * {@link isCurrentVercelProjectToken} to upgrade `principalType` from * `"service"` to `"runtime"` when the caller is the deployment itself. */ -function isCurrentVercelEnvironmentToken(input: { readonly payload: JWTPayload }): boolean { - const currentEnvironment = getCurrentVercelEnvironment(); +function isCurrentVercelEnvironmentToken(input: { + readonly payload: JWTPayload; + readonly strategy: ResolvedOidcAuthStrategy; +}): boolean { + const currentEnvironment = input.strategy.currentVercelProject?.environment; if (currentEnvironment === undefined || currentEnvironment.length === 0) { return false; } @@ -223,13 +262,15 @@ function isCurrentVercelEnvironmentToken(input: { readonly payload: JWTPayload } } /** - * Returns whether the token's `environment` claim matches the current Vercel - * deployment environment. Fails closed: when the environment cannot be - * resolved this returns `false`, so an external-subject ("user") token cannot - * authenticate on a deployment with no resolvable environment. + * Returns whether the token's `environment` claim matches the configured + * current Vercel environment. Fails closed when that environment is absent, + * so a user token cannot authenticate without an explicit environment bind. */ -function currentVercelEnvironmentMatches(input: { readonly payload: JWTPayload }): boolean { - const currentEnvironment = getCurrentVercelEnvironment(); +function currentVercelEnvironmentMatches(input: { + readonly payload: JWTPayload; + readonly strategy: ResolvedOidcAuthStrategy; +}): boolean { + const currentEnvironment = input.strategy.currentVercelProject?.environment; if (currentEnvironment === undefined || currentEnvironment.length === 0) { return false; } @@ -239,7 +280,3 @@ function currentVercelEnvironmentMatches(input: { readonly payload: JWTPayload } input.payload.environment === currentEnvironment ); } - -function getCurrentVercelEnvironment(): string | undefined { - return process.env.VERCEL_TARGET_ENV?.trim() || process.env.VERCEL_ENV?.trim() || undefined; -} diff --git a/packages/eve/src/runtime/governance/auth/types.ts b/packages/eve/src/runtime/governance/auth/types.ts index 33e3f7f9c..5867e3178 100644 --- a/packages/eve/src/runtime/governance/auth/types.ts +++ b/packages/eve/src/runtime/governance/auth/types.ts @@ -31,6 +31,12 @@ export interface ResolvedTokenClaimMatchers { readonly subjects?: readonly string[]; } +/** One Vercel project and environment accepted by a Vercel OIDC strategy. */ +export interface CurrentVercelProject { + readonly environment?: string; + readonly projectId: string; +} + /** * Resolved HTTP Basic auth strategy. */ @@ -70,27 +76,26 @@ export interface ResolvedJwtEcdsaAuthStrategy extends ResolvedTokenClaimMatchers export interface ResolvedOidcAuthStrategy extends ResolvedTokenClaimMatchers { /** * Vercel-platform extension. When `true` and the token's issuer is - * a Vercel OIDC issuer, tokens minted for the current - * `VERCEL_PROJECT_ID` are accepted unconditionally — in addition to + * a Vercel OIDC issuer, tokens minted for the configured current project + * are accepted unconditionally — in addition to * the author-supplied `subjects`/`claims` matchers — so the * deployment's own runtime callers (subagent, internal fetches) - * always authenticate. Tokens that additionally match the current - * `VERCEL_TARGET_ENV` / `VERCEL_ENV` are tagged + * always authenticate. Tokens that additionally match the configured + * current environment are tagged * `principalType: "runtime"`; other current-project tokens are * tagged `"service"`. * - * Vercel OIDC tokens with an `external_sub` claim are user tokens. - * They must satisfy the current project/environment constraints when - * those environment variables are configured, and then authenticate as - * `principalType: "user"` with `external_sub` as their subject and - * `external_iss` / `connector_id` as their issuer when present. + * Vercel OIDC tokens with an `external_sub` claim are user tokens. So are + * development tokens with a `user_id` claim when the configured environment + * is `development`. Both must satisfy the current project/environment bind. * - * Set exclusively by `verifyVercelOidc`. The generic public + * Set exclusively by Vercel-specific verification. The generic public * `verifyOidc` always passes `false`. */ readonly acceptCurrentVercelProject: boolean; readonly audiences: readonly string[]; readonly clockSkewSeconds: number; + readonly currentVercelProject?: CurrentVercelProject; readonly discoveryUrl: string; readonly issuer: string; readonly kind: "oidc"; diff --git a/packages/eve/src/runtime/resolve-agent-graph.framework-app-root.test.ts b/packages/eve/src/runtime/resolve-agent-graph.framework-app-root.test.ts new file mode 100644 index 000000000..79a4078b5 --- /dev/null +++ b/packages/eve/src/runtime/resolve-agent-graph.framework-app-root.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createCompiledAgentManifest, ROOT_COMPILED_AGENT_NODE_ID } from "#compiler/manifest.js"; +import { TEST_DEFAULT_MODEL_ID } from "#internal/testing/app-harness.js"; + +const mocks = vi.hoisted(() => ({ + getFrameworkChannelDefinitions: vi.fn(() => []), +})); + +vi.mock("#runtime/framework-channels/index.js", () => ({ + getAllFrameworkChannelNames: () => new Set(), + getFrameworkChannelDefinitions: mocks.getFrameworkChannelDefinitions, +})); + +import { resolveRuntimeAgentGraph } from "#runtime/resolve-agent-graph.js"; + +describe("resolveRuntimeAgentGraph", () => { + it("uses the authored app root for framework channels when supplied", async () => { + const manifest = createCompiledAgentManifest({ + agentRoot: "/snapshot/app/agent", + appRoot: "/snapshot/app", + config: { + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "snapshot-agent", + }, + }); + + await resolveRuntimeAgentGraph({ + frameworkAppRoot: "/workspace/app", + manifest, + moduleMap: { + nodes: { + [ROOT_COMPILED_AGENT_NODE_ID]: { modules: {} }, + }, + }, + }); + + expect(mocks.getFrameworkChannelDefinitions).toHaveBeenCalledWith({ + appRoot: "/workspace/app", + }); + }); +}); diff --git a/packages/eve/src/runtime/resolve-agent-graph.ts b/packages/eve/src/runtime/resolve-agent-graph.ts index a1f8c0b3b..26f7673c5 100644 --- a/packages/eve/src/runtime/resolve-agent-graph.ts +++ b/packages/eve/src/runtime/resolve-agent-graph.ts @@ -39,6 +39,8 @@ import type { * into a runtime-owned recursive agent graph. */ interface ResolveRuntimeAgentGraphInput { + /** Authored app root used by framework features when manifests point at a dev snapshot. */ + readonly frameworkAppRoot?: string; manifest: CompiledAgentManifest; moduleMap: CompiledModuleMap; } @@ -91,6 +93,7 @@ export async function resolveRuntimeAgentGraph( ); const root = await resolveRuntimeAgentNode({ childNodeIdsByParentNodeId, + frameworkAppRoot: input.frameworkAppRoot, manifest: input.manifest, moduleMap: input.moduleMap, nodeId: ROOT_COMPILED_AGENT_NODE_ID, @@ -106,6 +109,7 @@ export async function resolveRuntimeAgentGraph( interface ResolveRuntimeAgentNodeInput { readonly childNodeIdsByParentNodeId: ReadonlyMap; + readonly frameworkAppRoot?: string; readonly manifest: CompiledAgentNodeManifest; readonly moduleMap: CompiledModuleMap; readonly nodeId: string; @@ -198,7 +202,9 @@ async function resolveRuntimeAgentNode( } const disabledFrameworkChannels = new Set(agent.disabledFrameworkChannels); - const activeFrameworkChannels = getFrameworkChannelDefinitions().filter( + const activeFrameworkChannels = getFrameworkChannelDefinitions({ + appRoot: input.frameworkAppRoot ?? agent.metadata.appRoot, + }).filter( (channel) => !authoredChannelNames.has(channel.name) && !disabledFrameworkChannels.has(channel.name), ); @@ -218,6 +224,7 @@ async function resolveRuntimeAgentNode( ], subagents: await resolveRuntimeSubagents({ childNodeIdsByParentNodeId: input.childNodeIdsByParentNodeId, + frameworkAppRoot: input.frameworkAppRoot, manifest: input.manifest, moduleMap: input.moduleMap, nodesByNodeId: input.nodesByNodeId, @@ -255,6 +262,7 @@ async function resolveRuntimeAgentNode( async function resolveRuntimeSubagents(input: { readonly childNodeIdsByParentNodeId: ReadonlyMap; + readonly frameworkAppRoot?: string; readonly manifest: CompiledAgentNodeManifest; readonly moduleMap: CompiledModuleMap; readonly nodesByNodeId: Map; @@ -280,6 +288,7 @@ async function resolveRuntimeSubagents(input: { resolvedSubagents.push( await resolveRuntimeSubagent({ childNodeIdsByParentNodeId: input.childNodeIdsByParentNodeId, + frameworkAppRoot: input.frameworkAppRoot, moduleMap: input.moduleMap, nodesByNodeId: input.nodesByNodeId, sourceRef, @@ -303,6 +312,7 @@ async function resolveRuntimeSubagents(input: { async function resolveRuntimeSubagent(input: { readonly childNodeIdsByParentNodeId: ReadonlyMap; + readonly frameworkAppRoot?: string; readonly moduleMap: CompiledModuleMap; readonly nodesByNodeId: Map; readonly sourceRef: CompiledSubagentNode; @@ -319,6 +329,7 @@ async function resolveRuntimeSubagent(input: { }; await resolveRuntimeAgentNode({ childNodeIdsByParentNodeId: input.childNodeIdsByParentNodeId, + frameworkAppRoot: input.frameworkAppRoot, manifest: input.sourceRef.agent, moduleMap: input.moduleMap, nodeId: input.sourceRef.nodeId, diff --git a/packages/eve/src/runtime/sessions/compiled-agent-cache.ts b/packages/eve/src/runtime/sessions/compiled-agent-cache.ts index 421fb537e..a271f8c9b 100644 --- a/packages/eve/src/runtime/sessions/compiled-agent-cache.ts +++ b/packages/eve/src/runtime/sessions/compiled-agent-cache.ts @@ -8,6 +8,7 @@ import type { RuntimeAdapterRegistry } from "#runtime/channels/registry.js"; import { createRuntimeAdapterRegistry } from "#runtime/channels/registry.js"; import { getRuntimeCompiledArtifactsCacheKey, + getRuntimeCompiledArtifactsSandboxAppRoot, type RuntimeDiskCompiledArtifactsSource, type RuntimeCompiledArtifactsSource, } from "#runtime/compiled-artifacts-source.js"; @@ -71,7 +72,11 @@ async function loadFullBundle( loadCompiledManifest({ compiledArtifactsSource: normalizedCompiledArtifactsSource }), loadRuntimeCompiledModuleMap(normalizedCompiledArtifactsSource), ]); - const graph = await resolveRuntimeAgentGraph({ manifest, moduleMap }); + const graph = await resolveRuntimeAgentGraph({ + frameworkAppRoot: getRuntimeCompiledArtifactsSandboxAppRoot(normalizedCompiledArtifactsSource), + manifest, + moduleMap, + }); const rootNode = graph.root; return { diff --git a/packages/eve/src/services/dev-client.test.ts b/packages/eve/src/services/dev-client.test.ts index fca3461e0..ecef7ca42 100644 --- a/packages/eve/src/services/dev-client.test.ts +++ b/packages/eve/src/services/dev-client.test.ts @@ -93,6 +93,67 @@ describe("runtime-artifact refresher session rotation", () => { }); describe("createDevelopmentRuntimeArtifactSessionRefresher", () => { + it("forces a rebuild and rotates an active session after a known source change", async () => { + const requests: Array<{ method: string; url: string }> = []; + const fetchMock = createDevFetchMock({ + requests, + revisions: ["snapshot-a", "snapshot-b"], + }); + vi.stubGlobal("fetch", fetchMock); + const client = new Client({ host: "http://localhost:3000" }); + const refresher = createDevelopmentRuntimeArtifactSessionRefresher({ + serverUrl: "http://localhost:3000", + }); + let session = client.session(); + + session = await refresher.refreshIdle({ + createSession: () => client.session(), + session, + }); + await (await session.send({ message: "first" })).result(); + const before = session; + + session = await refresher.refreshAfterSourceChange({ + createSession: () => client.session(), + session, + }); + + expect(session).not.toBe(before); + expect( + requests.some((request) => { + const url = new URL(request.url); + return ( + request.method === "POST" && + url.pathname === "/eve/v1/dev/runtime-artifacts/rebuild" && + url.searchParams.get("force") === "1" + ); + }), + ).toBe(true); + }); + + it("rotates an active session after a known source change without a baseline revision", async () => { + const fetchMock = createDevFetchMock({ + requests: [], + revisions: ["snapshot-b"], + }); + vi.stubGlobal("fetch", fetchMock); + const client = new Client({ host: "http://localhost:3000" }); + const refresher = createDevelopmentRuntimeArtifactSessionRefresher({ + serverUrl: "http://localhost:3000", + }); + let session = client.session(); + + await (await session.send({ message: "first" })).result(); + const before = session; + + session = await refresher.refreshAfterSourceChange({ + createSession: () => client.session(), + session, + }); + + expect(session).not.toBe(before); + }); + it("reports local dev artifact revision changes for normal prompts", async () => { const requests: Array<{ method: string; url: string }> = []; const fetchMock = createDevFetchMock({ diff --git a/packages/eve/src/services/dev-client.ts b/packages/eve/src/services/dev-client.ts index fc891d7b4..94bc19253 100644 --- a/packages/eve/src/services/dev-client.ts +++ b/packages/eve/src/services/dev-client.ts @@ -31,6 +31,17 @@ export interface DevelopmentRuntimeArtifactSessionRefresher { readonly session: ClientSession; }): Promise; + /** + * Forces one rebuild after a local setup action wrote authored source. + */ + refreshAfterSourceChange(input: { + readonly createSession: () => ClientSession; + readonly onRuntimeArtifactsChanged?: ( + change: DevelopmentRuntimeArtifactChange, + ) => void | Promise; + readonly session: ClientSession; + }): Promise; + /** * Checks for a runtime-artifact revision change while the UI is idle. */ @@ -88,12 +99,29 @@ class LocalDevelopmentRuntimeArtifactSessionRefresher implements DevelopmentRunt return await this.#refreshSession({ ...input, rebuild: false }); } + async refreshAfterSourceChange(input: { + readonly createSession: () => ClientSession; + readonly onRuntimeArtifactsChanged?: ( + change: DevelopmentRuntimeArtifactChange, + ) => void | Promise; + readonly session: ClientSession; + }): Promise { + return await this.#refreshSession({ + ...input, + force: true, + rebuild: true, + resetActiveSession: true, + }); + } + async #refreshSession(input: { readonly createSession: () => ClientSession; readonly onRuntimeArtifactsChanged?: ( change: DevelopmentRuntimeArtifactChange, ) => void | Promise; + readonly force?: boolean; readonly rebuild: boolean; + readonly resetActiveSession?: boolean; readonly session: ClientSession; }): Promise { if (!this.#isLocal) { @@ -102,7 +130,10 @@ class LocalDevelopmentRuntimeArtifactSessionRefresher implements DevelopmentRunt const revision = (input.rebuild - ? await rebuildDevelopmentRuntimeArtifacts({ serverUrl: this.#serverUrl }) + ? await rebuildDevelopmentRuntimeArtifacts({ + force: input.force, + serverUrl: this.#serverUrl, + }) : undefined) ?? (await readDevelopmentRuntimeArtifactsRevision({ serverUrl: this.#serverUrl })); if (revision === undefined) { @@ -111,10 +142,13 @@ class LocalDevelopmentRuntimeArtifactSessionRefresher implements DevelopmentRunt let session = input.session; const previousRevision = this.#artifactRevision; - if (previousRevision !== undefined && previousRevision !== revision) { + const revisionChanged = previousRevision !== undefined && previousRevision !== revision; + if (revisionChanged || input.resetActiveSession === true) { if (session.state.continuationToken !== undefined) { session = input.createSession(); } + } + if (revisionChanged) { await input.onRuntimeArtifactsChanged?.({ previousRevision, revision }); } this.#artifactRevision = revision; diff --git a/packages/eve/src/services/dev-client/client-options.test.ts b/packages/eve/src/services/dev-client/client-options.test.ts index f62db2fe8..1cd6625a3 100644 --- a/packages/eve/src/services/dev-client/client-options.test.ts +++ b/packages/eve/src/services/dev-client/client-options.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { resolveDevelopmentClientOptions, + resolveLocalDevelopmentClientOptions, resolveRemoteDevelopmentClientOptions, } from "./client-options.js"; import { createDevelopmentCredentialGate } from "./credential-gate.js"; @@ -32,6 +33,21 @@ describe("resolveDevelopmentClientOptions", () => { } }); + it("uses an explicit per-request bearer for the local TUI server", () => { + const token = vi.fn(async () => "user-oidc-token"); + + const options = resolveLocalDevelopmentClientOptions({ + serverUrl: "http://127.0.0.1:3000", + token, + }); + + expect(options).toMatchObject({ + auth: { bearer: token }, + host: "http://127.0.0.1:3000", + redirect: "manual", + }); + }); + it("binds an authorized credential gate to a non-redirecting client", () => { const credentials = createDevelopmentCredentialGate("https://verified.example.com"); diff --git a/packages/eve/src/services/dev-client/client-options.ts b/packages/eve/src/services/dev-client/client-options.ts index f69c462d9..d29d5119d 100644 --- a/packages/eve/src/services/dev-client/client-options.ts +++ b/packages/eve/src/services/dev-client/client-options.ts @@ -11,6 +11,18 @@ export function resolveDevelopmentClientOptions(serverUrl: string): ClientOption return { host: serverUrl }; } +/** Builds a non-redirecting local client with an explicit per-request bearer source. */ +export function resolveLocalDevelopmentClientOptions(input: { + readonly serverUrl: string; + readonly token: () => Promise; +}): ClientOptions { + return { + auth: { bearer: input.token }, + host: input.serverUrl, + redirect: "manual", + }; +} + /** Builds non-redirecting client options backed by one verified credential gate. */ export function resolveRemoteDevelopmentClientOptions(input: { readonly credentials: DevelopmentCredentialGate; diff --git a/packages/eve/src/services/dev-client/request-headers.test.ts b/packages/eve/src/services/dev-client/request-headers.test.ts index 7412c320b..e4e068f1c 100644 --- a/packages/eve/src/services/dev-client/request-headers.test.ts +++ b/packages/eve/src/services/dev-client/request-headers.test.ts @@ -1,13 +1,21 @@ import { getVercelOidcToken } from "#compiled/@vercel/oidc/index.js"; +import { readVercelProjectLink } from "#internal/vercel/project-link.js"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveDevelopmentOidcToken } from "./request-headers.js"; +import { + resolveDevelopmentOidcToken, + resolveLinkedDevelopmentOidcToken, +} from "./request-headers.js"; vi.mock("#compiled/@vercel/oidc/index.js", async (importOriginal) => ({ ...(await importOriginal()), getVercelOidcToken: vi.fn(), })); +vi.mock("#internal/vercel/project-link.js", () => ({ + readVercelProjectLink: vi.fn(), +})); + const target = { ownerId: "team_expected", projectId: "prj_expected" } as const; function token(claims: Record): string { @@ -16,6 +24,7 @@ function token(claims: Record): string { afterEach(() => { vi.mocked(getVercelOidcToken).mockReset(); + vi.mocked(readVercelProjectLink).mockReset(); }); describe("resolveDevelopmentOidcToken", () => { @@ -99,3 +108,27 @@ describe("resolveDevelopmentOidcToken", () => { }); }); }); + +describe("resolveLinkedDevelopmentOidcToken", () => { + it("uses the current local project link to resolve the request bearer", async () => { + vi.mocked(readVercelProjectLink).mockResolvedValue({ + orgId: target.ownerId, + projectId: target.projectId, + }); + const expected = token({ owner_id: target.ownerId, project_id: target.projectId }); + vi.mocked(getVercelOidcToken).mockResolvedValue(expected); + + await expect(resolveLinkedDevelopmentOidcToken("/workspace")).resolves.toBe(expected); + expect(getVercelOidcToken).toHaveBeenCalledWith({ + team: target.ownerId, + project: target.projectId, + }); + }); + + it("does not send a bearer when the local directory is unlinked", async () => { + vi.mocked(readVercelProjectLink).mockResolvedValue(undefined); + + await expect(resolveLinkedDevelopmentOidcToken("/workspace")).resolves.toBe(""); + expect(getVercelOidcToken).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/services/dev-client/request-headers.ts b/packages/eve/src/services/dev-client/request-headers.ts index fef4fc9f9..3c44e4531 100644 --- a/packages/eve/src/services/dev-client/request-headers.ts +++ b/packages/eve/src/services/dev-client/request-headers.ts @@ -1,4 +1,5 @@ import { getVercelOidcToken } from "#compiled/@vercel/oidc/index.js"; +import { readVercelProjectLink } from "#internal/vercel/project-link.js"; import { toErrorMessage } from "#shared/errors.js"; import { z } from "zod"; @@ -60,6 +61,18 @@ export async function resolveDevelopmentOidcToken( } } +/** Resolves the current linked project's Vercel OIDC token for a local TUI request. */ +export async function resolveLinkedDevelopmentOidcToken(workspaceRoot: string): Promise { + const link = await readVercelProjectLink(workspaceRoot); + if (link === undefined) return ""; + + const result = await resolveDevelopmentOidcToken({ + ownerId: link.orgId, + projectId: link.projectId, + }); + return result.kind === "resolved" ? result.token : ""; +} + function validateDevelopmentOidcToken( token: string, input: DevelopmentOidcTarget, diff --git a/packages/eve/src/services/dev-client/runtime-artifacts.test.ts b/packages/eve/src/services/dev-client/runtime-artifacts.test.ts index 28efefe1d..578a500a5 100644 --- a/packages/eve/src/services/dev-client/runtime-artifacts.test.ts +++ b/packages/eve/src/services/dev-client/runtime-artifacts.test.ts @@ -61,4 +61,18 @@ describe("rebuildDevelopmentRuntimeArtifacts", () => { expect.objectContaining({ method: "POST" }), ); }); + + it("can force a rebuild after a known local source mutation", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ revision: "rev-3" }))); + vi.stubGlobal("fetch", fetchMock); + + await expect( + rebuildDevelopmentRuntimeArtifacts({ force: true, serverUrl: SERVER_URL }), + ).resolves.toBe("rev-3"); + + expect(fetchMock).toHaveBeenCalledWith( + new URL("/eve/v1/dev/runtime-artifacts/rebuild?force=1", SERVER_URL), + expect.objectContaining({ method: "POST" }), + ); + }); }); diff --git a/packages/eve/src/services/dev-client/runtime-artifacts.ts b/packages/eve/src/services/dev-client/runtime-artifacts.ts index c6a658de2..88e453263 100644 --- a/packages/eve/src/services/dev-client/runtime-artifacts.ts +++ b/packages/eve/src/services/dev-client/runtime-artifacts.ts @@ -26,10 +26,12 @@ export async function readDevelopmentRuntimeArtifactsRevision(input: { } export async function rebuildDevelopmentRuntimeArtifacts(input: { + readonly force?: boolean; readonly serverUrl: string; }): Promise { try { const url = new URL(EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH, input.serverUrl); + if (input.force === true) url.searchParams.set("force", "1"); const response = await fetch(url, { method: "POST" }); return await parseDevelopmentRuntimeArtifactsRevision(response); } catch { diff --git a/packages/eve/src/setup/project-resolution.ts b/packages/eve/src/setup/project-resolution.ts index 651167a66..fcfcfffd0 100644 --- a/packages/eve/src/setup/project-resolution.ts +++ b/packages/eve/src/setup/project-resolution.ts @@ -1,7 +1,12 @@ -import { readFile, stat } from "node:fs/promises"; +import { stat } from "node:fs/promises"; import { join } from "node:path"; import { z } from "zod"; +import { + readVercelProjectLink, + type VercelProjectLink, + VercelProjectLinkSchema, +} from "#internal/vercel/project-link.js"; import { captureVercel } from "./primitives/run-vercel.js"; /** Link and production-deployment status for a Vercel project directory. */ @@ -15,18 +20,13 @@ export interface DeploymentInfo { productionUrl?: string; } -const VercelProjectReferenceSchema = z.object({ - projectId: z.string().min(1), - orgId: z.string().min(1), - projectName: z.string().min(1).optional(), -}); const VercelProjectEnvironmentSchema = z.object({ - VERCEL_ORG_ID: VercelProjectReferenceSchema.shape.orgId, - VERCEL_PROJECT_ID: VercelProjectReferenceSchema.shape.projectId, + VERCEL_ORG_ID: VercelProjectLinkSchema.shape.orgId, + VERCEL_PROJECT_ID: VercelProjectLinkSchema.shape.projectId, }); /** Validated Vercel owner and project identifiers. */ -export type VercelProjectReference = z.infer; +export type VercelProjectReference = VercelProjectLink; /** Parses the complete Vercel owner and project environment pair. */ export function projectReferenceFromEnvironment( @@ -59,13 +59,7 @@ export async function readProjectLink( projectPath: string, ): Promise { await assertNoLegacyProjectLinkDirectory(projectPath); - try { - const raw = await readFile(join(projectPath, ".vercel", "project.json"), "utf8"); - const parsed = VercelProjectReferenceSchema.safeParse(JSON.parse(raw)); - return parsed.success ? parsed.data : undefined; - } catch { - return undefined; - } + return await readVercelProjectLink(projectPath); } interface VercelApiProject { diff --git a/packages/eve/test/scenarios/dev-server.scenario.test.ts b/packages/eve/test/scenarios/dev-server.scenario.test.ts index be1d48aa3..4ed74db96 100644 --- a/packages/eve/test/scenarios/dev-server.scenario.test.ts +++ b/packages/eve/test/scenarios/dev-server.scenario.test.ts @@ -2,9 +2,25 @@ import { spawn, type ChildProcessByStdio } from "node:child_process"; import { join } from "node:path"; import type { Readable } from "node:stream"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { EVE_HEALTH_ROUTE_PATH } from "../../src/protocol/routes.js"; +import { + EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH, + EVE_HEALTH_ROUTE_PATH, + EVE_INFO_ROUTE_PATH, +} from "../../src/protocol/routes.js"; +import { Client } from "../../src/client/index.js"; +import { + EveTUIRunner, + type AgentTUIRenderer, + type PromptCommandOutcome, +} from "../../src/cli/dev/tui/runner.js"; +import { runPnpmCommand } from "../../src/internal/testing/run-pnpm-command.js"; +import { getCatalogEntry } from "../../src/setup/scaffold/connections/catalog.js"; +import { + ensureConnection, + ensureConnectionDependencies, +} from "../../src/setup/scaffold/update/connections.js"; import { WEATHER_AGENT_DESCRIPTOR } from "../../src/internal/testing/scenario-apps/weather-agent.js"; import { type ScenarioAppDescriptor, @@ -294,4 +310,166 @@ describe("eve dev server", () => { }, DEV_SERVER_SCENARIO_TIMEOUT_MS, ); + + it( + "activates a newly installed Connect connection before the next request", + async () => { + const app = await scenarioApp(DEV_SERVER_AGENT_DESCRIPTOR); + const server = await startEveDev(app.appRoot); + const linear = getCatalogEntry("linear"); + if (linear === undefined) throw new Error("Expected the Linear connection catalog entry."); + let restoreFetch: (() => void) | undefined; + + try { + const originalFetch = globalThis.fetch; + const requestedUrls: URL[] = []; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + requestedUrls.push(url); + return await originalFetch(input, init); + }); + restoreFetch = () => fetchSpy.mockRestore(); + const client = new Client({ host: server.url }); + const prompts: Array = ["/connect", undefined]; + const renderer: AgentTUIRenderer = { + readPrompt: async () => prompts.shift(), + async renderStream(result) { + for await (const _event of result.events) { + // The setup command does not create a model stream in this test. + } + }, + }; + const connectOutcome: PromptCommandOutcome = { + effect: { kind: "connection-added" }, + message: "Connections added: linear.", + }; + const handle = vi.fn(async (): Promise => { + await ensureConnectionDependencies({ projectRoot: app.appRoot }); + await runPnpmCommand({ + args: [ + "install", + "--no-frozen-lockfile", + "--prefer-offline", + "--ignore-scripts", + "--config.confirm-modules-purge=false", + "--config.minimum-release-age=0", + ], + cwd: app.appRoot, + }); + await ensureConnection({ + entry: { + ...linear, + description: "hmr-probe: connection-active", + }, + projectRoot: app.appRoot, + protocol: "mcp", + }); + return connectOutcome; + }); + const runner = new EveTUIRunner({ + client, + promptCommandHandler: { + handle, + }, + renderer, + serverUrl: server.url, + session: client.session(), + }); + + await runner.run(); + expect(handle).toHaveBeenCalledTimes(1); + expect( + requestedUrls.some( + (url) => + url.pathname === EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH && + url.searchParams.get("force") === "1", + ), + ).toBe(true); + + const info = await fetch(new URL(EVE_INFO_ROUTE_PATH, server.url)); + expect(info.status).toBe(200); + const infoPayload = await info.json(); + expect( + infoPayload, + [`stdout:\n${server.stdout()}`, `stderr:\n${server.stderr()}`].join("\n\n"), + ).toMatchObject({ connections: [expect.objectContaining({ connectionName: "linear" })] }); + + const turn = await sendDevelopmentMessage({ + message: "hello", + session: createDevelopmentSessionState(), + serverUrl: server.url, + }); + const completedMessage = turn.events.find( + (event) => event.type === "message.completed" && event.data.message !== null, + ); + expect(completedMessage).toMatchObject({ + data: { message: expect.stringContaining("probe=connection-active") }, + }); + } finally { + restoreFetch?.(); + await server.stop(); + } + }, + DEV_SERVER_SCENARIO_TIMEOUT_MS, + ); + + it( + "activates a connection added outside the TUI without restarting eve dev", + async () => { + const app = await scenarioApp(DEV_SERVER_AGENT_DESCRIPTOR); + const server = await startEveDev(app.appRoot); + const linear = getCatalogEntry("linear"); + if (linear === undefined) throw new Error("Expected the Linear connection catalog entry."); + + try { + await ensureConnectionDependencies({ projectRoot: app.appRoot }); + await runPnpmCommand({ + args: [ + "install", + "--no-frozen-lockfile", + "--prefer-offline", + "--ignore-scripts", + "--config.confirm-modules-purge=false", + "--config.minimum-release-age=0", + ], + cwd: app.appRoot, + }); + await ensureConnection({ + entry: { + ...linear, + description: "hmr-probe: watcher-active", + }, + projectRoot: app.appRoot, + protocol: "mcp", + }); + + await vi.waitFor( + async () => { + const info = await fetch(new URL(EVE_INFO_ROUTE_PATH, server.url)); + + expect(info.status).toBe(200); + expect(await info.json()).toMatchObject({ + connections: [expect.objectContaining({ connectionName: "linear" })], + }); + }, + { interval: 100, timeout: 20_000 }, + ); + + const turn = await sendDevelopmentMessage({ + message: "hello", + session: createDevelopmentSessionState(), + serverUrl: server.url, + }); + const completedMessage = turn.events.find( + (event) => event.type === "message.completed" && event.data.message !== null, + ); + expect(completedMessage).toMatchObject({ + data: { message: expect.stringContaining("probe=watcher-active") }, + }); + } finally { + await server.stop(); + } + }, + DEV_SERVER_SCENARIO_TIMEOUT_MS, + ); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee4bbab74..f00685d56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -283,6 +283,9 @@ importers: apps/fixtures/weather-agent: dependencies: + '@vercel/connect': + specifier: 0.2.2 + version: 0.2.2(@ai-sdk/mcp@2.0.0(zod@4.4.3))(@auth/core@0.41.2)(ai@7.0.0(zod@4.4.3))(eve@packages+eve) eve: specifier: workspace:* version: link:../../../packages/eve