diff --git a/.changeset/connect-command.md b/.changeset/connect-command.md new file mode 100644 index 000000000..07effbfc7 --- /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, 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/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index e07663dd0..975b72124 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. Logged-out users are directed to `/vc:login`. When the directory is not linked, selecting a server opens the same team and project flow used by `/model`, including creating a project or linking an existing one. + +For a selected server, eve first tries to attach the provider's canonical connector. If that fails, choose an existing connector from a searchable list or create one with a specific name. The fallback stays scoped to the team selected by the linked project. A connector created by the current attempt is removed if attachment or connection-file patching fails. Successful setup writes `agent/connections/.ts`, records the attached connector UID, installs the new dependency so the dev server can load it, then returns to the main prompt. + ## Keyboard shortcuts Chat and freeform `ask_question` inputs behave like a shell line editor. @@ -83,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-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/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"; } 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/runner.test.ts b/packages/eve/src/cli/dev/tui/runner.test.ts index 74b621658..113941b5d 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]; @@ -1629,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 69b2d4bd1..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 { @@ -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, @@ -1091,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; @@ -1104,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, @@ -1211,6 +1266,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/setup-commands.test.ts b/packages/eve/src/cli/dev/tui/setup-commands.test.ts index 32a684b8e..1e0afbca4 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,44 @@ describe("runTuiSetupCommand", () => { }); }); + it.each([ + [ + "configured", + { kind: "done", addedConnections: ["linear", "notion"] }, + "Connections added: linear, notion.", + { kind: "connection-added" }, + ], + [ + "empty", + { kind: "done", addedConnections: [] }, + "No connections added.", + { kind: "model-access-changed" }, + ], + ["cancelled", { kind: "cancelled" }, "/connect 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, effect) => { + const runConnectionsFlow = vi.fn(async () => result); + await expect( + run({ command: "connect", flows: fakeFlows({ runConnectionsFlow }) }), + ).resolves.toEqual({ + message, + preserveFlowDiagnostics: true, + effect, + }); + expect(runConnectionsFlow).toHaveBeenCalledWith(expect.objectContaining({ appRoot: APP_ROOT })); + }); + it("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..3a2dc2e78 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; } @@ -67,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" }; } /** @@ -108,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 @@ -158,6 +161,7 @@ async function executeSetupCommand( runLoginFlow, runModelFlow, runChannelsFlow, + runConnectionsFlow, runDeployFlow, ...input.flows, }; @@ -235,6 +239,41 @@ async function executeSetupCommand( }; } } + case "connect": { + const result = await flows.runConnectionsFlow({ appRoot, prompter, signal }); + switch (result.kind) { + case "cancelled": + return { + message: "/connect cancelled.", + preserveFlowDiagnostics: true, + effect: { kind: "model-access-changed" }, + }; + case "failed": + return { + message: + result.addedConnections.length === 0 + ? `/connect failed: ${result.message}` + : `Connection files changed, but /connect failed: ${result.message}`, + preserveFlowDiagnostics: true, + effect: + result.addedConnections.length === 0 + ? { kind: "model-access-changed" } + : { kind: "connection-added" }, + }; + case "done": + return { + message: + result.addedConnections.length === 0 + ? "No connections added." + : `Connections added: ${result.addedConnections.join(", ")}.`, + preserveFlowDiagnostics: true, + effect: + result.addedConnections.length === 0 + ? { kind: "model-access-changed" } + : { kind: "connection-added" }, + }; + } + } case "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..affbf616a 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[]; @@ -29,6 +32,7 @@ interface SetupSearchAction extends SearchActionOption { interface SetupSearchSelectRequest extends SetupSelectRequestBase { kind: "search"; + layout?: "task-list"; initialValue?: string; placeholder?: string; searchAction?: SetupSearchAction; @@ -92,7 +96,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 db39d6289..095cfa957 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" }, }, @@ -241,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: [ @@ -263,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?", @@ -281,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"); @@ -388,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: [ @@ -405,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 34ea52661..102cb8200 100644 --- a/packages/eve/src/cli/dev/tui/setup-panel.ts +++ b/packages/eve/src/cli/dev/tui/setup-panel.ts @@ -94,7 +94,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" }) @@ -149,16 +153,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; } @@ -228,6 +242,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 @@ -255,17 +284,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)}`); } @@ -335,7 +359,7 @@ function selectPresentation(state: SetupOptionSelectPanelState): SelectPresentat return { selection: "single", filter: { placeholder: state.placeholder }, - layout: "plain", + layout: state.layout ?? "plain", edit: undefined, }; case "multi": @@ -494,13 +518,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"; @@ -520,7 +541,7 @@ function appendSelectOptionRows(input: { visibleLabelWidth: number; width: number; theme: Theme; -}): void { +}): boolean { const { rows, state, @@ -534,11 +555,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(""); } @@ -556,7 +584,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, })}`, @@ -591,6 +619,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 { @@ -739,7 +768,7 @@ export function renderSelectQuestion( rows.push(` ${c.dim("(no matches)")}`); } - appendSelectOptionRows({ + const renderedTrailingTaskAction = appendSelectOptionRows({ rows, state, presentation, @@ -757,7 +786,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 b9dcae635..dc5afa43f 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -271,6 +271,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 { @@ -586,6 +609,45 @@ 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", + 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 () => { const { screen, renderer } = makeRenderer(34, 8); const words = Array.from( @@ -2030,6 +2092,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(); @@ -2131,7 +2208,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"); @@ -2150,7 +2227,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, }); @@ -2177,11 +2255,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"); @@ -2202,7 +2283,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 4b8edc955..a81b7b81a 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. */ @@ -641,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; @@ -1007,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(); } @@ -1297,7 +1299,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) @@ -1381,7 +1383,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; @@ -1901,8 +1903,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; @@ -1918,7 +1929,7 @@ export class TerminalRenderer implements AgentTUIRenderer { } this.#start(); this.#startWorking(); - this.#status = content; + this.#status = content.text; this.#paint(); } @@ -2262,10 +2273,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; } } @@ -2675,7 +2692,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" }; } @@ -2684,7 +2701,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", }; } @@ -2698,7 +2715,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, @@ -2706,15 +2725,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, }; } @@ -3401,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/src/cli/dev/tui/tui-prompter.test.ts b/packages/eve/src/cli/dev/tui/tui-prompter.test.ts index 38c92de00..d14ffe69e 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 8d1e789f2..f231b3880 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"; @@ -59,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 }; @@ -205,8 +207,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/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/boxes/add-connections.test.ts b/packages/eve/src/setup/boxes/add-connections.test.ts index 76d8bc088..709e2458d 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,14 +64,39 @@ 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 () => {}), }; } +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" }; @@ -100,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.", ); }); @@ -117,11 +147,17 @@ describe("selectConnections + addConnections boxes", () => { expect(deps.setupConnectionConnector).toHaveBeenCalledWith( expect.objectContaining({ slug: "linear", - service: "mcp.linear.app", - connectionFilePath: "/tmp/project/agent/connections/linear.ts", + service: "mcp.linear.app/mcp", 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 () => { @@ -141,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); @@ -186,11 +222,12 @@ 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" }, }), }), ); - // 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" }), ); @@ -242,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(); @@ -272,6 +309,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 +332,39 @@ describe("selectConnections + addConnections boxes", () => { ); }); + test("removes a connector created before a scaffold failure", async () => { + const deps = createdConnectorDeps(); + deps.ensureConnection.mockRejectedValueOnce(new Error("write failed")); + + await expect(runCreatedConnector(deps)).rejects.toThrow("write failed"); + expect(deps.cleanupCreatedConnectionConnector).toHaveBeenCalledWith( + expect.objectContaining({ connectorId: "scl_new" }), + ); + }); + + test("removes a created connector when pre-scaffold preparation fails", async () => { + const deps = createdConnectorDeps(); + + 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" }), + ); + }); + + test("reports both scaffold and connector cleanup failures", async () => { + const deps = createdConnectorDeps(); + deps.ensureConnection.mockRejectedValueOnce(new Error("write failed")); + deps.cleanupCreatedConnectionConnector.mockRejectedValueOnce( + new Error("remove scl_new manually"), + ); + await expect(runCreatedConnector(deps)).rejects.toThrow("write failed remove scl_new manually"); + }); + test("is skipped in headless mode when no connections are requested", async () => { const deps = createDeps(); const boxes = makeBoxes({ prompter: createPrompter(), headless: true, deps }); @@ -332,7 +403,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 +421,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/boxes/resolve-provisioning.ts b/packages/eve/src/setup/boxes/resolve-provisioning.ts index eb60300a0..c4aea58bd 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/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/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 ae42f2a76..1a9161a7b 100644 --- a/packages/eve/src/setup/connection-connector.integration.test.ts +++ b/packages/eve/src/setup/connection-connector.integration.test.ts @@ -1,15 +1,16 @@ -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 type { PrompterValue, SingleSelectOptions } from "#setup/prompter.js"; import { + parseConnectors, parseCreatedConnector, - pickConnectConnector, setupConnectionConnector, } from "./connection-connector.js"; @@ -19,161 +20,56 @@ 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/mcp"; +const CANONICAL_UID = "mcp.linear.app/linear"; -const SERVICE = "mcp.linear.app"; +function jsonResult(value: unknown) { + return { ok: true as const, stdout: JSON.stringify(value) }; +} -/** `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" }); +function connectorResult(uid: string, id: string, subject: "app" | "user") { + return jsonResult({ uid, id, service: SERVICE, supportedSubjectTypes: [subject] }); } -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", +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(); + 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/mcp" }, + ], + }, + SERVICE, + ), + ).toEqual([{ uid: "linear/acme", id: "scl_1", name: "acme" }]); }); }); -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(); - }); -}); - -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 +77,134 @@ 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(), + 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" }); + canonicalConnectorUid: CANONICAL_UID, + linkProject: async () => "prj_1", + }; + } - // 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")'); - }); + it("attaches the canonical connector without listing or creating", async () => { + run.mockResolvedValue(true); - test("warns but still patches when attaching the connector to the project fails", async () => { - mockedRunVercelCaptureStdout.mockResolvedValue({ - ok: true, - stdout: createConnectorJson("linear/my-agent"), + await expect(setupConnectionConnector(options())).resolves.toEqual({ + kind: "existing", + connectorUid: CANONICAL_UID, }); - 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(capture).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledWith( + ["connect", "attach", CANONICAL_UID, "--yes", "--scope", "org_1"], + expect.any(Object), ); - 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), + it("paginates and offers only existing connectors that support user authorization", async () => { + run.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + capture + .mockResolvedValueOnce( + jsonResult({ + connectors: [{ uid: "linear/app", id: "scl_app" }], + cursor: "next_page", + }), + ) + .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 selectOptions: SingleSelectOptions[] = []; + const fake = createFakePrompter({ + single: (input) => { + selectOptions.push(input); + return answers.shift()!; + }, }); - const result = await setupConnectionConnector({ - log: createTestLog(), - projectRoot, - slug: "linear", - service: SERVICE, - connectionFilePath, + await expect(setupConnectionConnector(options(fake.prompter))).resolves.toEqual({ + kind: "existing", + connectorUid: "linear/user", }); - - 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 }), + expect(capture).toHaveBeenCalledWith( + expect.arrayContaining(["--next", "next_page"]), + expect.any(Object), ); - expect(await readFile(connectionFilePath, "utf8")).toContain('connect("linear/my-agent")'); - }); - - test("fallback still resolves the legacy `clients` key from older CLI builds", async () => { - mockedRunVercelCaptureStdout.mockResolvedValue({ ok: true, stdout: "" }); - mockedCaptureVercel.mockResolvedValue({ - ok: true, - stdout: JSON.stringify({ - clients: [ - { uid: "linear/legacy", id: "scl_legacy", type: "oauth", createdAt: 1, projects: [] }, - ], - }), + 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}.` }], }); - - const result = await setupConnectionConnector({ - log: createTestLog(), - projectRoot, - slug: "linear", - service: SERVICE, - connectionFilePath, + expect(selectOptions[1]).toMatchObject({ + hintLayout: "inline", + placeholder: "type to search connectors", + search: true, }); - - 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: "" }); + 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()! }); - const result = await setupConnectionConnector({ - log: createTestLog(), - projectRoot, - slug: "linear", - service: SERVICE, - connectionFilePath, + await expect(setupConnectionConnector(options(fake.prompter))).resolves.toEqual({ + kind: "existing", + connectorUid: "linear/user", }); - - 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({ - ok: true, - stdout: createConnectorJson("linear/my-agent"), - }); - // `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 log = createTestLog(); - - const result = await setupConnectionConnector({ - log, - projectRoot, - slug: "linear", - service: SERVICE, - connectionFilePath, + it("removes a created connector when attach fails", async () => { + run.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + 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!, }); - 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(create).toHaveBeenCalledWith( + ["connect", "create", SERVICE, "--name", "linear-2", "-F", "json", "--scope", "org_1"], + expect.any(Object), ); - expect(mockedRunVercel).toHaveBeenCalledWith( - ["connect", "attach", "linear/my-agent", "--yes"], - expect.objectContaining({ cwd: projectRoot }), + expect(run).toHaveBeenLastCalledWith( + ["connect", "remove", "scl_created", "--disconnect-all", "--yes", "--scope", "org_1"], + 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(jsonResult({ 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", "--scope", "org_1"], + 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..d265718c0 100644 --- a/packages/eve/src/setup/connection-connector.ts +++ b/packages/eve/src/setup/connection-connector.ts @@ -1,252 +1,414 @@ -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 { readProjectLink, type VercelProjectReference } from "#setup/project-resolution.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. */ -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 }; - -interface VercelConnectListClient { - uid?: unknown; - id?: unknown; - type?: unknown; - service?: unknown; - createdAt?: unknown; - projects?: unknown; + canonicalConnectorUid: string; + signal?: AbortSignal; + linkProject: () => Promise; } -interface VercelConnectListResponse { - /** `vercel connect list -F json` (current CLI). */ - connectors?: unknown; - /** Older CLI builds emitted the same array under `clients`. */ - clients?: unknown; -} - -/** Identifiers returned by Vercel Connect for an OAuth connector. */ +/** Connector identity returned by the Vercel CLI. */ export interface ConnectConnectorRef { uid: string; id: string; + name?: string; } -/** - * 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 { +export type SetupConnectionConnectorResult = + | { kind: "existing"; connectorUid: string } + | { kind: "created"; connectorUid: string; connectorId: string }; + +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); +} + +function parseTerminalJson(source: string): unknown { + const clean = stripVTControlCharacters(source).trim(); + 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 { + // A nested object/array cannot consume the trailing enclosing JSON; + // keep walking toward the enclosing terminal payload. + } + } + return undefined; +} + +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; +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; +} - let attached: { ref: ConnectConnectorRef; createdAt: number } | undefined; - let newest: { ref: ConnectConnectorRef; createdAt: number } | 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; +} - 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; +/** Parses a created connector that can issue user credentials. */ +export function parseCreatedConnector(stdout: string): ConnectConnectorRef | undefined { + const value = parseTerminalJson(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; +} - const ref: ConnectConnectorRef = { uid: raw.uid, id: raw.id }; - const createdAt = typeof raw.createdAt === "number" ? raw.createdAt : 0; +/** Parses the service-scoped connector inventory returned by the Vercel CLI. */ +export function parseConnectors(value: unknown, service: string): ConnectConnectorRef[] { + const candidates = connectorCandidates(value); + if (candidates === undefined) return []; - if (!newest || createdAt > newest.createdAt) { - newest = { ref, createdAt }; - } - if (attachedToProject(raw, projectId) && (!attached || createdAt > attached.createdAt)) { - attached = { ref, createdAt }; - } + 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; +} - return (attached ?? newest)?.ref; +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 readProjectId(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; - } 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; } -/** - * 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, +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, + "--scope", + project.orgId, + ]; + if (cursor !== undefined) args.push("--next", cursor); + const result = await captureVercel(args, { + cwd: options.projectRoot, onOutput, - }, - ); - if (!result.ok) return undefined; - try { - return pickConnectConnector(JSON.parse(result.stdout), service, projectId); - } catch { - return undefined; - } + signal: options.signal, + }); + if (!result.ok) throw new Error(result.failure.message); + 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(...parsed.connectors); + const next = parsed.cursor; + 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; } -/** - * 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 supportsUserAuthorization( options: SetupConnectionConnectorOptions, -): Promise { - const { log, projectRoot, slug, service, connectionFilePath } = options; - const onOutput = createPromptCommandOutput(log); + project: VercelProjectReference, + 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 = parseTerminalJson(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"); +} - const projectId = options.linkProject - ? await options.linkProject() - : await ensureLinkedProject(log, projectRoot, onOutput); +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; +} - log.message(`Connecting ${slug} via Vercel Connect...`); - const create = await runVercelCaptureStdout( - ["connect", "create", service, "--name", slug, "-F", "json"], - { cwd: projectRoot, onOutput }, - ); - 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.`, +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}`; +} + +/** Removes a connector created by this setup attempt. */ +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 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\`.`, ); - 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, + project: VercelProjectReference, + connectorId: string, + message: string, +): Promise { + try { + await cleanupCreatedConnectionConnector({ + log: options.log, + projectRoot: options.projectRoot, + connectorId, + orgId: project.orgId, + }); + } 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, + project: VercelProjectReference, + connectorUid: string, + onOutput: ProcessOutputHandler, +): Promise { + return runVercel(["connect", "attach", connectorUid, "--yes", "--scope", project.orgId], { + cwd: options.projectRoot, + onOutput, + signal: options.signal, + }); +} + +async function resolveFallbackConnector( + options: SetupConnectionConnectorOptions, + project: VercelProjectReference, + 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, project, 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}`, + hintLayout: "inline", + 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", + "--scope", + project.orgId, + ], + { 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(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, project, ownedId, message); + throw new Error(message); } +} - log.success(`Linked ${slug} to ${ref.uid}`); - return { kind: "patched", created: true, connectorUid: ref.uid }; +/** 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, project, options.canonicalConnectorUid, onOutput)) { + options.log.success(`Attached ${options.canonicalConnectorUid} connector`); + return { kind: "existing", connectorUid: options.canonicalConnectorUid }; + } + options.signal?.throwIfAborted(); + + const resolution = await resolveFallbackConnector( + options, + project, + onOutput, + `Could not attach ${options.canonicalConnectorUid}.`, + ); + 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.`, + ); + } + 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/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 new file mode 100644 index 000000000..318d4b749 --- /dev/null +++ b/packages/eve/src/setup/flows/connections.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; +import type { AddConnectionsDeps } from "#setup/boxes/add-connections.js"; +import type { DeploymentInfo } from "#setup/project-resolution.js"; +import type { PrompterValue, SingleSelectOptions } from "#setup/prompter.js"; +import { WizardCancelledError } from "#setup/step.js"; + +import { + CONNECTIONS_PROMPT_MESSAGE, + runConnectionsFlow, + type ConnectionsFlowDeps, +} from "./connections.js"; + +const APP_ROOT = "/app/agent"; +const LINKED: DeploymentInfo = { state: "linked", projectId: "prj_1", orgId: "org_1" }; + +function scriptConnectionList(queue: Array) { + const requests: SingleSelectOptions[] = []; + return { + requests, + single(options: SingleSelectOptions): PrompterValue { + if (options.message !== CONNECTIONS_PROMPT_MESSAGE) { + throw new Error(`Unexpected select: ${options.message}`); + } + requests.push(options); + const next = queue.shift(); + if (next === undefined) throw new Error("Connection list exhausted its scripted picks."); + if (next === "cancel") throw new WizardCancelledError(); + return next; + }, + }; +} + +function addConnectionDeps(): AddConnectionsDeps { + return { + ensureConnection: vi.fn(async (options) => ({ + slug: options.slug ?? options.entry.slug, + protocol: options.protocol, + action: "created", + filePath: `${APP_ROOT}/agent/connections/${options.slug ?? options.entry.slug}.ts`, + filesWritten: [`${APP_ROOT}/agent/connections/${options.slug ?? options.entry.slug}.ts`], + filesSkipped: [], + packageJsonUpdated: [], + envKeysAdded: [], + envKeysRequired: [], + })), + setupConnectionConnector: vi.fn(async () => + Object.freeze({ kind: "existing", connectorUid: "mcp.linear.app/linear" }), + ), + listAuthoredConnections: vi.fn(async () => []), + cleanupCreatedConnectionConnector: vi.fn(async () => {}), + }; +} + +function runConnectionFlow( + list: ReturnType, + deps: Partial = {}, +) { + const defaults: ConnectionsFlowDeps = { + detectDeployment: vi.fn(async () => LINKED), + detectPackageManager: vi.fn(async () => Object.freeze({ kind: "pnpm", source: "default" })), + ensureConnectionDependencies: vi.fn(async () => []), + getVercelAuthStatus: vi.fn(() => Promise.resolve<"authenticated">("authenticated")), + listAuthoredConnections: vi.fn(async () => []), + runLinkFlow: vi.fn(async () => Object.freeze({ kind: "done" })), + runPackageManagerInstall: vi.fn(async () => true), + addConnections: addConnectionDeps(), + }; + return runConnectionsFlow({ + appRoot: APP_ROOT, + prompter: createFakePrompter({ single: list.single }).prompter, + deps: { ...defaults, ...deps }, + }); +} + +describe("runConnectionsFlow", () => { + it("returns a terminal success after adding a catalog connection", async () => { + const listAuthoredConnections = vi + .fn(async () => [] as string[]) + .mockResolvedValueOnce([]) + .mockResolvedValue(["linear"]); + const list = scriptConnectionList(["linear"]); + + await expect(runConnectionFlow(list, { listAuthoredConnections })).resolves.toEqual({ + kind: "done", + addedConnections: ["linear"], + }); + + expect(list.requests[0]).toMatchObject({ + hintLayout: "inline", + search: true, + placeholder: "type to search MCP servers", + }); + expect(list.requests[0]?.options.map((row) => row.value)).toEqual(["linear", "notion", "done"]); + expect(list.requests).toHaveLength(1); + }); + + it("defaults to Done when every catalog connection is already authored", async () => { + const list = scriptConnectionList(["done"]); + await runConnectionFlow(list, { + listAuthoredConnections: vi.fn(async () => ["linear", "notion"]), + }); + + expect(list.requests[0]?.initialValue).toBe("done"); + }); + + it("blocks logged-out rows", async () => { + const loggedOutList = scriptConnectionList(["cancel"]); + await expect( + runConnectionFlow(loggedOutList, { + detectDeployment: vi.fn(() => Promise.resolve({ state: "unlinked" })), + getVercelAuthStatus: vi.fn(async (): Promise<"logged-out"> => "logged-out"), + }), + ).resolves.toEqual({ kind: "cancelled" }); + expect(loggedOutList.requests[0]?.options.find((row) => row.value === "linear")).toMatchObject({ + disabled: true, + disabledReason: "Log in to Vercel first, see /vc:login", + }); + }); + + it("runs the shared create-or-link flow before configuring an unlinked project", async () => { + const detectDeployment = vi + .fn() + .mockResolvedValueOnce({ state: "unlinked" }) + .mockResolvedValueOnce(LINKED); + const runLinkFlow = vi.fn(async () => ({ kind: "done" })); + const listAuthoredConnections = vi + .fn(async () => [] as string[]) + .mockResolvedValueOnce([]) + .mockResolvedValue(["linear"]); + const list = scriptConnectionList(["linear"]); + await expect( + runConnectionFlow(list, { + detectDeployment, + listAuthoredConnections, + runLinkFlow, + }), + ).resolves.toEqual({ kind: "done", addedConnections: ["linear"] }); + + const linkInput = runLinkFlow.mock.calls[0]?.[0]; + expect(linkInput?.projectSelection).toBe("create-or-link"); + expect(linkInput?.teamSelectMessage?.("Acme")).toBe( + "You need to link to a project to use Vercel Connect.\n\nSelect your team", + ); + }); + + it("returns a terminal cancellation when project linking is cancelled", async () => { + const runLinkFlow = vi.fn(async () => Object.freeze({ kind: "cancelled" })); + const list = scriptConnectionList(["linear"]); + + await expect( + runConnectionFlow(list, { + detectDeployment: vi.fn(() => Promise.resolve({ state: "unlinked" })), + runLinkFlow, + }), + ).resolves.toEqual({ kind: "cancelled" }); + expect(list.requests).toHaveLength(1); + }); + + it("returns a terminal failure when connector setup fails", async () => { + const list = scriptConnectionList(["linear"]); + const addConnections = addConnectionDeps(); + vi.mocked(addConnections.setupConnectionConnector).mockRejectedValueOnce( + new Error("Could not create the mcp.linear.app connector."), + ); + + await expect(runConnectionFlow(list, { addConnections })).resolves.toEqual({ + kind: "failed", + addedConnections: [], + message: "Could not create the mcp.linear.app connector.", + }); + expect(list.requests).toHaveLength(1); + }); + + it("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( + runConnectionFlow(list, { + 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..5a6e23b02 --- /dev/null +++ b/packages/eve/src/setup/flows/connections.ts @@ -0,0 +1,213 @@ +import { + CONNECTION_CATALOG, + ensureConnectionDependencies, + listAuthoredConnections, +} from "#setup/scaffold/index.js"; +import { createPromptCommandOutput, withPhase } from "#setup/cli/index.js"; +import { detectPackageManager } from "#setup/package-manager.js"; +import { runPackageManagerInstall } from "#setup/primitives/pm/run.js"; +import { toErrorMessage } from "#shared/errors.js"; + +import { interactiveAsker } from "../ask.js"; +import { addConnections, type AddConnectionsDeps } from "../boxes/add-connections.js"; +import { selectConnections } from "../boxes/select-connections.js"; +import { + detectDeployment, + isProjectResolved, + projectResolutionFromDeployment, +} from "../project-resolution.js"; +import type { Prompter, SelectOption, SingleSelectOptions } from "../prompter.js"; +import { runInteractive, type AnySetupBox } from "../runner.js"; +import { snapshotSetupState, type SetupState } from "../state.js"; +import { WizardCancelledError } from "../step.js"; +import { + getVercelAuthStatus, + vercelAuthBlockerReason, + type VercelAuthStatus, +} from "../vercel-project.js"; +import { withSpinner } from "../with-spinner.js"; + +import { inProjectSetupState, prompterSink } from "./in-project.js"; +import { runLinkFlow } from "./link.js"; + +export const CONNECTIONS_PROMPT_MESSAGE = + "Select an MCP server to add to your agent through Vercel Connect"; + +const USER_AUTH_CONNECTIONS = CONNECTION_CATALOG.filter( + (entry) => entry.slug === "linear" || entry.slug === "notion", +); + +export interface ConnectionsFlowDeps { + detectDeployment: typeof detectDeployment; + detectPackageManager: typeof detectPackageManager; + getVercelAuthStatus: typeof getVercelAuthStatus; + runLinkFlow: typeof runLinkFlow; + ensureConnectionDependencies: typeof ensureConnectionDependencies; + listAuthoredConnections: typeof listAuthoredConnections; + runPackageManagerInstall: typeof runPackageManagerInstall; + addConnections?: AddConnectionsDeps; +} + +export type ConnectionsFlowResult = + | { kind: "done"; addedConnections: readonly string[] } + | { kind: "cancelled" } + | { kind: "failed"; addedConnections: readonly string[]; message: string }; + +function connectionRows( + authored: ReadonlySet, + authStatus: VercelAuthStatus, +): 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 { ...row, completed: true, focusHint: "Already added" }; + } + if (blocker !== undefined) { + return { + ...row, + disabled: true, + disabledReason: blocker, + disabledReasonTone: "warning", + }; + } + return { ...row, hint: entry.hint }; + }); + rows.push({ value: "done", label: "Done", trailingAction: true }); + return rows; +} + +async function pickConnection( + prompter: Prompter, + authored: ReadonlySet, + authStatus: VercelAuthStatus, +): Promise { + const options = connectionRows(authored, authStatus); + const request: SingleSelectOptions = { + message: CONNECTIONS_PROMPT_MESSAGE, + options, + hintLayout: "inline", + search: true, + placeholder: "type to search MCP servers", + }; + if ( + !options.some( + (option) => option.value !== "done" && option.disabled !== true && option.completed !== true, + ) + ) { + request.initialValue = "done"; + } + try { + return await prompter.select(request); + } catch (error) { + if (error instanceof WizardCancelledError) return undefined; + throw error; + } +} + +/** Runs `/connect`, linking a project on first selection when needed. */ +export async function runConnectionsFlow(input: { + appRoot: string; + prompter: Prompter; + signal?: AbortSignal; + deps?: Partial; +}): Promise { + const { appRoot, prompter, signal } = input; + const deps: ConnectionsFlowDeps = { + detectDeployment, + detectPackageManager, + ensureConnectionDependencies, + getVercelAuthStatus, + listAuthoredConnections, + runLinkFlow, + runPackageManagerInstall, + ...input.deps, + }; + const [deployment, initialAuthored, authStatus] = await withSpinner( + prompter, + "Checking the project…", + () => + Promise.all([ + deps.detectDeployment(appRoot, { signal }), + deps.listAuthoredConnections(appRoot), + deps.getVercelAuthStatus(appRoot, { signal }), + ]), + ); + signal?.throwIfAborted(); + + let state = inProjectSetupState(appRoot, projectResolutionFromDeployment(deployment)); + const authored = new Set(initialAuthored); + const selected = await pickConnection(prompter, authored, authStatus); + if (selected === undefined) return { kind: "cancelled" }; + if (selected === "done" || authored.has(selected)) { + return { kind: "done", addedConnections: [] }; + } + + try { + if (!isProjectResolved(state.project)) { + const link = await deps.runLinkFlow({ + appRoot, + prompter, + signal, + projectSelection: "create-or-link", + teamSelectMessage: () => + "You need to link to a project to use Vercel Connect.\n\nSelect your team", + }); + if (link.kind === "cancelled") return { kind: "cancelled" }; + + const deploymentAfterLink = await withSpinner(prompter, "Checking the project…", () => + deps.detectDeployment(appRoot, { signal }), + ); + const project = projectResolutionFromDeployment(deploymentAfterLink); + if (!isProjectResolved(project)) throw new Error("Project link was not found after linking."); + state = { ...state, project }; + } + + const boxes: AnySetupBox[] = [ + selectConnections({ asker: interactiveAsker(prompter), presetConnections: [selected] }), + addConnections({ + prompter, + signal, + deps: deps.addConnections, + beforeScaffold: async () => { + const packageManager = await deps.detectPackageManager(appRoot); + await deps.ensureConnectionDependencies({ projectRoot: appRoot }); + const installed = await withPhase( + prompter.log, + `Installing connection dependencies (${packageManager.kind} install)...`, + () => + deps.runPackageManagerInstall(packageManager.kind, appRoot, { + onOutput: createPromptCommandOutput(prompter.log), + signal, + }), + ); + if (!installed) { + throw new Error( + `Dependency installation failed. Run \`${packageManager.kind} install\`.`, + ); + } + }, + }), + ]; + + const 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/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 }), 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/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; diff --git a/packages/eve/src/setup/scaffold/connections/catalog.test.ts b/packages/eve/src/setup/scaffold/connections/catalog.test.ts index ee5f07275..b4f1d9b98 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,12 +42,13 @@ 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(); } }); }); 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" }, @@ -71,6 +73,27 @@ describe("connectorServiceForEntry", () => { }); }); +describe("canonicalConnectorUidForEntry", () => { + 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", 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 f9ec660cd..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, @@ -194,6 +197,23 @@ 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 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. */ 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); 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/packages/eve/test/tui-client/tui-connection-auth-states.ts b/packages/eve/test/tui-client/tui-connection-auth-states.ts index f53af28f5..5c908ebb7 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({ @@ -202,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"), { 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