From 46c5c450f0bd02278ded371720d9fd5f6fa5da89 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:35:39 +0000 Subject: [PATCH 1/5] feat(eve): add /add slash command Signed-off-by: owenkephart Co-Authored-By: owenkephart --- .changeset/tui-registry-browser.md | 5 + docs/guides/dev-tui.md | 9 +- docs/reference/cli.md | 22 +- .../declarations/shadcn-registry.d.ts | 1 + .../cli/commands/integration-connect.test.ts | 91 +++++ .../src/cli/commands/integration-connect.ts | 132 +++++++ .../eve/src/cli/commands/integration-setup.ts | 14 +- .../commands/register-integration-commands.ts | 12 +- .../commands/registry-setup-command.test.ts | 227 ++++++++---- .../cli/commands/registry-setup-command.ts | 256 +++++++++++++- .../eve/src/cli/commands/registry.test.ts | 21 +- packages/eve/src/cli/commands/registry.ts | 118 ++++++- .../eve/src/cli/dev/tui/agent-header.test.ts | 8 +- packages/eve/src/cli/dev/tui/agent-header.ts | 3 +- .../src/cli/dev/tui/command-typeahead.test.ts | 6 +- .../dev/tui/prompt-command-handler.test.ts | 1 + .../src/cli/dev/tui/prompt-command-handler.ts | 2 + .../src/cli/dev/tui/prompt-commands.test.ts | 7 + .../eve/src/cli/dev/tui/prompt-commands.ts | 9 + .../src/cli/dev/tui/remote-auth-command.ts | 3 + packages/eve/src/cli/dev/tui/runner.test.ts | 18 +- packages/eve/src/cli/dev/tui/runner.ts | 22 +- .../src/cli/dev/tui/setup-commands.test.ts | 50 ++- .../eve/src/cli/dev/tui/setup-commands.ts | 35 +- packages/eve/src/cli/dev/tui/setup-flow.ts | 20 +- .../dev/tui/setup-issues.integration.test.ts | 16 +- .../eve/src/cli/dev/tui/setup-panel.test.ts | 38 ++- packages/eve/src/cli/dev/tui/setup-panel.ts | 27 +- .../src/cli/dev/tui/terminal-renderer.test.ts | 85 ++++- .../eve/src/cli/dev/tui/terminal-renderer.ts | 93 +++-- .../dev/tui/test/fake-setup-flow-renderer.ts | 1 + .../eve/src/cli/dev/tui/tui-prompter.test.ts | 21 ++ packages/eve/src/cli/dev/tui/tui-prompter.ts | 11 +- packages/eve/src/cli/dev/tui/tui.ts | 13 +- packages/eve/src/cli/option-parsers.ts | 68 ++++ packages/eve/src/cli/run.ts | 92 ++--- packages/eve/src/cli/shutdown.test.ts | 20 ++ packages/eve/src/cli/shutdown.ts | 12 + ...v-authored-source-watcher.scenario.test.ts | 21 ++ .../nitro/host/dev-authored-source-watcher.ts | 36 +- .../host/start-development-server.test.ts | 2 + .../nitro/host/start-development-server.ts | 22 +- packages/eve/src/protocol/routes.ts | 6 + .../dev-client/runtime-artifacts.test.ts | 25 ++ .../services/dev-client/runtime-artifacts.ts | 37 ++ .../eve/src/setup/boxes/add-channels.test.ts | 5 +- packages/eve/src/setup/boxes/add-channels.ts | 34 +- .../src/setup/flows/ensure-vercel-project.ts | 58 ++++ packages/eve/src/setup/flows/registry.test.ts | 243 +++++++++++++ packages/eve/src/setup/flows/registry.ts | 322 ++++++++++++++++++ .../eve/src/setup/primitives/run-vercel.ts | 13 +- packages/eve/src/setup/prompter.ts | 27 +- .../eve/src/setup/registry-setup-client.ts | 213 ++++++++++++ .../eve/src/setup/registry-setup-protocol.ts | 95 ++++++ ...e-connection-connector.integration.test.ts | 15 + .../update/update-connection-connector.ts | 12 +- .../tui-client/tui-packed-install-model.ts | 5 + 57 files changed, 2505 insertions(+), 275 deletions(-) create mode 100644 .changeset/tui-registry-browser.md create mode 100644 packages/eve/src/cli/commands/integration-connect.test.ts create mode 100644 packages/eve/src/cli/commands/integration-connect.ts create mode 100644 packages/eve/src/cli/option-parsers.ts create mode 100644 packages/eve/src/setup/flows/ensure-vercel-project.ts create mode 100644 packages/eve/src/setup/flows/registry.test.ts create mode 100644 packages/eve/src/setup/flows/registry.ts create mode 100644 packages/eve/src/setup/registry-setup-client.ts create mode 100644 packages/eve/src/setup/registry-setup-protocol.ts diff --git a/.changeset/tui-registry-browser.md b/.changeset/tui-registry-browser.md new file mode 100644 index 000000000..021700137 --- /dev/null +++ b/.changeset/tui-registry-browser.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add a `/add` dev TUI command with categorized registry browsing, manifest inspection, and integration installation without leaving the interactive session. Fresh `eve init` sessions open this next-steps hub after model setup. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index 12df1d99d..85a0a6586 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -16,7 +16,7 @@ On startup the TUI prints a brand line with your agent's name, plus a rotating t Use /channels to add more ways to reach your agent. ``` -If agent discovery reported problems, an error and warning count renders between the two lines. Instructions, tools, skills, and subagents are one `eve info` away, and `/help` lists every command. The TUI also runs a startup check. A fresh `eve init` starts local `eve dev` with `/model` prefilled. That input starts onboarding: the flow installs the Vercel CLI if needed, asks you to log in if needed, then opens `/model` before the first prompt. Other `eve dev` sessions show missing setup as an attention line, with each command's outcome hanging under it on a `⎿` connector. +If agent discovery reported problems, an error and warning count renders between the two lines. Instructions, tools, skills, and subagents are one `eve info` away, and `/help` lists every command. The TUI also runs a startup check. A fresh `eve init` starts local `eve dev` with `/model` prefilled. That input starts onboarding: the flow installs the Vercel CLI if needed, asks you to log in if needed, opens `/model`, then offers categorized next steps for channels, connections, capabilities, and observability through the registry before the first prompt. Other `eve dev` sessions show missing setup as an attention line, with each command's outcome hanging under it on a `⎿` connector. ## Reading the transcript @@ -37,6 +37,7 @@ Each command echoes as an invocation line, asks through a bordered panel that ta | `/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). | +| `/add` | Searches registry integrations or adds an item address entered directly. | | `/deploy` | Ships the agent to Vercel production, linking the directory first when it is unlinked. | | `/vc:install` | Installs the Vercel CLI. Available locally and on a remote session. | | `/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. | @@ -45,7 +46,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`, `/connect`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. +`/model`, `/channels`, `/connect`, `/add`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. ### Configure the model and provider @@ -59,6 +60,10 @@ 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. +### Browse registry integrations + +`/add` first organizes integrations by what they add: a way to chat, tools and data, capabilities, or observability. Pick a category to open its searchable catalog from the official eve registry and the registry namespaces configured in `package.json`, or browse everything. Each integration shows its source beside its name, such as `Vercel` or a configured registry namespace. Select a result to review its source, packages, environment variables, and target files before adding it, or type an official item path, configured namespace address, or HTTP(S) URL directly into the search bar. After an add attempt finishes, the panel closes instead of returning to the catalog. + ### Add a connection `/connect` shows a searchable list of MCP servers available through Vercel Connect. Already-authored connections remain checked. Logged-out users are directed to `/vc:login`. When the directory is not linked, selecting a server opens the same team and project flow used by `/model`, including creating a project or linking an existing one. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8060625c6..ad3cf1022 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -22,7 +22,6 @@ The `eve` binary (`bin: eve`) runs from your app root, and every command first l | `eve link` | Link the directory to a Vercel project and pull AI Gateway credentials | | `eve deploy` | Deploy the agent to Vercel production (links first if needed) | | `eve eval` | Run evals against the local app or a remote target | -| `eve channels add [kind]` | Scaffold a channel interactively, or by kind (`slack` \| `web`) | | `eve channels list` | List user-authored channels | | `eve extension init [target]` | Create a new extension package | | `eve extension build` | Build the current package as an extension | @@ -47,9 +46,9 @@ Creates a new agent app or adds an agent to an existing app. Always installs dep After scaffolding, a human terminal usually continues into `eve dev` (or a coding-agent REPL if one is on `PATH` and you pick it). Coding-agent launches print the next steps instead of opening the TUI, so the session does not get stuck. Fresh projects use the parent workspace's package manager when there is one; otherwise they use the manager that launched `eve init`. -| Flag | Type | Default | Description | -| ---------------------- | ---- | ------- | ----------------------------------------------------------------------------------------------------- | -| `--channel-web-nextjs` | flag | off | Add the Web Chat app (Next.js). Not for existing projects — run `eve channels add web` there instead. | +| Flag | Type | Default | Description | +| ---------------------- | ---- | ------- | ---------------------------------------------------------------------------------------------------- | +| `--channel-web-nextjs` | flag | off | Add the Web Chat app (Next.js). Not for existing projects — run `eve add channel/web` there instead. | ## `eve extension` @@ -184,7 +183,7 @@ Pass a bare URL and the UI connects to that server instead of booting a local on | `--context-size ` | number | none | Model context window size, shown as a usage percentage | | `--logs ` | enum | `stderr` | Server/agent logs to show: `all` \| `stderr` \| `sandbox` \| `none` | -A fresh `eve init` passes `--input /model`. That bare local input starts onboarding: the TUI installs the Vercel CLI if needed, asks you to log in if needed, then opens `/model`. Other input stays editable in the prompt. +A fresh `eve init` passes `--input /model`. That bare local input starts onboarding: the TUI installs the Vercel CLI if needed, asks you to log in if needed, opens `/model`, then offers categorized registry next steps before the first prompt. Other input stays editable in the prompt. For a URL target protected by HTTP Basic auth, put the credentials in the URL. eve sends them as a Basic `Authorization` header and strips them from the server URL before connecting: @@ -295,19 +294,6 @@ Runs all discovered evals when no eval ids are given; ids match exactly or by di See [Evals](../evals/overview) for authoring evals. -## `eve channels add` - -```bash -eve channels add [kind] [-f] [-y] -``` - -Scaffolds a channel into `agent/channels/`. With no `kind` it prompts interactively; pass a `kind` (`slack` \| `web`) to scaffold one directly. When the Vercel CLI has an authenticated session, eve scaffolds the Vercel-integrated variant and asks whether to deploy after setup. Without one, Slack setup asks whether to set up Vercel Connect or use portable credentials. The portable variant reads `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` and adds both names to `.env.example`. - -| Flag | Type | Default | Description | -| ------------- | ---- | ------- | --------------------------------------------------------- | -| `-f, --force` | flag | off | Overwrite existing channel files | -| `-y, --yes` | flag | off | Assume yes for confirmations; requires an explicit `kind` | - ## `eve channels list` ```bash diff --git a/packages/eve/scripts/vendor-compiled/declarations/shadcn-registry.d.ts b/packages/eve/scripts/vendor-compiled/declarations/shadcn-registry.d.ts index 4bf7f7ba9..de27cfee8 100644 --- a/packages/eve/scripts/vendor-compiled/declarations/shadcn-registry.d.ts +++ b/packages/eve/scripts/vendor-compiled/declarations/shadcn-registry.d.ts @@ -14,6 +14,7 @@ export interface AddRegistryItemsOptions { cwd?: string; config?: RegistryConfig; overwrite?: boolean; + silent?: boolean; } export interface RegistrySearchItem { diff --git a/packages/eve/src/cli/commands/integration-connect.test.ts b/packages/eve/src/cli/commands/integration-connect.test.ts new file mode 100644 index 000000000..434b8b46f --- /dev/null +++ b/packages/eve/src/cli/commands/integration-connect.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; + +import { runIntegrationConnect } from "./integration-connect.js"; + +const PROJECT = { orgId: "team_1", projectId: "prj_1" }; + +function dependencies(overrides: Record = {}) { + const fake = createFakePrompter(); + return { + createPrompter: () => fake.prompter, + readProjectLink: vi.fn(async () => PROJECT), + runLinkFlow: vi.fn(async () => ({ kind: "done" as const })), + setupConnectionConnector: vi.fn(async () => ({ + kind: "existing" as const, + connectorUid: "linear/real", + })), + cleanupCreatedConnectionConnector: vi.fn(async () => {}), + updateConnectionConnectorUid: vi.fn(async () => ({ patched: true })), + ...overrides, + }; +} + +afterEach(() => { + process.exitCode = undefined; +}); + +describe("runIntegrationConnect", () => { + it("provisions and patches an installed connection", async () => { + const deps = dependencies(); + await runIntegrationConnect({ + appRoot: "/project", + slug: "linear", + service: "mcp.linear.app", + dependencies: deps, + }); + + expect(deps.setupConnectionConnector).toHaveBeenCalledWith( + expect.objectContaining({ + slug: "linear", + service: "mcp.linear.app", + canonicalConnectorName: "linear", + project: PROJECT, + }), + ); + expect(deps.updateConnectionConnectorUid).toHaveBeenCalledWith( + "/project/agent/connections/linear.ts", + "linear/real", + ); + }); + + it("links an unlinked project before connector setup", async () => { + const readProjectLink = vi.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce(PROJECT); + const deps = dependencies({ readProjectLink }); + + await runIntegrationConnect({ + appRoot: "/project", + slug: "linear", + service: "mcp.linear.app", + dependencies: deps, + }); + + expect(deps.runLinkFlow).toHaveBeenCalledWith( + expect.objectContaining({ appRoot: "/project", projectSelection: "create-or-link" }), + ); + }); + + it("cleans up a newly-created connector when the installed file cannot be patched", async () => { + const deps = dependencies({ + setupConnectionConnector: vi.fn(async () => ({ + kind: "created" as const, + connectorUid: "linear/new", + connectorId: "scl_new", + })), + updateConnectionConnectorUid: vi.fn(async () => ({ patched: false })), + }); + + await expect( + runIntegrationConnect({ + appRoot: "/project", + slug: "linear", + service: "mcp.linear.app", + dependencies: deps, + }), + ).rejects.toThrow("Could not update the connector"); + expect(deps.cleanupCreatedConnectionConnector).toHaveBeenCalledWith( + expect.objectContaining({ connectorId: "scl_new", orgId: "team_1" }), + ); + }); +}); diff --git a/packages/eve/src/cli/commands/integration-connect.ts b/packages/eve/src/cli/commands/integration-connect.ts new file mode 100644 index 000000000..fff686a79 --- /dev/null +++ b/packages/eve/src/cli/commands/integration-connect.ts @@ -0,0 +1,132 @@ +import { join } from "node:path"; + +import { + cleanupCreatedConnectionConnector, + setupConnectionConnector, + type SetupConnectionConnectorOptions, +} from "#setup/connection-connector.js"; +import { runLinkFlow, type LinkFlowDeps } from "#setup/flows/link.js"; +import { readProjectLink } from "#setup/project-resolution.js"; +import { createPrompter, type Prompter } from "#setup/prompter.js"; +import { createRegistrySetupClient } from "#setup/registry-setup-client.js"; +import { isEveProject } from "#setup/scaffold/index.js"; +import { updateConnectionConnectorUid } from "#setup/scaffold/update/update-connection-connector.js"; + +import { NOT_AN_AGENT_MESSAGE } from "./preconditions.js"; +import type { RegistryCommandLogger } from "./registry.js"; + +export interface IntegrationConnectOptions { + signal?: AbortSignal; +} + +export interface IntegrationConnectDependencies { + createPrompter?: () => Prompter; + readProjectLink: typeof readProjectLink; + runLinkFlow: typeof runLinkFlow; + linkFlowDeps?: Partial; + setupConnectionConnector: typeof setupConnectionConnector; + cleanupCreatedConnectionConnector: typeof cleanupCreatedConnectionConnector; + updateConnectionConnectorUid: typeof updateConnectionConnectorUid; +} + +const defaultDependencies: IntegrationConnectDependencies = { + readProjectLink, + runLinkFlow, + setupConnectionConnector, + cleanupCreatedConnectionConnector, + updateConnectionConnectorUid, +}; + +/** Configures the Vercel Connect connector referenced by an installed connection item. */ +export async function runIntegrationConnect(input: { + appRoot: string; + slug: string; + service: string; + canonicalConnectorName?: string; + options?: IntegrationConnectOptions; + dependencies?: Partial; +}): Promise { + const dependencies = { ...defaultDependencies, ...input.dependencies }; + const prompter = dependencies.createPrompter?.() ?? createPrompter(); + const signal = input.options?.signal; + prompter.intro(`Set up ${input.slug}`); + + let project = await dependencies.readProjectLink(input.appRoot); + if (project === undefined) { + const link = await dependencies.runLinkFlow({ + appRoot: input.appRoot, + prompter, + signal, + projectSelection: "create-or-link", + teamSelectMessage: () => + `You need to link to a project to use ${input.slug} through Vercel Connect.\n\nSelect your team`, + deps: dependencies.linkFlowDeps, + }); + if (link.kind === "cancelled") return; + project = await dependencies.readProjectLink(input.appRoot); + if (project === undefined) throw new Error("Project link was not found after linking."); + } + + const connectorOptions: SetupConnectionConnectorOptions = { + log: prompter.log, + prompter, + projectRoot: input.appRoot, + slug: input.slug, + service: input.service, + canonicalConnectorName: input.canonicalConnectorName ?? input.slug, + project, + signal, + }; + const connector = await dependencies.setupConnectionConnector(connectorOptions); + const filePath = join(input.appRoot, "agent", "connections", `${input.slug}.ts`); + const patched = await dependencies.updateConnectionConnectorUid(filePath, connector.connectorUid); + if (!patched.patched) { + if (connector.kind === "created") { + await dependencies.cleanupCreatedConnectionConnector({ + log: prompter.log, + projectRoot: input.appRoot, + connectorId: connector.connectorId, + orgId: project.orgId, + }); + } + throw new Error(`Could not update the connector in agent/connections/${input.slug}.ts.`); + } + prompter.outro(`Connection ${input.slug} set up.`); +} + +/** CLI adapter for the hidden integration command used by trusted registry items. */ +export async function runIntegrationConnectCommand( + logger: RegistryCommandLogger, + appRoot: string, + slug: string, + service: string, + canonicalConnectorName: string | undefined, + options: IntegrationConnectOptions = {}, + dependencies: IntegrationConnectDependencies = defaultDependencies, +): Promise { + if (!(await isEveProject(appRoot))) { + logger.error(NOT_AN_AGENT_MESSAGE); + process.exitCode = 1; + return; + } + const client = createRegistrySetupClient({ signal: options.signal }); + try { + await runIntegrationConnect({ + appRoot, + slug, + service, + canonicalConnectorName, + options, + dependencies: { + ...dependencies, + createPrompter: () => + client?.prompter ?? dependencies.createPrompter?.() ?? createPrompter(), + }, + }); + client?.complete(); + } catch (error) { + client?.fail(error); + logger.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index a8937cd89..79966ea4b 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -12,6 +12,7 @@ import { } from "#setup/channel-setup-integrations.js"; import { detectDeployment, projectResolutionFromDeployment } from "#setup/project-resolution.js"; import { createPrompter, type Prompter } from "#setup/prompter.js"; +import { createRegistrySetupClient } from "#setup/registry-setup-client.js"; import { isEveProject, type ChannelKind } from "#setup/scaffold/index.js"; import { createDefaultSetupState, type SetupState } from "#setup/state.js"; import { getVercelAuthStatus } from "#setup/vercel-project.js"; @@ -21,6 +22,7 @@ import type { RegistryCommandLogger } from "./registry.js"; export interface IntegrationSetupOptions { yes?: boolean; + signal?: AbortSignal; } export interface IntegrationSetupDependencies { @@ -50,6 +52,7 @@ export async function runIntegrationSetupCommand( return; } + const client = createRegistrySetupClient({ signal: options.signal }); try { if (kind !== "slack" && kind !== "web") { throw new Error( @@ -57,12 +60,13 @@ export async function runIntegrationSetupCommand( ); } const channelKind: ChannelKind = kind; - const prompter = dependencies.createPrompter?.() ?? createPrompter(); + const prompter = client?.prompter ?? dependencies.createPrompter?.() ?? createPrompter(); + const signal = client?.signal ?? options.signal; prompter.intro(`Set up ${channelSetupIntegration(channelKind).label}`); prompter.log.message("Checking Vercel setup..."); const [deployment, authStatus] = await Promise.all([ - dependencies.detectDeployment(appRoot), - dependencies.getVercelAuthStatus(appRoot), + dependencies.detectDeployment(appRoot, { signal }), + dependencies.getVercelAuthStatus(appRoot, { signal }), ]); const project = projectResolutionFromDeployment(deployment); const environment = channelSetupEnvironment(authStatus, project); @@ -81,8 +85,10 @@ export async function runIntegrationSetupCommand( presetPortableCredentials: options.yes ? true : undefined, skipDependencyMutation: true, deps: dependencies.addChannelsDeps, + signal, }); if (result.kind === "cancelled") { + client?.cancel(); if (process.env.EVE_SETUP === "1") process.exitCode = 130; return; } @@ -106,7 +112,9 @@ export async function runIntegrationSetupCommand( prompter.outro( finalState.channels.includes(channelKind) ? "Integration set up." : "No changes made.", ); + client?.complete(); } catch (error) { + client?.fail(error); logger.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } diff --git a/packages/eve/src/cli/commands/register-integration-commands.ts b/packages/eve/src/cli/commands/register-integration-commands.ts index 143eff13e..5bd79409d 100644 --- a/packages/eve/src/cli/commands/register-integration-commands.ts +++ b/packages/eve/src/cli/commands/register-integration-commands.ts @@ -13,12 +13,20 @@ export function registerIntegrationCommands(input: { }): void { const { appRoot, logger, program } = input; - program - .command("integration", { hidden: true }) + const integration = program.command("integration", { hidden: true }); + + integration .command("setup ") .option("-y, --yes") .action(async (kind: string, options: { yes?: boolean }) => { const { runIntegrationSetupCommand } = await import("./integration-setup.js"); await runIntegrationSetupCommand(logger, appRoot, kind, { yes: options.yes }); }); + + integration + .command("connect [canonical-name]") + .action(async (slug: string, service: string, canonicalName: string | undefined) => { + const { runIntegrationConnectCommand } = await import("./integration-connect.js"); + await runIntegrationConnectCommand(logger, appRoot, slug, service, canonicalName); + }); } diff --git a/packages/eve/src/cli/commands/registry-setup-command.test.ts b/packages/eve/src/cli/commands/registry-setup-command.test.ts index bb2c2c117..7d61469d2 100644 --- a/packages/eve/src/cli/commands/registry-setup-command.test.ts +++ b/packages/eve/src/cli/commands/registry-setup-command.test.ts @@ -1,7 +1,11 @@ import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; +import type { Prompter } from "#setup/prompter.js"; +import { REGISTRY_SETUP_PROTOCOL_VERSION } from "#setup/registry-setup-protocol.js"; import { runRegistrySetupCommand } from "./registry-setup-command.js"; const { findPackageJSON, readFile, spawn } = vi.hoisted(() => ({ @@ -14,12 +18,42 @@ vi.mock("node:fs/promises", () => ({ readFile })); vi.mock("node:module", () => ({ findPackageJSON })); vi.mock("node:child_process", () => ({ spawn })); -function childThatCloses(code: number | null, signal: NodeJS.Signals | null = null) { - const child = new EventEmitter(); - setTimeout(() => child.emit("close", code, signal), 0); +function createPrompter(): Prompter { + const fake = createFakePrompter({ + single: () => "selected", + text: () => "answer", + }); + fake.prompter.log.spinner = vi.fn(() => ({ stop: vi.fn() })); + return fake.prompter; +} + +function protocolChild( + code = 0, + signal: NodeJS.Signals | null = null, + beforeClose?: (child: EventEmitter & { send: ReturnType }) => void, +) { + const child = Object.assign(new EventEmitter(), { + connected: true, + send: vi.fn(), + kill: vi.fn(), + pid: undefined, + stdout: new PassThrough(), + stderr: new PassThrough(), + }); + setTimeout(() => { + child.emit("message", { type: "ready", version: REGISTRY_SETUP_PROTOCOL_VERSION }); + if (beforeClose === undefined) { + child.emit("message", { type: "result", outcome: { kind: "completed", facts: [] } }); + } else { + beforeClose(child); + } + child.emit("close", code, signal); + }, 0); return child; } +const options = () => ({ prompter: createPrompter() }); + describe("runRegistrySetupCommand", () => { beforeEach(() => { vi.clearAllMocks(); @@ -32,119 +66,191 @@ describe("runRegistrySetupCommand", () => { ); }); - it("runs a package's declared binary directly with Node", async () => { - spawn.mockReturnValue(childThatCloses(0)); + it("runs a package binary with IPC while the parent owns stdin", async () => { + spawn.mockReturnValue(protocolChild()); await expect( runRegistrySetupCommand( "/project", { package: "@acme/slack", bin: "acme-slack", args: ["setup"] }, "channel/slack", + options(), ), - ).resolves.toBe("completed"); + ).resolves.toEqual({ kind: "completed", output: [] }); - expect(findPackageJSON).toHaveBeenCalledWith("@acme/slack", expect.any(URL)); expect(spawn).toHaveBeenCalledWith( process.execPath, ["/project/node_modules/@acme/slack/dist/cli.js", "setup"], expect.objectContaining({ cwd: "/project", - env: expect.objectContaining({ EVE_SETUP: "1", EVE_SETUP_ITEM: "channel/slack" }), - stdio: "inherit", + env: expect.objectContaining({ + EVE_SETUP: "1", + EVE_SETUP_ITEM: "channel/slack", + EVE_SETUP_PROTOCOL: "1", + }), + stdio: ["ignore", "pipe", "pipe", "ipc"], }), ); }); - it("runs the declared eve binary without a package-manager shim", async () => { - findPackageJSON.mockReturnValue("/project/node_modules/eve/package.json"); - readFile.mockResolvedValue(JSON.stringify({ name: "eve", bin: { eve: "./bin/eve.js" } })); - spawn.mockReturnValue(childThatCloses(0)); + it("routes child prompts through the parent prompter", async () => { + const child = protocolChild(0, null, (running) => { + running.emit("message", { + type: "prompt", + id: 7, + prompt: { kind: "text", message: "Workspace name" }, + }); + running.emit("message", { type: "result", outcome: { kind: "completed", facts: [] } }); + }); + const prompter = createPrompter(); + const readText = vi.spyOn(prompter, "text"); + spawn.mockReturnValue(child); await runRegistrySetupCommand( "/project", - { package: "eve", bin: "eve", args: ["integration", "setup", "slack"] }, + { package: "@acme/slack", bin: "acme-slack", args: ["setup"] }, "channel/slack", + { prompter }, ); - expect(spawn).toHaveBeenCalledWith( - process.execPath, - ["/project/node_modules/eve/bin/eve.js", "integration", "setup", "slack"], - expect.any(Object), + expect(readText).toHaveBeenCalledWith({ kind: "text", message: "Workspace name" }); + await vi.waitFor(() => + expect(child.send).toHaveBeenCalledWith({ type: "prompt-result", id: 7, value: "answer" }), ); }); - it.each([ - [130, null], - [null, "SIGINT"], - ] as const)("maps exit %s and signal %s to cancellation", async (code, signal) => { - spawn.mockReturnValue(childThatCloses(code, signal)); - - await expect( - runRegistrySetupCommand( - "/project", - { package: "@acme/slack", bin: "acme-slack", args: ["setup"] }, - "channel/slack", - ), - ).resolves.toBe("cancelled"); - }); - - it("resolves string-form bin using the installed package's unscoped name", async () => { - readFile.mockResolvedValue( - JSON.stringify({ name: "@renamed/installed-slack", bin: "./dist/cli.js" }), - ); - spawn.mockReturnValue(childThatCloses(0)); + it("streams child output through the parent prompter", async () => { + const child = protocolChild(); + const prompter = createPrompter(); + spawn.mockReturnValue(child); + child.stdout.write("connecting\n"); await runRegistrySetupCommand( "/project", - { package: "registry-package-alias", bin: "installed-slack", args: [] }, + { package: "@acme/slack", bin: "acme-slack", args: [] }, "channel/slack", + { prompter }, ); - expect(spawn).toHaveBeenCalledWith( - process.execPath, - ["/project/node_modules/@acme/slack/dist/cli.js"], - expect.any(Object), - ); + expect(prompter.log.commandOutput).toHaveBeenCalledWith("connecting"); }); - it("rejects a binary the installed package does not declare", async () => { + it("uses a structured child failure instead of the exit code", async () => { + const child = protocolChild(1, null, (running) => { + running.emit("message", { + type: "result", + outcome: { + kind: "failed", + error: { message: "Photon approval was denied.", details: ["at setupPhoton"] }, + }, + }); + }); + spawn.mockReturnValue(child); + await expect( runRegistrySetupCommand( "/project", - { package: "@acme/slack", bin: "something-else", args: [] }, - "channel/slack", + { package: "@acme/slack", bin: "acme-slack", args: [] }, + "channel/photon", + options(), ), - ).rejects.toThrow('Package "@acme/slack" does not declare a "something-else" binary.'); - expect(spawn).not.toHaveBeenCalled(); + ).rejects.toThrow("Photon approval was denied.\nat setupPhoton"); }); - it("rejects a declared binary outside the package directory", async () => { - readFile.mockResolvedValue( - JSON.stringify({ name: "@acme/slack", bin: { "acme-slack": "../escape.js" } }), - ); + it("returns durable setup notes with successful completion", async () => { + const child = protocolChild(0, null, (running) => { + running.emit("message", { + type: "result", + outcome: { + kind: "completed", + facts: [ + { label: "Text your agent", value: "+15550000000", kind: "phone" }, + { + label: "Photon project", + value: "https://app.photon.codes/dashboard/project-id", + kind: "url", + }, + ], + }, + }); + }); + spawn.mockReturnValue(child); await expect( runRegistrySetupCommand( "/project", { package: "@acme/slack", bin: "acme-slack", args: [] }, - "channel/slack", + "channel/photon", + options(), ), - ).rejects.toThrow( - 'Package "@acme/slack" declares its "acme-slack" binary outside the package directory.', + ).resolves.toEqual({ + kind: "completed", + output: [ + "Text your agent: +15550000000", + "Photon project: https://app.photon.codes/dashboard/project-id", + ], + }); + }); + + it("cancels the setup child through IPC and its process group", async () => { + const child = Object.assign(new EventEmitter(), { + connected: true, + send: vi.fn(), + kill: vi.fn(), + pid: undefined, + stdout: new PassThrough(), + stderr: new PassThrough(), + }); + const controller = new AbortController(); + spawn.mockReturnValue(child); + const setup = runRegistrySetupCommand( + "/project", + { package: "@acme/slack", bin: "acme-slack", args: [] }, + "channel/slack", + { prompter: createPrompter(), signal: controller.signal }, ); - expect(spawn).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()); + controller.abort(); + child.emit("message", { type: "result", outcome: { kind: "cancelled" } }); + child.emit("close", 130, null); + + await expect(setup).resolves.toEqual({ kind: "cancelled" }); + expect(child.send).toHaveBeenCalledWith({ type: "cancel" }); + expect(child.kill).not.toHaveBeenCalled(); }); - it("reports a package without binaries as not declaring the requested binary", async () => { - readFile.mockResolvedValue(JSON.stringify({ name: "@acme/slack" })); + it("rejects a setup command that does not speak the protocol", async () => { + const child = Object.assign(new EventEmitter(), { + connected: true, + send: vi.fn(), + kill: vi.fn(), + pid: undefined, + stdout: new PassThrough(), + stderr: new PassThrough(), + }); + setTimeout(() => child.emit("close", 0, null), 0); + spawn.mockReturnValue(child); await expect( runRegistrySetupCommand( "/project", { package: "@acme/slack", bin: "acme-slack", args: [] }, "channel/slack", + options(), + ), + ).rejects.toThrow("exited with code 0 before reporting a result"); + }); + + it("rejects a binary the installed package does not declare", async () => { + await expect( + runRegistrySetupCommand( + "/project", + { package: "@acme/slack", bin: "something-else", args: [] }, + "channel/slack", + options(), ), - ).rejects.toThrow('Package "@acme/slack" does not declare a "acme-slack" binary.'); + ).rejects.toThrow('Package "@acme/slack" does not declare a "something-else" binary.'); + expect(spawn).not.toHaveBeenCalled(); }); it("does not download a missing setup package", async () => { @@ -155,6 +261,7 @@ describe("runRegistrySetupCommand", () => { "/project", { package: "@acme/slack", bin: "acme-slack", args: ["setup"] }, "channel/slack", + options(), ), ).rejects.toThrow( 'Setup package "@acme/slack" is not installed. Run `eve add channel/slack` first.', diff --git a/packages/eve/src/cli/commands/registry-setup-command.ts b/packages/eve/src/cli/commands/registry-setup-command.ts index 33ef063d8..6903020a7 100644 --- a/packages/eve/src/cli/commands/registry-setup-command.ts +++ b/packages/eve/src/cli/commands/registry-setup-command.ts @@ -1,10 +1,19 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { readFile } from "node:fs/promises"; import { findPackageJSON } from "node:module"; import { dirname, isAbsolute, relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { z } from "#compiled/zod/index.js"; +import { createProcessOutputBuffer } from "#setup/primitives/process-output.js"; +import type { Prompter, PrompterValue } from "#setup/prompter.js"; +import { + isRegistrySetupChildMessage, + REGISTRY_SETUP_PROTOCOL_VERSION, + type RegistrySetupChildMessage, + type RegistrySetupParentMessage, +} from "#setup/registry-setup-protocol.js"; +import { WizardCancelledError } from "#setup/step.js"; export interface RegistrySetupCommand { package: string; @@ -12,7 +21,14 @@ export interface RegistrySetupCommand { args: string[]; } -export type RegistrySetupCommandResult = "completed" | "cancelled"; +export type RegistrySetupCommandResult = + | { kind: "completed"; output: readonly string[] } + | { kind: "cancelled" }; + +export interface RegistrySetupCommandOptions { + prompter: Prompter; + signal?: AbortSignal; +} const PackageJsonSchema = z.object({ name: z.string().min(1), @@ -47,11 +63,127 @@ async function resolveNodePackageBin(packageJsonPath: string, binName: string): return executable; } -/** Executes a trusted registry setup command from an installed package's declared Node binary. */ +function send(child: ChildProcess, message: RegistrySetupParentMessage): void { + if (child.connected) child.send(message); +} + +async function answerPrompt( + child: ChildProcess, + prompter: Prompter, + message: Extract, + openHandles: Map void>, +): Promise { + try { + let value: unknown; + const request = message.prompt; + switch (request.kind) { + case "text": + value = await prompter.text(request); + break; + case "password": + value = await prompter.password(request); + break; + case "select": + value = request.options.multiple + ? await prompter.select(request.options) + : await prompter.select(request.options); + break; + case "editable-select": + if (prompter.selectEditable === undefined) { + const selected = await prompter.select(request.options); + value = { kind: "selected", value: selected }; + } else { + value = await prompter.selectEditable({ + ...request.options, + editable: { + ...request.options.editable, + formatHint: (text) => text, + }, + }); + } + break; + case "acknowledge": + await prompter.acknowledge?.(request.options); + break; + case "choice": { + if (prompter.awaitChoice === undefined) { + value = await prompter.select({ + message: request.options.context, + options: [...request.options.actions], + }); + break; + } + const choice = prompter.awaitChoice(request.options); + openHandles.set(message.id, choice.close); + try { + value = await choice.choice; + } finally { + openHandles.delete(message.id); + } + break; + } + } + send(child, { type: "prompt-result", id: message.id, value }); + } catch (error) { + if (error instanceof WizardCancelledError) { + send(child, { type: "prompt-result", id: message.id, cancelled: true }); + return; + } + throw error; + } +} + +function handlePresentation( + child: ChildProcess, + prompter: Prompter, + message: RegistrySetupChildMessage, + openChoices: Map void>, +): boolean { + switch (message.type) { + case "log": + prompter.log[message.level](message.text); + return true; + case "note": + prompter.note(message.message, message.title, { tone: message.tone }); + return true; + case "intro": + prompter.intro(message.text, message.subtitle); + return true; + case "outro": + prompter.outro(message.text); + return true; + case "result": + return false; + case "status": { + openChoices.get(message.id)?.(); + openChoices.delete(message.id); + if (message.status !== undefined) { + const spinner = prompter.log.spinner?.(message.status); + if (spinner !== undefined) openChoices.set(message.id, () => spinner.stop()); + } + return true; + } + case "close-prompt": + openChoices.get(message.id)?.(); + openChoices.delete(message.id); + return true; + case "prompt": + void answerPrompt(child, prompter, message, openChoices).catch((error: unknown) => { + terminateChild(child, "SIGTERM"); + child.emit("error", error as Error); + }); + return true; + case "ready": + return false; + } +} + +/** Executes a trusted setup binary while the parent prompter owns all terminal interaction. */ export async function runRegistrySetupCommand( appRoot: string, setup: RegistrySetupCommand, item: string, + options?: RegistrySetupCommandOptions, ): Promise { const packageJsonPath = findPackageJSON( setup.package, @@ -63,30 +195,130 @@ export async function runRegistrySetupCommand( ); } const executable = await resolveNodePackageBin(packageJsonPath, setup.bin); + if (options === undefined) { + throw new Error("Registry setup commands require a parent prompter."); + } return new Promise((resolveResult, reject) => { const child = spawn(process.execPath, [executable, ...setup.args], { cwd: appRoot, - env: { ...process.env, EVE_SETUP: "1", EVE_SETUP_ITEM: item }, - stdio: "inherit", + env: { + ...process.env, + EVE_SETUP: "1", + EVE_SETUP_ITEM: item, + EVE_SETUP_PROTOCOL: String(REGISTRY_SETUP_PROTOCOL_VERSION), + }, + stdio: ["ignore", "pipe", "pipe", "ipc"], + detached: process.platform !== "win32", + }); + let settled = false; + let ready = false; + let outcome: Extract["outcome"] | undefined; + const openHandles = new Map void>(); + const output = createProcessOutputBuffer(({ text }) => + options.prompter.log.commandOutput(text), + ); + child.stdout?.on("data", (chunk: Buffer) => output.write("stdout", chunk)); + child.stderr?.on("data", (chunk: Buffer) => output.write("stderr", chunk)); + + let cancelTimer: NodeJS.Timeout | undefined; + const cancel = (): void => { + send(child, { type: "cancel" }); + cancelTimer = setTimeout(() => terminateChild(child, "SIGTERM"), 3_000); + cancelTimer.unref(); + }; + options.signal?.addEventListener("abort", cancel, { once: true }); + const cleanup = (): void => { + output.flush(); + options.signal?.removeEventListener("abort", cancel); + if (cancelTimer !== undefined) clearTimeout(cancelTimer); + for (const close of openHandles.values()) close(); + openHandles.clear(); + }; + child.on("message", (value: unknown) => { + if (!isRegistrySetupChildMessage(value)) { + cancel(); + reject(new Error("Setup command sent an invalid registry protocol message.")); + return; + } + if (value.type === "ready") { + if (value.version !== REGISTRY_SETUP_PROTOCOL_VERSION) { + cancel(); + reject( + new Error( + `Setup command does not support registry setup protocol v${REGISTRY_SETUP_PROTOCOL_VERSION}.`, + ), + ); + return; + } + ready = true; + return; + } + if (value.type === "result") { + outcome = value.outcome; + return; + } + handlePresentation(child, options.prompter, value, openHandles); + }); + child.once("error", (error) => { + cleanup(); + reject(error); }); - child.once("error", reject); child.once("close", (code, signal) => { - if (code === 0) { - resolveResult("completed"); + cleanup(); + if (settled) return; + settled = true; + if (outcome?.kind === "completed") { + if (code !== 0) { + reject( + new Error( + `Setup command reported success, then exited with code ${code ?? "unknown"}.`, + ), + ); + return; + } + resolveResult({ + kind: "completed", + output: outcome.facts.map((fact) => `${fact.label}: ${fact.value}`), + }); + return; + } + if (outcome?.kind === "cancelled") { + resolveResult({ kind: "cancelled" }); + return; + } + if (outcome?.kind === "failed") { + const details = outcome.error.details?.length + ? `\n${outcome.error.details.join("\n")}` + : ""; + reject(new Error(`${outcome.error.message}${details}`)); + return; + } + if (options.signal?.aborted === true || code === 130 || signal === "SIGINT") { + resolveResult({ kind: "cancelled" }); return; } - if (code === 130 || signal === "SIGINT") { - resolveResult("cancelled"); + if (code === 0 && ready) { + reject(new Error("Setup command exited without reporting a result.")); return; } reject( new Error( signal === null - ? `Setup command exited with code ${code ?? "unknown"}.` - : `Setup command was terminated by ${signal}.`, + ? `Setup command exited with code ${code ?? "unknown"} before reporting a result.` + : `Setup command was terminated by ${signal} before reporting a result.`, ), ); }); }); } + +function terminateChild(child: ChildProcess, signal: NodeJS.Signals): void { + if (process.platform !== "win32" && child.pid !== undefined) { + try { + process.kill(-child.pid, signal); + return; + } catch {} + } + child.kill(signal); +} diff --git a/packages/eve/src/cli/commands/registry.test.ts b/packages/eve/src/cli/commands/registry.test.ts index 91d781ecf..e69b58926 100644 --- a/packages/eve/src/cli/commands/registry.test.ts +++ b/packages/eve/src/cli/commands/registry.test.ts @@ -12,6 +12,7 @@ import { const { addRegistryItems, + applyPackageManagerWorkspaceConfiguration, getRegistryItems, isEveProject, readFile, @@ -20,6 +21,7 @@ const { writeFile, } = vi.hoisted(() => ({ addRegistryItems: vi.fn(), + applyPackageManagerWorkspaceConfiguration: vi.fn(), getRegistryItems: vi.fn(), isEveProject: vi.fn(), readFile: vi.fn(), @@ -35,6 +37,7 @@ vi.mock("#compiled/shadcn-registry/index.js", () => ({ })); vi.mock("#setup/scaffold/index.js", () => ({ isEveProject })); +vi.mock("#setup/scaffold/workspace-root.js", () => ({ applyPackageManagerWorkspaceConfiguration })); vi.mock("#internal/application/package.js", () => ({ resolveInstalledPackageInfo })); vi.mock("node:fs/promises", () => ({ readFile, writeFile })); @@ -84,6 +87,7 @@ describe("registry commands", () => { }, cwd: "/project", overwrite: true, + silent: undefined, }); expect(logger.errors).toEqual([]); }); @@ -92,7 +96,7 @@ describe("registry commands", () => { "installs the official %s item before running its declared setup", async (kind) => { const logger = createLogger(); - const runSetupCommand = vi.fn(async () => "completed" as const); + const runSetupCommand = vi.fn(async () => ({ kind: "completed" as const, output: [] })); getRegistryItems.mockResolvedValue([ { name: `channel/${kind}`, @@ -127,13 +131,14 @@ describe("registry commands", () => { args: ["integration", "setup", kind, "--yes"], }, `channel/${kind}`, + expect.objectContaining({ prompter: expect.any(Object) }), ); }, ); it("skips setup in non-interactive use and prints the resume command", async () => { const logger = createLogger(); - const runSetup = vi.fn(async () => "completed" as const); + const runSetup = vi.fn(async () => ({ kind: "completed" as const, output: [] })); getRegistryItems.mockResolvedValue([ { meta: { eve: { setup: { package: "@acme/slack", bin: "eve-slack", args: ["setup"] } } }, @@ -159,7 +164,7 @@ describe("registry commands", () => { it("asks before setup and prints the resume command when declined", async () => { const logger = createLogger(); - const runSetup = vi.fn(async () => "completed" as const); + const runSetup = vi.fn(async () => ({ kind: "completed" as const, output: [] })); const fake = createFakePrompter({ single: () => "no" }); getRegistryItems.mockResolvedValue([ { @@ -188,7 +193,7 @@ describe("registry commands", () => { it("prints the resume command when the setup CLI cancels", async () => { const logger = createLogger(); - const runSetup = vi.fn(async () => "cancelled" as const); + const runSetup = vi.fn(async () => ({ kind: "cancelled" as const })); getRegistryItems.mockResolvedValue([ { meta: { eve: { setup: { package: "@acme/slack", bin: "eve-slack", args: ["setup"] } } }, @@ -213,7 +218,7 @@ describe("registry commands", () => { it("runs setup directly without installing the item", async () => { const logger = createLogger(); - const runSetup = vi.fn(async () => "completed" as const); + const runSetup = vi.fn(async () => ({ kind: "completed" as const, output: [] })); getRegistryItems.mockResolvedValue([ { meta: { eve: { setup: { package: "@acme/slack", bin: "eve-slack", args: ["setup"] } } }, @@ -235,6 +240,7 @@ describe("registry commands", () => { "/project", { package: "@acme/slack", bin: "eve-slack", args: ["setup", "--yes"] }, "channel/slack", + expect.objectContaining({ prompter: expect.any(Object) }), ); }); @@ -286,7 +292,7 @@ describe("registry commands", () => { it("does not infer setup from the item address", async () => { const logger = createLogger(); - const runSetupCommand = vi.fn(async () => "completed" as const); + const runSetupCommand = vi.fn(async () => ({ kind: "completed" as const, output: [] })); getRegistryItems.mockResolvedValue([{ name: "channel/web", type: "registry:item" }]); await runAddCommand( @@ -305,7 +311,7 @@ describe("registry commands", () => { it("does not execute setup metadata from a URL item", async () => { const logger = createLogger(); - const runSetupCommand = vi.fn(async () => "completed" as const); + const runSetupCommand = vi.fn(async () => ({ kind: "completed" as const, output: [] })); getRegistryItems.mockResolvedValue([ { name: "channel/web", @@ -330,6 +336,7 @@ describe("registry commands", () => { expect(runSetupCommand).not.toHaveBeenCalled(); expect(addRegistryItems).toHaveBeenCalledOnce(); + expect(applyPackageManagerWorkspaceConfiguration).not.toHaveBeenCalled(); }); it("accepts any declared package binary from trusted official metadata", async () => { diff --git a/packages/eve/src/cli/commands/registry.ts b/packages/eve/src/cli/commands/registry.ts index 1aa3d24c3..d241c555c 100644 --- a/packages/eve/src/cli/commands/registry.ts +++ b/packages/eve/src/cli/commands/registry.ts @@ -3,6 +3,7 @@ import { getRegistryItems, searchRegistries, type RegistryConfig, + type RegistrySearchItem, } from "#compiled/shadcn-registry/index.js"; import semver from "#compiled/semver/index.js"; import { z } from "#compiled/zod/index.js"; @@ -25,9 +26,14 @@ export interface AddCommandOptions { overwrite?: boolean; skipSetup?: boolean; yes?: boolean; + /** Suppresses the registry SDK's terminal-native progress output. */ + silent?: boolean; } -type SetupCommandOptions = Pick; +type SetupCommandOptions = Pick & { + prompter?: Prompter; + signal?: AbortSignal; +}; export interface RegistrySetupDependencies { loadSetupCommandRunner(): Promise; @@ -38,6 +44,22 @@ export interface AddCommandDependencies extends RegistrySetupDependencies { isInteractive?: () => boolean; } +/** One discoverable item from an eve-compatible registry catalog. */ +export interface RegistryCatalogItem { + address: string; + name: string; + type?: string; + description?: string; + source: string; +} + +/** Catalog items plus non-fatal failures from the registry sources queried. */ +export interface RegistryCatalogResult { + items: RegistryCatalogItem[]; + total: number; + errors: Array<{ message: string; registry: string }>; +} + const defaultAddCommandDependencies: AddCommandDependencies = { createPrompter, isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true, @@ -176,20 +198,52 @@ function printSearchResults( } } -async function browseRegistryItems( - logger: RegistryCommandLogger, +async function searchRegistryCatalog( appRoot: string, - query: string | undefined, - source: string | undefined, -): Promise { - validateRegistrySource(source); + options: { query?: string; source?: string }, +) { + validateRegistrySource(options.source); const config = await readRegistryConfig(appRoot); - const sources = source ? [source] : [OFFICIAL_CATALOG, ...configuredRegistrySources(config)]; + const sources = options.source + ? [options.source] + : [OFFICIAL_CATALOG, ...configuredRegistrySources(config)]; const result = await searchRegistries(sources, { config, continueOnError: sources.length > 1, - query, + query: options.query, }); + return { result, sources }; +} + +/** Browses all configured catalogs, or one namespace or URL source. */ +export async function browseRegistryCatalog( + appRoot: string, + options: { query?: string; source?: string } = {}, +): Promise { + const { result } = await searchRegistryCatalog(appRoot, options); + return { + items: result.items.map((item: RegistrySearchItem) => { + const catalogItem: RegistryCatalogItem = { + address: item.registry === OFFICIAL_CATALOG ? item.name : item.addCommandArgument, + name: item.name, + source: item.registry === OFFICIAL_CATALOG ? "Vercel" : item.registry, + }; + if (item.type !== undefined) catalogItem.type = item.type; + if (item.description !== undefined) catalogItem.description = item.description; + return catalogItem; + }), + total: result.pagination.total, + errors: result.errors ?? [], + }; +} + +async function browseRegistryItems( + logger: RegistryCommandLogger, + appRoot: string, + query: string | undefined, + source: string | undefined, +): Promise { + const { result, sources } = await searchRegistryCatalog(appRoot, { query, source }); const errors = result.errors ?? []; if (errors.length < sources.length) { printSearchResults(logger, result, { query, sources }); @@ -200,6 +254,13 @@ async function browseRegistryItems( if (errors.length > 0) process.exitCode = 1; } +/** Resolves one official, configured, or URL-addressed item manifest. */ +export async function getRegistryItemManifest(appRoot: string, item: string): Promise { + const config = await readRegistryConfig(appRoot); + const items = await getRegistryItems([itemAddress(item)], { config }); + return items.length === 1 ? items[0] : items; +} + async function runDeclaredSetup( logger: RegistryCommandLogger, appRoot: string, @@ -211,23 +272,51 @@ async function runDeclaredSetup( if (setup === undefined) return; const runSetupCommand = await dependencies.loadSetupCommandRunner(); try { + const prompter = options.prompter ?? createPrompter(); const result = await runSetupCommand( appRoot, { ...setup, args: [...setup.args, ...(options.yes ? ["--yes"] : [])] }, item, + { prompter, signal: options.signal }, ); - if (result === "cancelled") logger.log(setupReminder(item, "cancelled")); + if (result.kind === "cancelled") { + logger.log(setupReminder(item, "cancelled")); + } else { + for (const line of result.output) logger.log(line); + } } catch (error) { throw new Error(`${errorMessage(error)} Try again with \`${setupResumeCommand(item)}\`.`); } } +/** Installs an official, configured, or URL-addressed registry item. */ +export async function installRegistryItem( + appRoot: string, + item: string, + options: AddCommandOptions & { prompter?: Prompter; signal?: AbortSignal } = {}, + dependencies: AddCommandDependencies = defaultAddCommandDependencies, +): Promise { + let failure: string | undefined; + const output: string[] = []; + const logger: RegistryCommandLogger = { + error: (message) => { + failure = message; + }, + log: (message) => output.push(message), + }; + const previousExitCode = process.exitCode; + await runAddCommand(logger, appRoot, item, { ...options, yes: true }, dependencies); + process.exitCode = previousExitCode; + if (failure !== undefined) throw new Error(failure); + return output; +} + /** Installs an official, configured, or URL-addressed registry item. */ export async function runAddCommand( logger: RegistryCommandLogger, appRoot: string, item: string, - options: AddCommandOptions, + options: AddCommandOptions & { prompter?: Prompter; signal?: AbortSignal }, dependencies: AddCommandDependencies = defaultAddCommandDependencies, ): Promise { await runRegistryAction(logger, appRoot, async () => { @@ -260,7 +349,12 @@ export async function runAddCommand( return; } - await addRegistryItems([address], { config, cwd: appRoot, overwrite: options.overwrite }); + await addRegistryItems([address], { + config, + cwd: appRoot, + overwrite: options.overwrite, + silent: options.silent, + }); if (eveMetadata?.setup === undefined) return; const isInteractive = 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 73f760056..3fb97f6cc 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.test.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.test.ts @@ -134,11 +134,11 @@ describe("buildAgentHeader", () => { expect(remote.join("\n")).not.toContain("/channels"); }); - it("renders the /connect tip with a blue command", () => { + it("renders the /add tip with a blue command", () => { const colorTheme = createTheme({ color: true, unicode: false }); - const tip = AGENT_HEADER_TIPS.find((candidate) => candidate.includes("/connect")); + const tip = AGENT_HEADER_TIPS.find((candidate) => candidate.includes("/add")); - expect(tip).toBe("Tip: /connect to seamlessly add MCP Connections to your agent"); + expect(tip).toBe("Use /add to install integrations from the registry."); if (tip === undefined) return; const line = buildAgentHeader({ @@ -150,7 +150,7 @@ describe("buildAgentHeader", () => { }).at(-1); expect(stripAnsi(line ?? "")).toBe(` ${tip}`); - expect(line).toContain(colorTheme.colors.blue("/connect")); + expect(line).toContain(colorTheme.colors.blue("/add")); }); it("keeps the discovery-diagnostics line when the compiler reported problems", () => { diff --git a/packages/eve/src/cli/dev/tui/agent-header.ts b/packages/eve/src/cli/dev/tui/agent-header.ts index 8ea3cddda..98b068770 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.ts @@ -28,10 +28,9 @@ export interface AgentHeaderInput { * slash commands, so callers only attach a tip to local `eve dev` sessions. */ export const AGENT_HEADER_TIPS: readonly string[] = [ - "Use /channels to add more ways to reach your agent.", + "Use /add to install integrations from the registry.", "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. */ diff --git a/packages/eve/src/cli/dev/tui/command-typeahead.test.ts b/packages/eve/src/cli/dev/tui/command-typeahead.test.ts index e039fec26..16cdb20ea 100644 --- a/packages/eve/src/cli/dev/tui/command-typeahead.test.ts +++ b/packages/eve/src/cli/dev/tui/command-typeahead.test.ts @@ -178,11 +178,11 @@ describe("renderCommandSuggestions", () => { expect(rows.some((row) => row.includes("commands, showing"))).toBe(false); }); - it("shows the whole command registry on a bare slash", () => { + it("shows the first window of the command registry on a bare slash", () => { const state = typeaheadFor(PROMPT_COMMANDS, "/"); const rows = renderCommandSuggestions(state, theme, 80).map(stripAnsi); - expect(rows).toHaveLength(PROMPT_COMMANDS.length); - expect(rows.some((row) => row.includes("/exit (/quit)"))).toBe(true); + expect(rows).toHaveLength(Math.min(PROMPT_COMMANDS.length, 10)); + expect(rows[0]).toContain("/help"); }); it("clips rows to the terminal width", () => { diff --git a/packages/eve/src/cli/dev/tui/prompt-command-handler.test.ts b/packages/eve/src/cli/dev/tui/prompt-command-handler.test.ts index 5e2b35038..4da4355a0 100644 --- a/packages/eve/src/cli/dev/tui/prompt-command-handler.test.ts +++ b/packages/eve/src/cli/dev/tui/prompt-command-handler.test.ts @@ -42,6 +42,7 @@ function setupFlowRenderer() { setStatus: vi.fn(), renderLine: vi.fn(), renderOutput: vi.fn(), + withInheritedStdio: (task) => task(), waitForInterrupt: () => ({ promise: new Promise(() => {}), dispose: vi.fn(), diff --git a/packages/eve/src/cli/dev/tui/prompt-command-handler.ts b/packages/eve/src/cli/dev/tui/prompt-command-handler.ts index 520d36bb4..f47e1dd75 100644 --- a/packages/eve/src/cli/dev/tui/prompt-command-handler.ts +++ b/packages/eve/src/cli/dev/tui/prompt-command-handler.ts @@ -117,6 +117,7 @@ export function createPromptCommandHandler( appRoot: target.workspaceRoot, renderer: flow, disabledConnectionReasons: context.disabledConnectionReasons, + withExclusiveTerminal: context.withExclusiveTerminal, }; if (context.initialModelStep !== undefined) { commandInput.initialModelStep = context.initialModelStep; @@ -125,6 +126,7 @@ export function createPromptCommandHandler( const result = await runTuiSetupCommand(commandInput); preserveFlowDiagnostics = result.preserveFlowDiagnostics; const outcome: PromptCommandOutcome = { message: result.message }; + if (result.tone !== undefined) outcome.tone = result.tone; if (result.effect !== undefined) outcome.effect = result.effect; return outcome; } finally { 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 9caad9fa0..cefb327d2 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.test.ts @@ -55,6 +55,11 @@ describe("parsePromptCommand", () => { name: "deploy", argument: "", }); + expect(parsePromptCommand("/add")).toEqual({ + type: "extension", + name: "add", + argument: "", + }); }); it("parses bare /loglevel and /loglevel with a mode argument", () => { @@ -95,6 +100,7 @@ describe("promptCommandsFor", () => { expect(names).toContain("model"); expect(names).toContain("channels"); expect(names).toContain("connect"); + expect(names).toContain("add"); expect(names).toContain("deploy"); expect(names).toContain("vc:install"); expect(names).toContain("vc:login"); @@ -109,6 +115,7 @@ describe("promptCommandsFor", () => { expect(names).not.toContain("model"); expect(names).not.toContain("channels"); expect(names).not.toContain("connect"); + expect(names).not.toContain("add"); expect(names).not.toContain("deploy"); }); diff --git a/packages/eve/src/cli/dev/tui/prompt-commands.ts b/packages/eve/src/cli/dev/tui/prompt-commands.ts index be7bac701..5d755f115 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.ts @@ -2,6 +2,7 @@ export type PromptCommandExtensionName = | "model" | "channels" | "connect" + | "add" | "deploy" | "vc:install" | "vc:login"; @@ -113,6 +114,14 @@ const PROMPT_COMMAND_DEFINITIONS = [ build: () => ({ type: "extension", name: "connect", argument: "" }), targets: ["local"], }, + { + name: "add", + aliases: [], + description: "Browse and add registry integrations", + takesArgument: false, + build: () => ({ type: "extension", name: "add", argument: "" }), + targets: ["local"], + }, { name: "deploy", aliases: [], diff --git a/packages/eve/src/cli/dev/tui/remote-auth-command.ts b/packages/eve/src/cli/dev/tui/remote-auth-command.ts index 270dd480f..7307516e2 100644 --- a/packages/eve/src/cli/dev/tui/remote-auth-command.ts +++ b/packages/eve/src/cli/dev/tui/remote-auth-command.ts @@ -63,6 +63,9 @@ function mutedRenderer( renderOutput: (text) => { if (!isMuted()) renderer.renderOutput(text); }, + withInheritedStdio: (task) => renderer.withInheritedStdio(task), + withExclusiveTerminal: (task) => + renderer.withExclusiveTerminal?.(task) ?? renderer.withInheritedStdio(task), }; } diff --git a/packages/eve/src/cli/dev/tui/runner.test.ts b/packages/eve/src/cli/dev/tui/runner.test.ts index f253a56e7..9e1df5f23 100644 --- a/packages/eve/src/cli/dev/tui/runner.test.ts +++ b/packages/eve/src/cli/dev/tui/runner.test.ts @@ -232,6 +232,7 @@ function idleSetupFlow(): SetupFlowRenderer { setStatus: vi.fn(), renderLine: vi.fn(), renderOutput: vi.fn(), + withInheritedStdio: (task) => task(), waitForInterrupt: () => ({ promise: new Promise(() => {}), dispose: vi.fn(), @@ -2564,10 +2565,13 @@ describe("EveTUIRunner boot setup detection", () => { detectProjectIdentity: vi.fn(async () => undefined), getVercelAuthStatus: vi.fn(async (): Promise<"authenticated"> => "authenticated"), promptCommandHandler: { - handle: async () => ({ - message: "Connected to AI Gateway via AI_GATEWAY_API_KEY in .env.local.", - effect: { kind: "model-access-changed" }, - }), + handle: async (command) => + command.name === "model" + ? { + message: "Connected to AI Gateway via AI_GATEWAY_API_KEY in .env.local.", + effect: { kind: "model-access-changed" }, + } + : { message: "/add dismissed." }, }, }); @@ -2627,7 +2631,7 @@ describe("EveTUIRunner boot setup detection", () => { await runner.run(); - expect(order).toEqual(["vc:install", "vc:login", "model", "prompt"]); + expect(order).toEqual(["vc:install", "vc:login", "model", "add", "prompt"]); expect(handle).toHaveBeenNthCalledWith( 1, { type: "extension", name: "vc:install", argument: "" }, @@ -2642,6 +2646,10 @@ describe("EveTUIRunner boot setup detection", () => { { type: "extension", name: "model", argument: "" }, { renderer, title: "Weather Agent", initialModelStep: "provider" }, ); + expect(handle).toHaveBeenCalledWith( + { type: "extension", name: "add", argument: "" }, + { renderer, title: "Weather Agent" }, + ); }); it("stops onboarding when Vercel CLI installation leaves the CLI unavailable", async () => { diff --git a/packages/eve/src/cli/dev/tui/runner.ts b/packages/eve/src/cli/dev/tui/runner.ts index 44028d58d..f9d67955a 100644 --- a/packages/eve/src/cli/dev/tui/runner.ts +++ b/packages/eve/src/cli/dev/tui/runner.ts @@ -256,7 +256,7 @@ export type AgentTUIRenderer = { clearSetupWarning?(): void; /** Commits the startup `/vc:login` invocation to the transcript. */ renderCommandInvocation?(text: string, status?: "failed"): void; - renderCommandResult?(text: string): void; + renderCommandResult?(text: string, tone?: "success" | "error"): void; readonly setupFlow?: SetupFlowRenderer; readPrompt?(options?: AgentTUISessionOptions): Promise; /** @@ -364,6 +364,7 @@ export interface PromptCommandHandlerContext { */ readonly keepSetupFlowOpen?: true; readonly remoteConnection?: RemoteConnectionController; + readonly withExclusiveTerminal?: (task: () => Promise) => Promise; readonly disabledConnectionReasons?: Readonly>; } @@ -371,6 +372,8 @@ export interface PromptCommandHandlerContext { export interface PromptCommandOutcome { /** Outcome line rendered under the echoed command; absent renders nothing. */ message?: string; + /** Promotes an outcome to a top-level status. */ + tone?: "success" | "error"; /** Post-command work after setup settles. */ effect?: VercelStatusEffect | { kind: "connection-added" } | { kind: "model-access-changed" }; } @@ -423,6 +426,8 @@ export type EveTUIRunnerOptions = TuiDisplayOptions & { promptCommandHandler?: PromptCommandHandler; /** Commands shown in discovery for this local or remote session. */ availablePromptCommands?: readonly PromptCommandSpec[]; + /** Gives setup subprocesses exclusive terminal and development-host ownership. */ + withExclusiveTerminal?: (task: () => Promise) => Promise; /** Remote target and mutable OIDC token source, when connected through `--url`. */ remote?: { readonly target: RemoteDevelopmentTarget; @@ -472,6 +477,7 @@ export class EveTUIRunner { readonly #initialInput?: string; readonly #promptCommandHandler?: PromptCommandHandler; readonly #availablePromptCommands: readonly PromptCommandSpec[]; + readonly #withExclusiveTerminal?: (task: () => Promise) => Promise; readonly #remoteConnection?: RemoteConnectionController; readonly #bootDetections: readonly BootDetection[]; readonly #getVercelAuthStatus: typeof getVercelAuthStatus; @@ -559,6 +565,7 @@ export class EveTUIRunner { if (this.#renderer.subagents !== undefined) pumpOptions.view = this.#renderer.subagents; this.#subagentPump = new SubagentPump(pumpOptions); this.#name = options.name ?? "eve"; + this.#withExclusiveTerminal = options.withExclusiveTerminal; this.#tools = options.tools ?? "full"; this.#reasoning = options.reasoning ?? "full"; this.#subagents = options.subagents ?? "full"; @@ -1298,10 +1305,10 @@ export class EveTUIRunner { }); } - #renderCommandOutcome(text: string | undefined): void { + #renderCommandOutcome(text: string | undefined, tone?: "success" | "error"): void { if (text === undefined) return; if (this.#renderer.renderCommandResult !== undefined) { - this.#renderer.renderCommandResult(text); + this.#renderer.renderCommandResult(text, tone); return; } this.#renderer.renderNotice?.(text); @@ -1320,6 +1327,7 @@ export class EveTUIRunner { title: input.title, initialModelStep: input.initialModelStep, remoteConnection: this.#remoteConnection, + withExclusiveTerminal: this.#withExclusiveTerminal, }; const disabledConnectionReasons = this.#mcpConnectionStatus?.current(); const context: PromptCommandHandlerContext = @@ -1389,7 +1397,7 @@ export class EveTUIRunner { title, }); this.#renderStartupCommandInvocation(command, input.trigger); - this.#renderCommandOutcome(outcome?.message); + this.#renderCommandOutcome(outcome?.message, outcome?.tone); await this.#applyCommandEffect(outcome?.effect); this.#refreshHeaderFromRemoteConnection(); } @@ -1399,7 +1407,8 @@ export class EveTUIRunner { * model access depends on the Vercel CLI and a Vercel session, so resolve * only those missing prerequisites before entering the model picker. A probe * failure still opens `/model`: its own-key and external-provider paths do - * not require Vercel. + * not require Vercel. After model setup, open the categorized registry hub so + * a new user has concrete next steps before reaching the chat prompt. */ async #runInitialModelOnboarding(title: string): Promise { const appRoot = this.#appRoot; @@ -1444,6 +1453,9 @@ export class EveTUIRunner { trigger: "startup", initialModelStep: "provider", }); + await this.#executeExtensionCommand({ type: "extension", name: "add", argument: "" }, title, { + trigger: "startup", + }); } #refreshHeaderFromRemoteConnection(): void { 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 8d63c7236..4c3322e3e 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.test.ts @@ -36,14 +36,15 @@ function fakePanelRenderer(): TuiSetupCommandRenderer & { setStatus: vi.fn(), renderLine: vi.fn(), renderOutput: vi.fn(), - waitForInterrupt: () => ({ + withInheritedStdio: (task) => task(), + waitForInterrupt: vi.fn(() => ({ promise: new Promise((resolve) => { fire = resolve; }), dispose: () => { disposed = true; }, - }), + })), fireInterrupt: () => fire(), interruptDisposed: () => disposed, }; @@ -67,6 +68,10 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { kind: "done", addedConnections: [], })), + runRegistryFlow: vi.fn(async () => ({ + kind: "done", + addedItems: [], + })), runDeployFlow: vi.fn(async () => ({ kind: "deployed", productionUrl: "https://my-agent.vercel.app", @@ -76,7 +81,7 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { } function run(input: { - command: "vc:install" | "vc:login" | "model" | "channels" | "connect" | "deploy"; + command: "vc:install" | "vc:login" | "model" | "channels" | "connect" | "add" | "deploy"; flows: TuiSetupFlows; renderer?: TuiSetupCommandRenderer; initialModelStep?: "provider"; @@ -100,6 +105,18 @@ function run(input: { } describe("runTuiSetupCommand", () => { + it("keeps registry setup interruptible through the parent drawer", async () => { + const renderer = fakePanelRenderer(); + const runRegistryFlow = vi.fn(async () => ({ + kind: "done", + addedItems: [], + })); + + await run({ command: "add", flows: fakeFlows({ runRegistryFlow }), renderer }); + + expect(renderer.waitForInterrupt).toHaveBeenCalledWith(); + }); + it("uses the build pulse for every setup command except deploy", () => { expect( Object.fromEntries( @@ -111,6 +128,7 @@ describe("runTuiSetupCommand", () => { model: "pulse", channels: "pulse", connect: "pulse", + add: "pulse", deploy: "spinner", }); }); @@ -490,6 +508,30 @@ describe("runTuiSetupCommand", () => { expect(runConnectionsFlow).toHaveBeenCalledWith(expect.objectContaining({ appRoot: APP_ROOT })); }); + it.each([ + [ + "added", + { kind: "done", addedItems: ["extension/browser"] }, + "Registry items added: extension/browser.", + ], + ["empty", { kind: "done", addedItems: [] }, "No registry items added."], + ["cancelled", { kind: "cancelled" }, "/add dismissed."], + ] as const)("reports a %s registry flow", async (_case, result, message) => { + const runRegistryFlow = vi.fn(async () => result); + const outcome = await run({ command: "add", flows: fakeFlows({ runRegistryFlow }) }); + const expected: { + message: string; + tone?: "success"; + preserveFlowDiagnostics: boolean; + } = { + message, + preserveFlowDiagnostics: true, + }; + if (result.kind === "done" && result.addedItems.length > 0) expected.tone = "success"; + expect(outcome).toEqual(expected); + expect(runRegistryFlow).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 () => ({ @@ -527,6 +569,7 @@ describe("runTuiSetupCommand", () => { }); await expect(run({ command: "channels", flows: failing })).resolves.toEqual({ message: "/channels failed: vercel CLI not found", + tone: "error", preserveFlowDiagnostics: true, }); @@ -552,6 +595,7 @@ describe("runTuiSetupCommand", () => { await expect(run({ command: "channels", flows, renderer })).resolves.toEqual({ message: "/channels failed: network down", + tone: "error", preserveFlowDiagnostics: true, }); expect(renderer.setStatus).toHaveBeenLastCalledWith(undefined); diff --git a/packages/eve/src/cli/dev/tui/setup-commands.ts b/packages/eve/src/cli/dev/tui/setup-commands.ts index da4e924f0..2c5cec524 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.ts @@ -9,6 +9,7 @@ import { import { runLoginFlow, type LoginFlowResult } from "#setup/flows/login.js"; import { runModelFlow, type ModelProviderOutcome } from "#setup/flows/model.js"; import { runProviderFlow, type ProviderPicker } from "#setup/flows/provider.js"; +import { runRegistryFlow } from "#setup/flows/registry.js"; import { openUrl } from "#setup/primitives/open-url.js"; import type { Prompter } from "#setup/prompter.js"; import { slackMessageDeepLink } from "#setup/slack-connect.js"; @@ -32,6 +33,7 @@ export const SETUP_FLOW_CONFIG = { model: { title: "Configure the agent model", indicator: "pulse" }, channels: { title: "Agent channels", indicator: "pulse" }, connect: { title: "Agent connections", indicator: "pulse" }, + add: { title: "Add to your agent", indicator: "pulse" }, deploy: { title: "Deploy to Vercel", indicator: "spinner" }, } satisfies Record; @@ -51,6 +53,8 @@ export interface TuiSetupCommandInput { /** Initial model-flow step authorized by the runner's boot evidence. */ initialModelStep?: "provider"; disabledConnectionReasons?: Readonly>; + /** Suspends development runtime artifacts while registry installation and setup mutate them. */ + withExclusiveTerminal?(task: () => Promise): Promise; /** Test seam; defaults to the real TUI-native prompter over `renderer`. */ createPrompter?: (renderer: TuiPrompterRenderer) => Prompter; /** Test seam; defaults to the real setup flows. */ @@ -64,11 +68,14 @@ export interface TuiSetupFlows { runModelFlow: typeof runModelFlow; runChannelsFlow: typeof runChannelsFlow; runConnectionsFlow: typeof runConnectionsFlow; + runRegistryFlow: typeof runRegistryFlow; runDeployFlow: typeof runDeployFlow; } export interface TuiSetupCommandResult { message: string; + /** Promotes an outcome to a top-level status. */ + tone?: "success" | "error"; /** Keep warning/error lines after the bordered panel closes. */ preserveFlowDiagnostics: boolean; /** Status refresh required after the command settles. */ @@ -83,6 +90,7 @@ export interface TuiSetupCommandResult { function muteableRenderer( renderer: TuiSetupCommandRenderer, isMuted: () => boolean, + withSuspendedRuntime?: TuiSetupCommandInput["withExclusiveTerminal"], ): MuteableSetupRenderer { return { readSelect: (options) => @@ -111,11 +119,15 @@ function muteableRenderer( renderOutput: (text) => { if (!isMuted()) renderer.renderOutput(text); }, + withInheritedStdio: (task) => renderer.withInheritedStdio(task), + // Registry setup keeps the parent renderer attached; this capability now + // pauses runtime artifacts without handing the terminal to the child. + withExclusiveTerminal: (task) => withSuspendedRuntime?.(task) ?? task(), }; } /** - * Runs one TUI setup command (/model, /channels, /connect, /deploy) over the + * Runs one TUI setup command (/model, /channels, /connect, /add, /deploy) over the * shared setup flows, asking through the TUI's own bordered panel. Never throws: * every outcome — done, cancelled, failed — folds into the returned command * result. Ctrl-C or Esc on the working indicator (no question open) aborts the @@ -128,7 +140,7 @@ export async function runTuiSetupCommand( const { command } = input; let interrupted = false; const controller = new AbortController(); - const renderer = muteableRenderer(input.renderer, () => interrupted); + const renderer = muteableRenderer(input.renderer, () => interrupted, input.withExclusiveTerminal); const prompter = (input.createPrompter ?? createTuiPrompter)(renderer); const interrupt = input.renderer.waitForInterrupt(); @@ -166,6 +178,7 @@ async function executeSetupCommand( runModelFlow, runChannelsFlow, runConnectionsFlow, + runRegistryFlow, runDeployFlow, ...input.flows, }; @@ -290,6 +303,23 @@ async function executeSetupCommand( }; } } + case "add": { + const result = await flows.runRegistryFlow({ appRoot, prompter, signal }); + if (result.kind === "cancelled") { + return { message: "/add dismissed.", preserveFlowDiagnostics: true }; + } + if (result.addedItems.length > 0) { + return { + message: [ + `Registry items added: ${result.addedItems.join(", ")}.`, + ...(result.output ?? []), + ].join("\n"), + tone: "success", + preserveFlowDiagnostics: true, + }; + } + return { message: "No registry items added.", preserveFlowDiagnostics: true }; + } case "deploy": { const result = await flows.runDeployFlow({ appRoot, prompter, interactive: true, signal }); if (result.kind === "cancelled") { @@ -329,6 +359,7 @@ async function executeSetupCommand( if (routed !== undefined) return routed; return { message: `/${command} failed: ${error instanceof Error ? error.message : String(error)}`, + tone: "error", preserveFlowDiagnostics: true, }; } diff --git a/packages/eve/src/cli/dev/tui/setup-flow.ts b/packages/eve/src/cli/dev/tui/setup-flow.ts index cfb35b7f0..1221a791b 100644 --- a/packages/eve/src/cli/dev/tui/setup-flow.ts +++ b/packages/eve/src/cli/dev/tui/setup-flow.ts @@ -2,7 +2,7 @@ import type { ChannelSetupChoice, ChannelSetupChoiceOptions } from "#setup/cli/i import type { SearchActionOption } from "#setup/cli/select-state.js"; import type { ModelSettingsRequest, ModelSettingsResult } from "#setup/flows/model.js"; import type { ProviderPickerChoice, ProviderPickerRequest } from "#setup/flows/provider.js"; -import type { SelectNotice } from "#setup/prompter.js"; +import type { SelectMetadata, SelectNotice } from "#setup/prompter.js"; import type { SetupPanelOption } from "./setup-panel.js"; @@ -18,6 +18,8 @@ export type SetupFlowStatus = string | { kind: "external-action"; text: string; interface SetupSelectRequestBase { message: string; + description?: string; + metadata?: readonly SelectMetadata[]; options: readonly SetupPanelOption[]; notices?: readonly SelectNotice[]; } @@ -102,15 +104,23 @@ export interface SetupFlowRenderer { setStatus(status: SetupFlowStatus | undefined): void; renderLine(text: string, tone: "info" | "success" | "warning" | "error"): void; renderOutput(text: string): void; + /** Temporarily restores the terminal while a child process inherits stdio. */ + withInheritedStdio(task: () => Promise): Promise; + /** Gives a setup subprocess exclusive terminal and development-host ownership. */ + withExclusiveTerminal?(task: () => Promise): Promise; /** * Arms a key trap for the flow's working state — the status indicator between * questions, where no prompt is consuming keys. Ctrl-C or Esc resolves the * promise so the command can abandon an in-flight flow (e.g. a parked * `vercel connect create` browser OAuth). Open questions own their keys; the - * trap covers only the gaps. `dispose` releases the trap; the promise then - * never resolves. + * trap covers only the gaps. `interruptible: false` still consumes and + * discards input but does not cancel non-abortable work. `dispose` releases + * the trap; the promise then never resolves. */ - waitForInterrupt(): { promise: Promise; dispose(): void }; + waitForInterrupt(options?: { interruptible?: boolean }): { + promise: Promise; + dispose(): void; + }; } export type SetupFlowPrompterRenderer = Pick< @@ -123,4 +133,6 @@ export type SetupFlowPrompterRenderer = Pick< | "setStatus" | "renderLine" | "renderOutput" + | "withInheritedStdio" + | "withExclusiveTerminal" >; diff --git a/packages/eve/src/cli/dev/tui/setup-issues.integration.test.ts b/packages/eve/src/cli/dev/tui/setup-issues.integration.test.ts index e048b4a92..c5cbc85c3 100644 --- a/packages/eve/src/cli/dev/tui/setup-issues.integration.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-issues.integration.test.ts @@ -102,9 +102,9 @@ describe("BOOT_DETECTIONS against a real directory", () => { vi.fn(async () => Response.json({ revision: "snapshot-a" })), ); const order: string[] = []; - const handle = vi.fn(async () => { - order.push("model"); - return { message: "/model dismissed." }; + const handle = vi.fn(async (command: { name: string }) => { + order.push(command.name); + return { message: `/${command.name} dismissed.` }; }); const readPrompt = vi.fn(async () => { order.push("prompt"); @@ -129,11 +129,17 @@ describe("BOOT_DETECTIONS against a real directory", () => { await runner.run(); - expect(handle).toHaveBeenCalledExactlyOnceWith( + expect(handle).toHaveBeenNthCalledWith( + 1, { type: "extension", name: "model", argument: "" }, { renderer, title: "eve", initialModelStep: "provider" }, ); + expect(handle).toHaveBeenNthCalledWith( + 2, + { type: "extension", name: "add", argument: "" }, + { renderer, title: "eve" }, + ); expect(readPrompt).toHaveBeenCalledOnce(); - expect(order).toEqual(["model", "prompt"]); + expect(order).toEqual(["model", "add", "prompt"]); }); }); 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 ed99ec650..bea8e48a5 100644 --- a/packages/eve/src/cli/dev/tui/setup-panel.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-panel.test.ts @@ -40,7 +40,7 @@ describe("renderFlowPanel", () => { ); const text = rows.join("\n"); - expect(rows[0]).toBe("▔".repeat(60)); + expect(rows[0]).toBe("▔".repeat(59)); expect(rows[1]).toBe(" /deploy"); expect(text).toContain(" · Creating Vercel project…"); expect(text).toContain(" ✓ Linked"); @@ -138,6 +138,42 @@ describe("renderFlowPanel", () => { }); describe("renderSelectQuestion", () => { + it("renders question context beneath the heading and above compact actions", () => { + const rows = renderSelectQuestion( + { + kind: "single", + message: "extension/agent-browser", + description: "Add browser automation tools backed by agent-browser to an eve agent.", + metadata: [ + { label: "Source", value: "Official eve registry" }, + { label: "Packages", value: "@agent-browser/eve" }, + ], + options: [ + { value: "add", label: "Add to project" }, + { value: "back", label: "Back" }, + ], + select: initialSelectState({ + options: [ + { value: "add", label: "Add to project" }, + { value: "back", label: "Back" }, + ], + }), + }, + theme, + 100, + ); + + expect(rows.slice(0, 7)).toEqual([ + " extension/agent-browser", + " Add browser automation tools backed by agent-browser to an eve agent.", + " Source: Official eve registry", + " Packages: @agent-browser/eve", + "", + " ▶ Add to project ", + " Back", + ]); + }); + it("shows a stacked menu's selected-row description beneath that option", () => { const options: SetupPanelOption[] = [ { value: "model", label: "Change model", description: "The model your agent uses" }, diff --git a/packages/eve/src/cli/dev/tui/setup-panel.ts b/packages/eve/src/cli/dev/tui/setup-panel.ts index b765f3105..4cbfe5fe9 100644 --- a/packages/eve/src/cli/dev/tui/setup-panel.ts +++ b/packages/eve/src/cli/dev/tui/setup-panel.ts @@ -27,7 +27,7 @@ import { type SearchActionOption, type SelectState, } from "#setup/cli/select-state.js"; -import type { SelectNotice } from "#setup/prompter.js"; +import type { SelectMetadata, SelectNotice } from "#setup/prompter.js"; import type { ModelSettingsRequest } from "#setup/flows/model.js"; import { @@ -56,6 +56,10 @@ export type SetupPanelOption = PromptOption; interface SetupQuestionPanelBase { message: string; + /** Inert context rendered beneath the heading and above the controls. */ + description?: string; + /** Labeled facts rendered beneath the description and above the controls. */ + metadata?: readonly SelectMetadata[]; error?: string; /** Outcome lines from earlier menu laps, shown beneath the options. */ notices?: readonly SelectNotice[]; @@ -293,7 +297,9 @@ function renderFlowPanelStatus(status: FlowPanelStatus, theme: Theme): string { */ export function renderFlowPanel(state: FlowPanelState, theme: Theme, width: number): string[] { const c = theme.colors; - const rows: string[] = [c.dim(theme.glyph.hrule.repeat(Math.max(1, width)))]; + // Avoid the terminal's final column: writing into it can trigger an implicit + // wrap that the live-region row counter cannot observe, leaking old frames. + const rows: string[] = [c.dim(theme.glyph.hrule.repeat(Math.max(1, width - 1)))]; if (state.title.length > 0) { rows.push(` ${c.bold(state.title)}`); } @@ -797,6 +803,23 @@ export function renderSelectQuestion( const railed = isRailedSearch(presentation); const rows = selectMessageRows(state.message, presentation.layout, theme); + if (state.description !== undefined || state.metadata !== undefined) { + if (rows.at(-1) === "") rows.pop(); + if (state.description !== undefined) { + for (const line of wrapVisibleLine(state.description, Math.max(1, width - 4))) { + rows.push(` ${c.dim(line)}`); + } + } + for (const { label, value } of state.metadata ?? []) { + const prefix = `${label}: `; + const wrapped = wrapVisibleLine(value, Math.max(1, width - 4 - prefix.length)); + rows.push(` ${c.dim(`${prefix}${wrapped[0] ?? ""}`)}`); + for (const line of wrapped.slice(1)) { + rows.push(` ${c.dim(`${" ".repeat(prefix.length)}${line}`)}`); + } + } + rows.push(""); + } if (presentation.filter !== undefined) { // The railed filter line indents one extra cell so its rail sits in the 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 357b183eb..5fbf83fb6 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -217,13 +217,13 @@ describe("TerminalRenderer (inline scrollback)", () => { name: "Weather Agent", serverUrl: "http://localhost:3000", info: agentInfoWithModel("gpt-5"), - tip: "Use /channels to add more ways to reach your agent.", + tip: "Use eve add to install integrations from the registry.", }); renderer.shutdown(); const snapshot = screen.snapshot(); expect(snapshot).toContain("eve Weather Agent"); - expect(snapshot).toContain("Use /channels to add more ways to reach your agent."); + expect(snapshot).toContain("Use eve add to install integrations from the registry."); // The model lives on the status line, not the header; the old config // rows and key hints are gone. expect(snapshot).not.toContain("gpt-5"); @@ -1448,6 +1448,16 @@ describe("TerminalRenderer (inline scrollback)", () => { expect(screen.snapshot()).toContain("\u23bf /model dismissed."); }); + it("promotes a successful command outcome to a top-level green check", () => { + const { screen, renderer } = makeRenderer(); + renderer.renderCommandResult("Registry items added: channel/discord.", "success"); + renderer.shutdown(); + + expect(screen.snapshot()).toContain("✓ Registry items added: channel/discord."); + expect(screen.snapshot()).not.toContain("⎿"); + expect(screen.rawOutput()).toContain("\u001b[32m✓\u001b[39m"); + }); + it("marks a failed automatic command and keeps its multiline outcome in one result block", () => { const { screen, renderer } = makeRenderer(); renderer.renderCommandInvocation("/vc:login", "failed"); @@ -2446,9 +2456,9 @@ describe("TerminalRenderer (inline scrollback)", () => { const { screen, input, renderer } = makeRenderer(); const prompt = renderer.readPrompt(); - input.type("/channels"); + input.type("/add"); // The ANSI blue open (34) wraps the typed command in the painted row. - expect(screen.rawOutput()).toContain("/channels"); + expect(screen.rawOutput()).toContain("/add"); input.enter(); await prompt; renderer.shutdown(); @@ -2459,7 +2469,7 @@ describe("TerminalRenderer (inline scrollback)", () => { const prompt = renderer.readPrompt(); // Never passes through a known command, even if painted per keystroke - // ("/li…" is not "/channels"). + // ("/li…" is not a known command). input.type("/lin is not a command"); expect(screen.rawOutput()).not.toContain(""); input.enter(); @@ -3766,6 +3776,42 @@ describe("TerminalRenderer setup panel", () => { }); describe("TerminalRenderer setup flow session", () => { + it("restores the terminal while a child process inherits stdio", async () => { + const { screen, input, renderer } = makeRenderer(); + + renderer.renderNotice("anchor"); + renderer.setupFlow.begin("Add integration", "pulse"); + let inherited = false; + await renderer.setupFlow.withInheritedStdio(async () => { + inherited = true; + input.pause(); + }); + + expect(inherited).toBe(true); + expect(input.resumeCalls).toBe(2); + expect(screen.snapshot()).toContain("Add integration"); + renderer.setupFlow.end(); + renderer.shutdown(); + }); + + it("discards input without interrupting a non-interruptible flow", async () => { + const { input, renderer } = makeRenderer(); + renderer.setupFlow.begin("Add to your agent", "pulse"); + const interrupt = renderer.setupFlow.waitForInterrupt({ interruptible: false }); + let interrupted = false; + void interrupt.promise.then(() => { + interrupted = true; + }); + + input.send("hello\x1b\r"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(interrupted).toBe(false); + interrupt.dispose(); + renderer.setupFlow.end(); + renderer.shutdown(); + }); + it("uses the build-phase pulse for pulse setup flows", () => { vi.useFakeTimers(); try { @@ -3784,6 +3830,23 @@ describe("TerminalRenderer setup flow session", () => { } }); + it("shows elapsed time after a setup status has run for five seconds", () => { + vi.useFakeTimers(); + try { + const { screen, renderer } = makeRenderer(); + + renderer.setupFlow.begin("Add to your agent", "pulse"); + renderer.setupFlow.setStatus("Installing Slack and dependencies…"); + expect(screen.snapshot()).not.toContain("5s"); + + vi.advanceTimersByTime(5_100); + expect(screen.snapshot()).toContain("Installing Slack and dependencies… 5s"); + renderer.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + it("uses the attention color for an external-action pulse", () => { const { screen, renderer } = makeRenderer(); @@ -4278,14 +4341,14 @@ describe("TerminalRenderer command echo spacing", () => { renderer.renderNotice("assistant said something"); const prompt = renderer.readPrompt(); - input.type("/channels"); + input.type("/connect"); input.enter(); await prompt; renderer.renderCommandResult("Project linked."); renderer.shutdown(); const lines = screen.snapshot().split("\n"); - const echoIndex = lines.findIndex((line) => line.includes("│ /channels")); + const echoIndex = lines.findIndex((line) => line.includes("│ /connect")); expect(echoIndex).toBeGreaterThan(0); expect(lines[echoIndex - 1]).toBe(""); const resultIndex = lines.findIndex((line) => line.includes("⎿ Project linked.")); @@ -4348,13 +4411,13 @@ describe("TerminalRenderer command typeahead", () => { const { screen, input, renderer } = makeRenderer(); const prompt = renderer.readPrompt(); - input.type("/chan"); + input.type("/ad"); input.enter(); - expect(await prompt).toBe("/channels"); + expect(await prompt).toBe("/add"); renderer.shutdown(); - expect(screen.snapshot()).toContain("│ /channels"); - expect(screen.snapshot()).not.toContain("❯ /channels"); + expect(screen.snapshot()).toContain("│ /add"); + expect(screen.snapshot()).not.toContain("❯ /add"); }); it("submits an alias as typed instead of canonicalizing it", async () => { diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index f26eab179..78b14e6b1 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -223,8 +223,8 @@ function completedTurnStatus(input: { type SetupFlowIndicatorState = { kind: "spinner" } | { kind: "pulse"; startedAtMs: number }; type SetupFlowStatusState = - | { kind: "progress"; text: string } - | { kind: "external-action"; text: string; emphasis: string }; + | { kind: "progress"; text: string; startedAtMs: number } + | { kind: "external-action"; text: string; emphasis: string; startedAtMs: number }; type TurnIndicatorState = { kind: "idle" } | { kind: "waiting"; startedAtMs: number }; @@ -560,7 +560,9 @@ export class TerminalRenderer implements AgentTUIRenderer { setStatus: (text) => this.#setFlowStatus(text), renderLine: (text, tone) => this.#renderFlowLine(text, tone), renderOutput: (text) => this.#renderFlowOutput(text), - waitForInterrupt: () => this.#waitForFlowInterrupt(), + withInheritedStdio: (task) => this.#withInheritedStdio(task), + withExclusiveTerminal: (task) => this.#withInheritedStdio(task), + waitForInterrupt: (options) => this.#waitForFlowInterrupt(options), }; constructor(options?: TerminalRendererOptions) { @@ -1607,15 +1609,16 @@ export class TerminalRenderer implements AgentTUIRenderer { this.#paint(); } - /** - * Commits one command's outcome under its invocation with the elbow - * connector (` ⎿ /model cancelled.`), Claude Code's sub-result grammar. - */ - renderCommandResult(text: string): void { + /** Commits one command outcome, promoting explicit status to a top-level result. */ + renderCommandResult(text: string, tone?: "success" | "error"): void { const content = stripTerminalControls(text); if (content.trim().length === 0) return; this.#start(); - this.#pushBlock({ kind: "result", body: content, live: false }); + this.#pushBlock( + tone === undefined + ? { kind: "result", body: content, live: false } + : { kind: "flow", title: tone, body: content, live: false }, + ); this.#paint(); } @@ -1842,7 +1845,11 @@ export class TerminalRenderer implements AgentTUIRenderer { ): ReturnType { this.#start(); const flow = this.#requireSetupFlow(); - flow.status = { kind: "progress", text: stripTerminalControls(opts.status) }; + flow.status = { + kind: "progress", + text: stripTerminalControls(opts.status), + startedAtMs: Date.now(), + }; // 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; @@ -2409,13 +2416,16 @@ export class TerminalRenderer implements AgentTUIRenderer { } /** See {@link SetupFlowRenderer.waitForInterrupt}. */ - #waitForFlowInterrupt(): { promise: Promise; dispose(): void } { + #waitForFlowInterrupt(options?: { interruptible?: boolean }): { + promise: Promise; + dispose(): void; + } { let fire!: () => void; const promise = new Promise((resolve) => { fire = resolve; }); this.#flowInterrupt = fire; - this.#armFlowIdleTrap(); + this.#armFlowIdleTrap(options?.interruptible ?? true); return { promise, dispose: () => { @@ -2428,13 +2438,16 @@ export class TerminalRenderer implements AgentTUIRenderer { /** * Installs the working-state key consumer (Ctrl-C/Esc fires the armed flow - * interrupt) while no question owns the keys. Questions overwrite - * `#consumeKey` for their lifetime; {@link #closeSetupQuestion} re-arms. + * interrupt) while no question owns the keys. All other input is discarded: + * carrying typeahead keystrokes across an install into the next setup prompt + * makes that prompt feel laggy and can select an unintended answer. Questions + * overwrite `#consumeKey` for their lifetime; {@link #closeSetupQuestion} + * re-arms. */ - #armFlowIdleTrap(): void { + #armFlowIdleTrap(interruptible = true): void { if (this.#flowInterrupt === undefined) return; const consumer = (key: TerminalKey): void => { - if (key.type === "ctrl-c" || key.type === "escape") { + if (interruptible && (key.type === "ctrl-c" || key.type === "escape")) { if (key.type === "ctrl-c") { this.#requestExit(); } @@ -2473,11 +2486,12 @@ export class TerminalRenderer implements AgentTUIRenderer { status === undefined ? undefined : typeof status === "string" - ? { kind: "progress", text: stripTerminalControls(status) } + ? { kind: "progress", text: stripTerminalControls(status), startedAtMs: Date.now() } : { kind: "external-action", text: stripTerminalControls(status.text), emphasis: stripTerminalControls(status.emphasis), + startedAtMs: Date.now(), }; if (this.#setupFlow !== undefined) { this.#setupFlow.status = content; @@ -2551,6 +2565,42 @@ export class TerminalRenderer implements AgentTUIRenderer { this.#paint(); } + /** Gives an interactive subprocess the real terminal, then restores the live region. */ + async #withInheritedStdio(task: () => Promise): Promise { + // Setup questions can resolve from the first key in a buffered terminal + // chunk. The remaining bytes belong to the child process, not the next + // TUI question after it exits. + this.#keyBuffer = ""; + this.#clearKeyFlush(); + this.#flowInterrupt = undefined; + this.#disarmFlowIdleTrap(); + this.#detachInput(); + this.#stopTicker(); + this.#live.clear(); + this.#live.showCursor(); + this.#removeLogCapture(); + if (this.#input.isTTY) this.#input.setRawMode?.(false); + this.#input.pause(); + try { + return await task(); + } finally { + if (this.#input.isTTY) this.#input.setRawMode?.(true); + // The parent stream must remain paused while the child owns the terminal. + // Resume only after its raw mode and key consumer are ready again. + this.#input.resume(); + this.#keyBuffer = ""; + this.#clearKeyFlush(); + this.#live.hideCursor(); + this.#installLogCapture(); + if (this.#setupFlow !== undefined) { + this.#startTicker(); + this.#armFlowIdleTrap(); + } + this.#live.reset(); + this.#paint(); + } + } + /** Last server session id the runner reported; named in the parting line. */ setSessionId(sessionId: string): void { this.#sessionId = sessionId; @@ -3681,8 +3731,13 @@ export class TerminalRenderer implements AgentTUIRenderer { // values are guaranteed stale; it reappears, refreshed, when the // panel closes. const indicator = this.#setupFlowIndicator(flow, flow.status); - const status: FlowPanelStatus | undefined = - flow.status === undefined ? undefined : { ...flow.status, indicator }; + let status: FlowPanelStatus | undefined; + if (flow.status !== undefined) { + const { startedAtMs, ...flowStatus } = flow.status; + const elapsedMs = Date.now() - startedAtMs; + const elapsed = elapsedMs >= 5_000 ? ` ${formatTurnDuration(elapsedMs)}` : ""; + status = { ...flowStatus, text: `${flowStatus.text}${elapsed}`, 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, diff --git a/packages/eve/src/cli/dev/tui/test/fake-setup-flow-renderer.ts b/packages/eve/src/cli/dev/tui/test/fake-setup-flow-renderer.ts index 53e3f2676..9d25f6c06 100644 --- a/packages/eve/src/cli/dev/tui/test/fake-setup-flow-renderer.ts +++ b/packages/eve/src/cli/dev/tui/test/fake-setup-flow-renderer.ts @@ -21,6 +21,7 @@ export function createFakeSetupFlowRenderer( setStatus: () => {}, renderLine: () => {}, renderOutput: () => {}, + withInheritedStdio: (task) => task(), waitForInterrupt: () => ({ promise: new Promise(() => {}), dispose: () => {}, 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 d14ffe69e..b5caaa9c6 100644 --- a/packages/eve/src/cli/dev/tui/tui-prompter.test.ts +++ b/packages/eve/src/cli/dev/tui/tui-prompter.test.ts @@ -15,6 +15,7 @@ function fakeRenderer(overrides: Partial = {}): TuiPrompter setStatus: vi.fn(), renderLine: vi.fn(), renderOutput: vi.fn(), + withInheritedStdio: (task) => task(), ...overrides, }; } @@ -38,6 +39,26 @@ describe("createTuiPrompter", () => { expect(renderer.readSelect).toHaveBeenCalledWith(expect.objectContaining({ kind: "single" })); }); + it("forwards question-level descriptions and metadata to the panel", async () => { + const renderer = fakeRenderer({ readSelect: vi.fn(async () => ["option-0"]) }); + const prompter = createTuiPrompter(renderer); + + await prompter.select({ + message: "extension/agent-browser", + description: "Add browser automation tools to an eve agent.", + metadata: [{ label: "Source", value: "Official eve registry" }], + options: [{ value: "add", label: "Add to project" }], + }); + + expect(renderer.readSelect).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "single", + description: "Add browser automation tools to an eve agent.", + metadata: [{ label: "Source", value: "Official eve registry" }], + }), + ); + }); + it("maps a searchable action back to its typed value", async () => { const renderer = fakeRenderer({ readSelect: vi.fn(async () => [searchActionValue("older-agent")]), diff --git a/packages/eve/src/cli/dev/tui/tui-prompter.ts b/packages/eve/src/cli/dev/tui/tui-prompter.ts index f231b3880..aa4e69152 100644 --- a/packages/eve/src/cli/dev/tui/tui-prompter.ts +++ b/packages/eve/src/cli/dev/tui/tui-prompter.ts @@ -28,7 +28,14 @@ function setupSelectRequest( encode: (value: T) => string, encodeOptions: (options: readonly SelectOption[]) => SetupSelectRequest["options"], ): SetupSelectRequest { - const base = { message: opts.message, options }; + const base: { + message: string; + options: SetupSelectRequest["options"]; + description?: string; + metadata?: SetupSelectRequest["metadata"]; + } = { message: opts.message, options }; + if (opts.description !== undefined) base.description = opts.description; + if (opts.metadata !== undefined) base.metadata = opts.metadata; const withNotices = (request: Request): Request => { if (opts.notices !== undefined) request.notices = opts.notices; return request; @@ -194,6 +201,8 @@ export function createTuiPrompter(renderer: TuiPrompterRenderer): Prompter { outro() {}, + withInheritedStdio: (task) => renderer.withInheritedStdio(task), + log: { message: line("info"), info: line("info"), diff --git a/packages/eve/src/cli/dev/tui/tui.ts b/packages/eve/src/cli/dev/tui/tui.ts index cef31755a..b68db93bc 100644 --- a/packages/eve/src/cli/dev/tui/tui.ts +++ b/packages/eve/src/cli/dev/tui/tui.ts @@ -37,6 +37,8 @@ export interface RunDevelopmentTuiInput extends TuiDisplayOptions { readonly initialInput?: string; /** Reports local CLI boot phases. Omitted for remote and programmatic TUI runs. */ readonly onBootProgress?: DevBootProgressReporter; + /** Gives setup subprocesses exclusive terminal and development-host ownership. */ + withExclusiveTerminal?: (task: () => Promise) => Promise; readonly lifecycle?: CommandLifecycle; } @@ -82,7 +84,15 @@ function prepareDevelopmentTarget(target: DevelopmentTuiTarget): PreparedDevelop * the inline error region rather than crashing the command. */ export async function runDevelopmentTui(input: RunDevelopmentTuiInput): Promise { - const { target, headers, initialInput, onBootProgress, lifecycle, ...display } = input; + const { + target, + headers, + initialInput, + onBootProgress, + lifecycle, + withExclusiveTerminal, + ...display + } = input; const prepared = prepareDevelopmentTarget(target); const { serverUrl } = target; const headerOptions = headers === undefined ? {} : { headers }; @@ -122,6 +132,7 @@ export async function runDevelopmentTui(input: RunDevelopmentTuiInput): Promise< if (initialInput !== undefined) options.initialInput = initialInput; if (onBootProgress !== undefined) options.onBootProgress = onBootProgress; if (lifecycle !== undefined) options.lifecycle = lifecycle; + if (withExclusiveTerminal !== undefined) options.withExclusiveTerminal = withExclusiveTerminal; const diagnostics = prepared.kind === "local" diff --git a/packages/eve/src/cli/option-parsers.ts b/packages/eve/src/cli/option-parsers.ts new file mode 100644 index 000000000..290ace447 --- /dev/null +++ b/packages/eve/src/cli/option-parsers.ts @@ -0,0 +1,68 @@ +import { InvalidArgumentError } from "#compiled/commander/index.js"; +import { LOG_DISPLAY_MODES, parseLogDisplayMode } from "#cli/dev/tui/log-display-mode.js"; +import type { + AssistantResponseStatsMode, + LogDisplayMode, + TerminalPartDisplayMode, +} from "#cli/dev/tui/types.js"; + +/** Parses a TCP port accepted by the CLI. */ +export function parsePortOption(value: string): number { + if (!/^-?\d+$/.test(value)) { + throw new InvalidArgumentError(`Expected a numeric port, received "${value}".`); + } + + const port = Number(value); + if (port < 0 || port > 65_535) { + throw new InvalidArgumentError(`Expected a port between 0 and 65535, received "${value}".`); + } + + return port; +} + +const DISPLAY_MODES = new Set(["full", "collapsed", "auto-collapsed", "hidden"]); +const STATS_MODES = new Set(["tokens", "tokensPerSecond"]); + +/** Parses a terminal part display mode. */ +export function parseDisplayMode(value: string): TerminalPartDisplayMode { + if (!DISPLAY_MODES.has(value)) { + throw new InvalidArgumentError( + `Expected one of ${[...DISPLAY_MODES].join(", ")}, received "${value}".`, + ); + } + + return value as TerminalPartDisplayMode; +} + +/** Parses an assistant response statistics mode. */ +export function parseStatsMode(value: string): AssistantResponseStatsMode { + if (!STATS_MODES.has(value)) { + throw new InvalidArgumentError( + `Expected one of ${[...STATS_MODES].join(", ")}, received "${value}".`, + ); + } + + return value as AssistantResponseStatsMode; +} + +/** Parses a server log display mode. */ +export function parseLogsMode(value: string): LogDisplayMode { + const mode = parseLogDisplayMode(value); + if (mode === undefined) { + throw new InvalidArgumentError( + `Expected one of ${LOG_DISPLAY_MODES.join(", ")}, received "${value}".`, + ); + } + + return mode; +} + +/** Parses a positive model context-window size. */ +export function parseContextSizeOption(value: string): number { + const size = Number(value); + if (!Number.isFinite(size) || size <= 0) { + throw new InvalidArgumentError(`Expected a positive number, received "${value}".`); + } + + return size; +} diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index 6a4285270..620accd40 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -27,8 +27,18 @@ import { registerRuntimeInvokeCommand, type InvokeCliRuntimeDependencies, } from "#cli/invoke/command.js"; -import { LOG_DISPLAY_MODES, parseLogDisplayMode } from "#cli/dev/tui/log-display-mode.js"; +import { + parseContextSizeOption, + parseDisplayMode, + parseLogsMode, + parsePortOption, + parseStatsMode, +} from "#cli/option-parsers.js"; import { resolveTuiTitle, type DevelopmentTuiTarget } from "#cli/dev/tui/target.js"; +import { + resumeDevelopmentRuntimeArtifacts, + suspendDevelopmentRuntimeArtifacts, +} from "#services/dev-client/runtime-artifacts.js"; import { parseDevelopmentServerUrl } from "#cli/dev/url.js"; import { startCliLiveRow } from "#cli/ui/live-row.js"; import { createCliTheme, renderCliTaggedLine } from "#cli/ui/output.js"; @@ -152,64 +162,6 @@ async function loadStartProductionHost(): Promise 65_535) { - throw new InvalidArgumentError(`Expected a port between 0 and 65535, received "${value}".`); - } - - return port; -} - -const DISPLAY_MODES = new Set(["full", "collapsed", "auto-collapsed", "hidden"]); -const STATS_MODES = new Set(["tokens", "tokensPerSecond"]); - -function parseDisplayMode(value: string): TerminalPartDisplayMode { - if (!DISPLAY_MODES.has(value)) { - throw new InvalidArgumentError( - `Expected one of ${[...DISPLAY_MODES].join(", ")}, received "${value}".`, - ); - } - - return value as TerminalPartDisplayMode; -} - -function parseStatsMode(value: string): AssistantResponseStatsMode { - if (!STATS_MODES.has(value)) { - throw new InvalidArgumentError( - `Expected one of ${[...STATS_MODES].join(", ")}, received "${value}".`, - ); - } - - return value as AssistantResponseStatsMode; -} - -function parseLogsMode(value: string): LogDisplayMode { - const mode = parseLogDisplayMode(value); - if (mode === undefined) { - throw new InvalidArgumentError( - `Expected one of ${LOG_DISPLAY_MODES.join(", ")}, received "${value}".`, - ); - } - - return mode; -} - -function parseContextSizeOption(value: string): number { - const size = Number(value); - - if (!Number.isFinite(size) || size <= 0) { - throw new InvalidArgumentError(`Expected a positive number, received "${value}".`); - } - - return size; -} - function hasInteractiveTerminal(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } @@ -450,13 +402,31 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm : { kind: "remote", serverUrl: input.serverUrl, workspaceRoot: appRoot }; const title = resolveTuiTitle({ name: options.name, target }); if (title !== undefined) display.name = title; - const tuiInput = { + const tuiInput: RunDevelopmentTuiInput = { target, initialInput: options.input, onBootProgress: report, lifecycle, ...display, - } satisfies RunDevelopmentTuiInput; + }; + if (target.kind === "local") { + tuiInput.withExclusiveTerminal = async (task: () => Promise): Promise => { + const run = async (): Promise => { + if (!(await suspendDevelopmentRuntimeArtifacts({ serverUrl: input.serverUrl }))) { + throw new Error("Could not pause the development server for integration setup."); + } + try { + return await task(); + } finally { + await resumeDevelopmentRuntimeArtifacts({ + serverUrl: input.serverUrl, + silent: true, + }); + } + }; + return await run(); + }; + } if (remoteTarget?.headers !== undefined) { await runDevelopmentTui({ ...tuiInput, headers: remoteTarget.headers }); } else { diff --git a/packages/eve/src/cli/shutdown.test.ts b/packages/eve/src/cli/shutdown.test.ts index a0f131386..f5870aa80 100644 --- a/packages/eve/src/cli/shutdown.test.ts +++ b/packages/eve/src/cli/shutdown.test.ts @@ -18,6 +18,26 @@ describe("installShutdownSignal", () => { expect(process.listenerCount("SIGTERM")).toBe(sigterm); }); + it("ignores terminal signals while a subprocess owns the terminal", async () => { + const lifecycle = installShutdownSignal(); + let settle!: () => void; + const lease = lifecycle.withTerminalLease!( + () => + new Promise((resolve) => { + settle = resolve; + }), + ); + + process.emit("SIGINT"); + settle(); + await lease; + + await expect(Promise.race([lifecycle.stopped, Promise.resolve("running")])).resolves.toBe( + "running", + ); + lifecycle.dispose(); + }); + it("removes listeners when an owned server stops itself", async () => { const sigint = process.listenerCount("SIGINT"); const sigterm = process.listenerCount("SIGTERM"); diff --git a/packages/eve/src/cli/shutdown.ts b/packages/eve/src/cli/shutdown.ts index fa4807373..068066670 100644 --- a/packages/eve/src/cli/shutdown.ts +++ b/packages/eve/src/cli/shutdown.ts @@ -9,6 +9,8 @@ export const FORCED_EXIT_BACKSTOP_MS = 900; export interface CommandLifecycle { readonly signal: AbortSignal; readonly stopped: Promise; + /** Temporarily gives SIGINT and SIGTERM to an inherited-stdio subprocess. */ + withTerminalLease?(task: () => Promise): Promise; requestStop(): void; dispose(): void; } @@ -24,6 +26,7 @@ export function installShutdownSignal( const stopped = Promise.withResolvers(); let stopRequested = false; let signalCount = 0; + let terminalLeaseDepth = 0; let backstop: NodeJS.Timeout | undefined; const stop = (signal?: NodeJS.Signals) => { if (stopRequested) return; @@ -39,6 +42,7 @@ export function installShutdownSignal( } }; const handleSignal = (signal: NodeJS.Signals) => { + if (terminalLeaseDepth > 0) return; signalCount += 1; if (signalCount > 1) process.exit(signal === "SIGINT" ? 130 : 143); stop(signal); @@ -50,6 +54,14 @@ export function installShutdownSignal( return { signal: controller.signal, stopped: stopped.promise, + async withTerminalLease(task: () => Promise): Promise { + terminalLeaseDepth += 1; + try { + return await task(); + } finally { + terminalLeaseDepth -= 1; + } + }, requestStop: () => stop(), dispose() { if (backstop !== undefined) clearTimeout(backstop); 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 cb3fc5889..5d4176cbe 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 @@ -101,6 +101,27 @@ describe("startAuthoredSourceWatcher", () => { } }); + it("rebuilds once after resuming a suspended watcher", async () => { + const host = createPreparedHost(); + const coordinator = createCoordinator(host); + const watcher = await startAuthoredSourceWatcher({ coordinator, preparedHost: host }); + + try { + await watcher.suspend(); + mockedWatcher.emit("change", join(host.appRoot, "package.json")); + await vi.advanceTimersByTimeAsync(200); + expect(coordinator.rebuild).not.toHaveBeenCalled(); + + await watcher.resume(); + expect(coordinator.rebuild).toHaveBeenCalledOnce(); + expect(coordinator.rebuild).toHaveBeenCalledWith({ + changedPaths: [join(host.appRoot, "package.json")], + }); + } finally { + await watcher.close(); + } + }); + it("ignores generated directories while watching authored roots", async () => { const host = createPreparedHost(); const watcher = await startAuthoredSourceWatcher({ 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 f89032c61..16dc0f551 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 @@ -44,6 +44,8 @@ export interface AuthoredSourceWatcherHandle { close(): Promise; flush(): Promise; rebuild(): Promise; + suspend(): Promise; + resume(options?: { silent?: boolean }): Promise; } /** @@ -61,6 +63,7 @@ export async function startAuthoredSourceWatcher(input: { let queue: Promise = Promise.resolve(); let debounceTimer: NodeJS.Timeout | undefined; let isWatcherReady = false; + let suspensionCount = 0; const pendingEvents = new Map(); const pendingChangedPaths = new Set(); const initialWatchPaths = await resolveAuthoredWatchPaths(currentHost); @@ -76,8 +79,8 @@ export async function startAuthoredSourceWatcher(input: { }); const watcherReady = waitForWatcherReady(watcher); - const rebuild = async (force: boolean) => { - if (closed) { + const rebuild = async (force: boolean, silent = false) => { + if (closed || suspensionCount > 0) { return; } @@ -96,7 +99,7 @@ export async function startAuthoredSourceWatcher(input: { pendingEvents.clear(); pendingChangedPaths.clear(); const previousHost = currentHost; - if (changeEvents.length > 0) { + if (!silent && changeEvents.length > 0) { console.log(formatChangeDetectedLogLine(previousHost.appRoot, changeEvents)); } @@ -104,10 +107,12 @@ export async function startAuthoredSourceWatcher(input: { const result = await input.coordinator.rebuild({ changedPaths }); currentHost = result.host; - if (result.kind === "structural") { - console.log(STRUCTURAL_RELOAD_LOG_LINE); - } else { - console.log(AUTHORED_ARTIFACTS_UPDATED_LOG_LINE); + if (!silent) { + if (result.kind === "structural") { + console.log(STRUCTURAL_RELOAD_LOG_LINE); + } else { + console.log(AUTHORED_ARTIFACTS_UPDATED_LOG_LINE); + } } const nextWatchPaths = await resolveAuthoredWatchPaths(currentHost); @@ -132,12 +137,12 @@ export async function startAuthoredSourceWatcher(input: { } await rebuild(false); }; - const forceRebuild = async () => { + const forceRebuild = async (silent = false) => { if (debounceTimer !== undefined) { clearTimeout(debounceTimer); debounceTimer = undefined; } - await rebuild(true); + await rebuild(true, silent); }; watcher.on("all", (event, changedPath) => { if (closed || !isWatcherReady || event === "addDir" || event === "unlinkDir") { @@ -172,6 +177,19 @@ export async function startAuthoredSourceWatcher(input: { }, flush, rebuild: forceRebuild, + async suspend() { + suspensionCount += 1; + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer); + debounceTimer = undefined; + } + await queue; + }, + async resume(options) { + if (suspensionCount === 0) return; + suspensionCount -= 1; + if (suspensionCount === 0) await forceRebuild(options?.silent); + }, }; } 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 6e574da88..8c2b2541f 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 @@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => { close: vi.fn(async () => undefined), flush: vi.fn(async () => undefined), rebuild: vi.fn(async () => undefined), + resume: vi.fn(async () => undefined), + suspend: vi.fn(async () => undefined), }; const mocksWorldInstance = { close: vi.fn(async () => undefined), 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 17b565b43..f8a507a60 100644 --- a/packages/eve/src/internal/nitro/host/start-development-server.ts +++ b/packages/eve/src/internal/nitro/host/start-development-server.ts @@ -12,7 +12,9 @@ import { removeDevelopmentHostWorkspace } from "#internal/nitro/host/dev-host-wo import { createDevelopmentAuthoredRebuildCoordinator } from "#internal/nitro/host/dev-authored-rebuild-coordinator.js"; import { EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH, + EVE_DEV_RUNTIME_ARTIFACTS_RESUME_ROUTE_PATH, EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH, + EVE_DEV_RUNTIME_ARTIFACTS_SUSPEND_ROUTE_PATH, } from "#protocol/routes.js"; import { resolveDiscoveryProject } from "#discover/project.js"; import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; @@ -206,13 +208,27 @@ function addDevelopmentControlHandler(input: { if (url.pathname === EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH && request.method === "GET") { return handleDevRuntimeArtifactsRequest({ appRoot: input.appRoot }); } - if (url.pathname !== EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH || request.method !== "GET") { - return undefined; - } const watcher = input.getWatcher(); if (watcher === undefined) { return Response.json({ error: "The development server is still starting." }, { status: 503 }); } + if ( + url.pathname === EVE_DEV_RUNTIME_ARTIFACTS_SUSPEND_ROUTE_PATH && + request.method === "POST" + ) { + await watcher.suspend(); + return Response.json({ suspended: true }); + } + if (url.pathname === EVE_DEV_RUNTIME_ARTIFACTS_RESUME_ROUTE_PATH && request.method === "POST") { + await watcher.resume({ silent: url.searchParams.get("silent") === "1" }); + return handleDevRuntimeArtifactsRequest({ appRoot: input.appRoot }); + } + if ( + url.pathname !== EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH || + (request.method !== "GET" && request.method !== "POST") + ) { + return undefined; + } if (url.searchParams.get("force") === "1") { await watcher.rebuild(); } else { diff --git a/packages/eve/src/protocol/routes.ts b/packages/eve/src/protocol/routes.ts index d1f403765..e93d85fd3 100644 --- a/packages/eve/src/protocol/routes.ts +++ b/packages/eve/src/protocol/routes.ts @@ -76,6 +76,12 @@ export const EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH = `${EVE_ROUTE_PREFIX}/dev/run */ export const EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH = `${EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH}/rebuild`; +/** Dev-only route that pauses authored-source rebuilding while a setup subprocess owns the terminal. */ +export const EVE_DEV_RUNTIME_ARTIFACTS_SUSPEND_ROUTE_PATH = `${EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH}/suspend`; + +/** Dev-only route that resumes authored-source rebuilding after setup subprocess completion. */ +export const EVE_DEV_RUNTIME_ARTIFACTS_RESUME_ROUTE_PATH = `${EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH}/resume`; + /** * Builds the dev-only schedule dispatch URL for one named authored * schedule. The path encodes the schedule id so reserved characters in 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..95ee8a4b6 100644 --- a/packages/eve/src/services/dev-client/runtime-artifacts.test.ts +++ b/packages/eve/src/services/dev-client/runtime-artifacts.test.ts @@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { readDevelopmentRuntimeArtifactsRevision, rebuildDevelopmentRuntimeArtifacts, + resumeDevelopmentRuntimeArtifacts, + suspendDevelopmentRuntimeArtifacts, } from "./runtime-artifacts.js"; const SERVER_URL = "http://127.0.0.1:3000"; @@ -47,6 +49,29 @@ describe("readDevelopmentRuntimeArtifactsRevision", () => { }); }); +describe("development runtime artifact suspension", () => { + it("posts to suspend and resume routes", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ revision: "rev-3" }))); + vi.stubGlobal("fetch", fetchMock); + + await expect(suspendDevelopmentRuntimeArtifacts({ serverUrl: SERVER_URL })).resolves.toBe(true); + await expect(resumeDevelopmentRuntimeArtifacts({ serverUrl: SERVER_URL })).resolves.toBe( + "rev-3", + ); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + new URL("/eve/v1/dev/runtime-artifacts/suspend", SERVER_URL), + expect.objectContaining({ method: "POST" }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + new URL("/eve/v1/dev/runtime-artifacts/resume", SERVER_URL), + expect.objectContaining({ method: "POST" }), + ); + }); +}); + describe("rebuildDevelopmentRuntimeArtifacts", () => { it("posts to the rebuild route and returns the revision", async () => { const fetchMock = vi.fn(async () => new Response(JSON.stringify({ revision: "rev-2" }))); diff --git a/packages/eve/src/services/dev-client/runtime-artifacts.ts b/packages/eve/src/services/dev-client/runtime-artifacts.ts index 53c3c2e5e..5ff93c703 100644 --- a/packages/eve/src/services/dev-client/runtime-artifacts.ts +++ b/packages/eve/src/services/dev-client/runtime-artifacts.ts @@ -1,6 +1,8 @@ import { EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH, + EVE_DEV_RUNTIME_ARTIFACTS_RESUME_ROUTE_PATH, EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH, + EVE_DEV_RUNTIME_ARTIFACTS_SUSPEND_ROUTE_PATH, } from "#protocol/routes.js"; /** @@ -25,6 +27,41 @@ export async function readDevelopmentRuntimeArtifactsRevision(input: { } } +export async function suspendDevelopmentRuntimeArtifacts(input: { + readonly serverUrl: string; +}): Promise { + return await changeDevelopmentRuntimeArtifacts( + input.serverUrl, + EVE_DEV_RUNTIME_ARTIFACTS_SUSPEND_ROUTE_PATH, + ); +} + +export async function resumeDevelopmentRuntimeArtifacts(input: { + readonly serverUrl: string; + readonly silent?: boolean; +}): Promise { + try { + const url = new URL(EVE_DEV_RUNTIME_ARTIFACTS_RESUME_ROUTE_PATH, input.serverUrl); + if (input.silent === true) url.searchParams.set("silent", "1"); + const response = await fetch(url, { method: "POST" }); + return await parseDevelopmentRuntimeArtifactsRevision(response); + } catch { + return undefined; + } +} + +async function changeDevelopmentRuntimeArtifacts( + serverUrl: string, + path: string, +): Promise { + try { + const response = await fetch(new URL(path, serverUrl), { method: "POST" }); + return response.ok; + } catch { + return false; + } +} + export async function rebuildDevelopmentRuntimeArtifacts(input: { readonly force?: boolean; readonly serverUrl: string; diff --git a/packages/eve/src/setup/boxes/add-channels.test.ts b/packages/eve/src/setup/boxes/add-channels.test.ts index 520a00e80..b1288e36a 100644 --- a/packages/eve/src/setup/boxes/add-channels.test.ts +++ b/packages/eve/src/setup/boxes/add-channels.test.ts @@ -374,8 +374,10 @@ describe("addChannels box", () => { const state = resolvedState(["slack"]); state.project = { kind: "unresolved" }; state.vercelProject = { kind: "none" }; + const prompter = createPrompter(); + prompter.withInheritedStdio = vi.fn((task) => task()); const box = makeBox({ - prompter: createPrompter(), + prompter, presetCreateSlackbot: true, ensureLinkedProject: "interactive-vercel-link", deps, @@ -385,6 +387,7 @@ describe("addChannels box", () => { // The engine's exact fallback: a bare interactive `vercel link` with NO // onOutput, then a fresh deployment detection. + expect(prompter.withInheritedStdio).toHaveBeenCalledOnce(); expect(deps.runVercel).toHaveBeenCalledWith(["link"], { cwd: "/tmp/project" }); expect(deps.runVercel.mock.invocationCallOrder[0]).toBeLessThan( deps.provisionSlackbot.mock.invocationCallOrder[0]!, diff --git a/packages/eve/src/setup/boxes/add-channels.ts b/packages/eve/src/setup/boxes/add-channels.ts index ba5638392..da01a1a2f 100644 --- a/packages/eve/src/setup/boxes/add-channels.ts +++ b/packages/eve/src/setup/boxes/add-channels.ts @@ -11,6 +11,7 @@ import { createPromptCommandOutput, withPhase, type ChannelSetupLog } from "#set import { detectPackageManager, type PackageManagerKind } from "#setup/package-manager.js"; import { formatNodeEngineOverrideWarning } from "#setup/node-engine.js"; import { runPackageManagerInstall } from "#setup/primitives/pm/run.js"; +import { ensureVercelProject } from "#setup/flows/ensure-vercel-project.js"; import { runVercel } from "#setup/primitives/run-vercel.js"; import { @@ -128,6 +129,8 @@ export interface AddChannelsDeps { reconcileSlackUid: typeof reconcileSlackUid; detectPackageManager: typeof detectPackageManager; runPackageManagerInstall: typeof runPackageManagerInstall; + /** Parent-rendered project flow; legacy test seams may omit it. */ + ensureVercelProject?: typeof ensureVercelProject; runVercel: typeof runVercel; detectDeployment: typeof detectDeployment; } @@ -261,6 +264,7 @@ export function addChannels( reconcileSlackUid, detectPackageManager, runPackageManagerInstall, + ensureVercelProject, runVercel, detectDeployment, }; @@ -318,16 +322,30 @@ export function addChannels( reason: "Slackbot creation needs this directory linked to a Vercel project.", }); } - // No onOutput: `vercel link` (without --project) is interactive, so it must - // own the terminal. Piping its prompt through the rail renderer line-buffers - // the unterminated question and deadlocks the CLI waiting on hidden input. log.message("Linking this directory to a Vercel project..."); - if (!(await deps.runVercel(["link"], { cwd: projectPath, signal }))) { - signal?.throwIfAborted(); - throw new Error("Vercel project linking failed. Slackbot creation did not start."); + let project: ProjectResolution; + if (deps.ensureVercelProject !== undefined) { + const linked = await deps.ensureVercelProject({ + appRoot: projectPath, + prompter: options.prompter, + signal, + }); + project = mergeProjectResolution(current, { kind: "linked", projectId: linked.projectId }); + } else if (deps.runVercel !== runVercel) { + const link = () => deps.runVercel(["link"], { cwd: projectPath, signal }); + const linked = await (options.prompter.withInheritedStdio?.(link) ?? link()); + if (!linked) + throw new Error("Vercel project linking failed. Slackbot creation did not start."); + const deployment = await deps.detectDeployment(projectPath, { signal }); + project = mergeProjectResolution(current, projectResolutionFromDeployment(deployment)); + } else { + const linked = await ensureVercelProject({ + appRoot: projectPath, + prompter: options.prompter, + signal, + }); + project = mergeProjectResolution(current, { kind: "linked", projectId: linked.projectId }); } - const deployment = await deps.detectDeployment(projectPath, { signal }); - const project = mergeProjectResolution(current, projectResolutionFromDeployment(deployment)); if (!isProjectResolved(project)) { throw new Error("Vercel project linking failed. Slackbot creation did not start."); } diff --git a/packages/eve/src/setup/flows/ensure-vercel-project.ts b/packages/eve/src/setup/flows/ensure-vercel-project.ts new file mode 100644 index 000000000..9a5c25b75 --- /dev/null +++ b/packages/eve/src/setup/flows/ensure-vercel-project.ts @@ -0,0 +1,58 @@ +import { interactiveAsker, withAnswers } from "../ask.js"; +import { linkVercelProject, type LinkProjectDeps } from "../boxes/link-project.js"; +import { + resolveProvisioning, + type ResolveProvisioningDeps, +} from "../boxes/resolve-provisioning.js"; +import type { Prompter } from "../prompter.js"; +import { readProjectLink, type VercelProjectReference } from "../project-resolution.js"; +import { runInteractive, type AnySetupBox } from "../runner.js"; +import { snapshotSetupState, type SetupState } from "../state.js"; +import { WizardCancelledError } from "../step.js"; +import { inProjectSetupState, prompterSink } from "./in-project.js"; + +export interface EnsureVercelProjectDeps { + resolveProvisioning?: ResolveProvisioningDeps; + linkProject?: LinkProjectDeps; + readProjectLink: typeof readProjectLink; +} + +/** Ensures a project link using eve-owned prompts and a non-interactive Vercel command. */ +export async function ensureVercelProject(input: { + appRoot: string; + prompter: Prompter; + signal?: AbortSignal; + teamSelectMessage?: (currentTeam: string) => string; + deps?: Partial; +}): Promise { + const readLink = input.deps?.readProjectLink ?? readProjectLink; + const existing = await readLink(input.appRoot); + if (existing !== undefined) return existing; + + const state = inProjectSetupState(input.appRoot, { kind: "unresolved" }); + const boxes: AnySetupBox[] = [ + resolveProvisioning({ + asker: withAnswers({ deploy: "vercel" })(interactiveAsker(input.prompter)), + prompter: input.prompter, + targetDirectory: input.appRoot, + mode: { headless: false }, + adoptExistingLink: false, + projectSelection: "create-or-link", + teamSelectMessage: input.teamSelectMessage, + deps: input.deps?.resolveProvisioning, + }), + linkVercelProject({ prompter: input.prompter, deps: input.deps?.linkProject }), + ]; + const result = await runInteractive(boxes, state, prompterSink(input.prompter), { + snapshot: snapshotSetupState, + signal: input.signal, + }); + if (result.kind === "cancelled") { + input.signal?.throwIfAborted(); + throw new WizardCancelledError(); + } + + const linked = await readLink(input.appRoot); + if (linked === undefined) throw new Error("Vercel project linking failed."); + return linked; +} diff --git a/packages/eve/src/setup/flows/registry.test.ts b/packages/eve/src/setup/flows/registry.test.ts new file mode 100644 index 000000000..0b37f4a34 --- /dev/null +++ b/packages/eve/src/setup/flows/registry.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; + +import { runRegistryFlow, type RegistryFlowDeps } from "./registry.js"; + +const APP_ROOT = "/tmp/agent"; + +function deps(overrides: Partial = {}): RegistryFlowDeps { + return { + browseRegistryCatalog: vi.fn(async () => ({ + items: [ + { + address: "extension/agent-browser", + name: "extension/agent-browser", + type: "registry:item", + description: "Browser automation", + source: "Vercel", + }, + ], + total: 1, + errors: [], + })), + getRegistryItemManifest: vi.fn(async () => ({ + name: "extension/agent-browser", + title: "agent-browser", + description: "Browser automation", + dependencies: ["@agent-browser/eve"], + envVars: { AGENT_BROWSER_TOKEN: "" }, + files: [{ target: "agent/extensions/browser.ts" }], + })), + installRegistryItem: vi.fn(async () => []), + ...overrides, + }; +} + +describe("runRegistryFlow", () => { + it("browses a category, shows an item's manifest details, and exits after installing", async () => { + const answers = ["category:extension", "item:0", "add"]; + const prompts: unknown[] = []; + const fake = createFakePrompter({ + single: (options) => { + prompts.push(options); + return answers.shift()!; + }, + }); + const flowDeps = deps(); + + await expect( + runRegistryFlow({ appRoot: APP_ROOT, prompter: fake.prompter, deps: flowDeps }), + ).resolves.toEqual({ kind: "done", addedItems: ["extension/agent-browser"] }); + expect(flowDeps.installRegistryItem).toHaveBeenCalledWith( + APP_ROOT, + "extension/agent-browser", + expect.objectContaining({ silent: true, prompter: fake.prompter }), + ); + expect(prompts[0]).toMatchObject({ + message: "", + hintLayout: "inline", + options: expect.arrayContaining([ + expect.objectContaining({ value: "category:channel", label: "Chat channels" }), + expect.objectContaining({ value: "category:connection", label: "Tools & data" }), + expect.objectContaining({ value: "category:extension", label: "Capabilities" }), + expect.objectContaining({ + value: "category:instrumentation", + label: "Observability", + }), + expect.objectContaining({ value: "category:all", label: "All integrations" }), + expect.objectContaining({ value: "action:done", label: "Back to chat" }), + ]), + }); + expect(prompts[1]).toMatchObject({ + message: "Browse registry integrations", + options: [ + expect.objectContaining({ + label: "Agent Browser", + hint: "Browser automation", + }), + expect.objectContaining({ value: "action:back", label: "Back" }), + ], + }); + expect(prompts[2]).toMatchObject({ + message: "agent-browser", + description: "Browser automation", + metadata: [ + { label: "Source", value: "Vercel" }, + { label: "Packages", value: "@agent-browser/eve" }, + { label: "Environment", value: "AGENT_BROWSER_TOKEN" }, + { label: "Files", value: "agent/extensions/browser.ts" }, + ], + options: [ + { value: "add", label: "Add to project" }, + { value: "back", label: "Back" }, + ], + }); + }); + + it("keeps setup on the parent prompter without leasing the terminal", async () => { + const answers = ["category:channel", "item:0", "add"]; + const fake = createFakePrompter({ single: () => answers.shift()! }); + const inherited = vi.fn(async (task: () => Promise): Promise => task()); + fake.prompter.withInheritedStdio = (task: () => Promise): Promise => + inherited(task) as Promise; + fake.prompter.withExclusiveTerminal = (task: () => Promise): Promise => task(); + const flowDeps = deps({ + browseRegistryCatalog: vi.fn(async () => ({ + items: [{ address: "channel/slack", name: "channel/slack", source: "Vercel" }], + total: 1, + errors: [], + })), + }); + + await runRegistryFlow({ appRoot: APP_ROOT, prompter: fake.prompter, deps: flowDeps }); + + expect(inherited).not.toHaveBeenCalled(); + expect(flowDeps.installRegistryItem).toHaveBeenCalledWith( + APP_ROOT, + "channel/slack", + expect.objectContaining({ prompter: fake.prompter }), + ); + }); + + it("summarizes long package, environment, and file lists", async () => { + const answers = ["category:channel", "item:0", "add"]; + const prompts: unknown[] = []; + const fake = createFakePrompter({ + single: (options) => { + prompts.push(options); + return answers.shift()!; + }, + }); + const flowDeps = deps({ + browseRegistryCatalog: vi.fn(async () => ({ + items: [ + { + address: "channel/web", + name: "channel/web", + source: "Vercel", + }, + ], + total: 1, + errors: [], + })), + getRegistryItemManifest: vi.fn(async () => ({ + dependencies: ["next", "react", "react-dom", "streamdown", "tailwindcss"], + envVars: { FIRST: "", SECOND: "", THIRD: "", FOURTH: "" }, + files: ["one", "two", "three", "four", "five"].map((target) => ({ target })), + })), + }); + + await runRegistryFlow({ appRoot: APP_ROOT, prompter: fake.prompter, deps: flowDeps }); + + expect(prompts[2]).toMatchObject({ + metadata: [ + { label: "Source", value: "Vercel" }, + { label: "Packages", value: "next, react, react-dom … (+2 more)" }, + { label: "Environment", value: "FIRST, SECOND, THIRD … (+1 more)" }, + { label: "Files", value: "one, two, three … (+2 more)" }, + ], + }); + }); + + it("omits external registry sources from result rows", async () => { + const answers = ["category:extension", "action:back", "action:done"]; + const prompts: unknown[] = []; + const fake = createFakePrompter({ + single: (options) => { + prompts.push(options); + return answers.shift()!; + }, + }); + const flowDeps = deps({ + browseRegistryCatalog: vi.fn(async () => ({ + items: [ + { + address: "@acme/analytics", + name: "extension/analytics", + description: "Product analytics", + source: "@acme", + }, + ], + total: 1, + errors: [], + })), + }); + + await runRegistryFlow({ appRoot: APP_ROOT, prompter: fake.prompter, deps: flowDeps }); + + expect(prompts[1]).toMatchObject({ + options: [ + expect.objectContaining({ + label: "Analytics", + hint: "Product analytics", + }), + expect.objectContaining({ value: "action:back" }), + ], + }); + }); + + it("resolves a direct address before offering installation", async () => { + const answers = ["category:all", "address:@acme/analytics", "add"]; + const prompts: unknown[] = []; + const fake = createFakePrompter({ + single: (options) => { + prompts.push(options); + return answers.shift()!; + }, + }); + const flowDeps = deps({ + getRegistryItemManifest: vi.fn(async () => ({ + name: "analytics", + description: "Analytics integration", + })), + }); + + await runRegistryFlow({ appRoot: APP_ROOT, prompter: fake.prompter, deps: flowDeps }); + + expect(prompts[1]).toMatchObject({ + placeholder: "Search or enter an item address", + searchAction: { label: expect.any(Function), value: expect.any(Function) }, + }); + const searchAction = (prompts[1] as { searchAction: { label(query: string): string } }) + .searchAction; + expect(searchAction.label("@acme/analytics")).toBe("Add “@acme/analytics”"); + expect(flowDeps.getRegistryItemManifest).toHaveBeenCalledWith(APP_ROOT, "@acme/analytics"); + expect(flowDeps.installRegistryItem).toHaveBeenCalledWith( + APP_ROOT, + "@acme/analytics", + expect.objectContaining({ silent: true, prompter: fake.prompter }), + ); + }); + + it("returns to the category hub from a registry list", async () => { + const answers = ["category:channel", "action:back", "action:done"]; + const fake = createFakePrompter({ single: () => answers.shift()! }); + + await expect( + runRegistryFlow({ appRoot: APP_ROOT, prompter: fake.prompter, deps: deps() }), + ).resolves.toEqual({ kind: "done", addedItems: [] }); + + expect(fake.selectMessages).toEqual(["", "Browse registry integrations", ""]); + }); +}); diff --git a/packages/eve/src/setup/flows/registry.ts b/packages/eve/src/setup/flows/registry.ts new file mode 100644 index 000000000..cdcaec5a3 --- /dev/null +++ b/packages/eve/src/setup/flows/registry.ts @@ -0,0 +1,322 @@ +import type { RegistryCatalogItem } from "#cli/commands/registry.js"; +import type { + Prompter, + SelectMetadata, + SelectNotice, + SelectOption, + SingleSelectOptions, +} from "#setup/prompter.js"; +import { WizardCancelledError } from "#setup/step.js"; +import { withSpinner } from "#setup/with-spinner.js"; + +const ADDRESS_PREFIX = "address:"; +const BACK = "action:back"; +const DONE = "action:done"; +const ALL = "category:all"; + +type RegistryRow = string; +type RegistryCategory = "channel" | "connection" | "extension" | "instrumentation"; + +const REGISTRY_CATEGORIES: ReadonlyArray<{ + value: `category:${RegistryCategory}`; + prefix: `${RegistryCategory}/`; + label: string; + hint: string; +}> = [ + { + value: "category:channel", + prefix: "channel/", + label: "Chat channels", + hint: "Web, Slack, Discord, Teams", + }, + { + value: "category:connection", + prefix: "connection/", + label: "Tools & data", + hint: "Linear, Notion, GitHub, Vercel", + }, + { + value: "category:extension", + prefix: "extension/", + label: "Capabilities", + hint: "Browser automation, memory, developer tools", + }, + { + value: "category:instrumentation", + prefix: "instrumentation/", + label: "Observability", + hint: "Sentry, Braintrust, Honeycomb", + }, +]; + +export interface RegistryFlowDeps { + browseRegistryCatalog: (typeof import("#cli/commands/registry.js"))["browseRegistryCatalog"]; + getRegistryItemManifest: (typeof import("#cli/commands/registry.js"))["getRegistryItemManifest"]; + installRegistryItem: (typeof import("#cli/commands/registry.js"))["installRegistryItem"]; +} + +export type RegistryFlowResult = + | { kind: "done"; addedItems: readonly string[]; output?: readonly string[] } + | { kind: "cancelled" }; + +function itemLabel(item: RegistryCatalogItem): string { + const name = item.name.split("/").at(-1) ?? item.name; + return name + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function itemRows(items: readonly RegistryCatalogItem[]): SelectOption[] { + return items.map((item, index) => ({ + value: `item:${index}`, + label: itemLabel(item), + hint: item.description ?? item.type, + })); +} + +function categoryRows(): SelectOption[] { + return [ + ...REGISTRY_CATEGORIES.map((category) => ({ + value: category.value, + label: category.label, + hint: category.hint, + })), + { + value: ALL, + label: "All integrations", + hint: "Search by name or enter an item address", + }, + { value: DONE, label: "Back to chat", trailingAction: true }, + ]; +} + +function itemsForCategory( + items: readonly RegistryCatalogItem[], + selectedCategory: RegistryRow, +): readonly RegistryCatalogItem[] { + if (selectedCategory === ALL) return items; + const category = REGISTRY_CATEGORIES.find((entry) => entry.value === selectedCategory); + return category === undefined + ? items + : items.filter( + (item) => item.address.startsWith(category.prefix) || item.name.startsWith(category.prefix), + ); +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function itemSource(address: string): string { + if (address.startsWith("@")) return address.split("/")[0] ?? address; + if (/^https?:\/\//u.test(address)) { + try { + return new URL(address).host; + } catch { + return address; + } + } + return "Vercel"; +} + +function manifestRecord(manifest: unknown): Record { + return typeof manifest === "object" && manifest !== null && !Array.isArray(manifest) + ? (manifest as Record) + : {}; +} + +function summarizeDetails(values: readonly string[], limit = 3): string { + const visible = values.slice(0, limit); + const remaining = values.length - visible.length; + return remaining > 0 ? `${visible.join(", ")} … (+${remaining} more)` : visible.join(", "); +} + +function itemDetails( + item: RegistryCatalogItem, + manifest: Record, +): SelectMetadata[] { + const details: SelectMetadata[] = [{ label: "Source", value: item.source }]; + const dependencies = [ + ...stringArray(manifest.dependencies), + ...stringArray(manifest.devDependencies), + ...stringArray(manifest.registryDependencies), + ]; + if (dependencies.length > 0) + details.push({ label: "Packages", value: summarizeDetails(dependencies) }); + const envVars = manifest.envVars; + if (typeof envVars === "object" && envVars !== null && !Array.isArray(envVars)) { + const names = Object.keys(envVars); + if (names.length > 0) details.push({ label: "Environment", value: summarizeDetails(names) }); + } + const files = Array.isArray(manifest.files) ? manifest.files : []; + const targets = files.flatMap((file) => { + if (typeof file !== "object" || file === null || Array.isArray(file)) return []; + const target = (file as Record).target; + return typeof target === "string" ? [target] : []; + }); + if (targets.length > 0) details.push({ label: "Files", value: summarizeDetails(targets) }); + return details; +} + +async function inspectItem( + prompter: Prompter, + deps: RegistryFlowDeps, + appRoot: string, + item: RegistryCatalogItem, + resolvedManifest?: Record, + signal?: AbortSignal, +): Promise<{ kind: "added"; output: readonly string[] } | { kind: "back" }> { + const manifest = + resolvedManifest ?? + manifestRecord( + await withSpinner(prompter, "Loading registry item…", () => + deps.getRegistryItemManifest(appRoot, item.address), + ), + ); + while (true) { + const request: SingleSelectOptions = { + message: typeof manifest.title === "string" ? manifest.title : item.name, + metadata: itemDetails(item, manifest), + options: [ + { value: "add", label: "Add to project" }, + { value: "back", label: "Back" }, + ], + }; + const description = + typeof manifest.description === "string" ? manifest.description : item.description; + if (description !== undefined) request.description = description; + const action = await prompter.select(request); + if (action === "back") return { kind: "back" }; + const spinner = prompter.log.spinner?.(`Installing ${itemLabel(item)} and dependencies…`); + try { + spinner?.stop(); + const install = () => + deps.installRegistryItem(appRoot, item.address, { + silent: true, + prompter, + signal, + }); + const output = await (prompter.withExclusiveTerminal?.(install) ?? install()); + return { kind: "added", output }; + } finally { + spinner?.stop(); + } + } +} + +async function resolveAddressItem( + prompter: Prompter, + deps: RegistryFlowDeps, + appRoot: string, + address: string, +): Promise<{ item: RegistryCatalogItem; manifest: Record }> { + const manifest = await withSpinner(prompter, "Loading registry item…", () => + deps.getRegistryItemManifest(appRoot, address), + ); + const record = manifestRecord(manifest); + const item: RegistryCatalogItem = { + address, + name: typeof record.name === "string" ? record.name : address, + source: itemSource(address), + }; + if (typeof record.type === "string") item.type = record.type; + if (typeof record.description === "string") item.description = record.description; + return { item, manifest: record }; +} + +/** Runs the categorized interactive registry catalog used by the dev TUI's `/add`. */ +export async function runRegistryFlow(input: { + appRoot: string; + prompter: Prompter; + signal?: AbortSignal; + deps?: Partial; +}): Promise { + let loaded: typeof import("#cli/commands/registry.js") | undefined; + if ( + input.deps?.browseRegistryCatalog === undefined || + input.deps.getRegistryItemManifest === undefined || + input.deps.installRegistryItem === undefined + ) { + loaded = await import("#cli/commands/registry.js"); + } + const deps: RegistryFlowDeps = { + browseRegistryCatalog: input.deps?.browseRegistryCatalog ?? loaded!.browseRegistryCatalog, + getRegistryItemManifest: input.deps?.getRegistryItemManifest ?? loaded!.getRegistryItemManifest, + installRegistryItem: input.deps?.installRegistryItem ?? loaded!.installRegistryItem, + }; + let notices: SelectNotice[] = []; + + try { + while (true) { + input.signal?.throwIfAborted(); + const catalog = await withSpinner(input.prompter, "Loading registry…", () => + deps.browseRegistryCatalog(input.appRoot), + ); + notices = [ + ...notices, + ...catalog.errors.map((error) => ({ + tone: "warning" as const, + text: `${error.registry}: ${error.message}`, + })), + ]; + const selectedCategory = await input.prompter.select({ + message: "", + options: categoryRows(), + hintLayout: "inline", + notices, + }); + notices = []; + if (selectedCategory === DONE) return { kind: "done", addedItems: [] }; + + const categoryItems = itemsForCategory(catalog.items, selectedCategory); + const rows = itemRows(categoryItems); + rows.push({ value: BACK, label: "Back", trailingAction: true }); + const selected = await input.prompter.select({ + message: "Browse registry integrations", + options: rows, + search: true, + placeholder: "Search or enter an item address", + searchAction: { + label: (query) => `Add “${query}”`, + value: (query) => `${ADDRESS_PREFIX}${query.trim()}`, + }, + hintLayout: "inline", + }); + if (selected === BACK) continue; + const resolved = selected.startsWith(ADDRESS_PREFIX) + ? await resolveAddressItem( + input.prompter, + deps, + input.appRoot, + selected.slice(ADDRESS_PREFIX.length), + ) + : { item: categoryItems[Number(selected.slice("item:".length))] }; + if (resolved.item === undefined) { + throw new Error("The selected registry item is no longer available."); + } + const inspected = await inspectItem( + input.prompter, + deps, + input.appRoot, + resolved.item, + "manifest" in resolved ? resolved.manifest : undefined, + input.signal, + ); + if (inspected.kind === "added") { + return inspected.output.length === 0 + ? { kind: "done", addedItems: [resolved.item.address] } + : { + kind: "done", + addedItems: [resolved.item.address], + output: inspected.output, + }; + } + } + } catch (error) { + if (error instanceof WizardCancelledError) return { kind: "cancelled" }; + throw error; + } +} diff --git a/packages/eve/src/setup/primitives/run-vercel.ts b/packages/eve/src/setup/primitives/run-vercel.ts index c34011799..ef7c4b932 100644 --- a/packages/eve/src/setup/primitives/run-vercel.ts +++ b/packages/eve/src/setup/primitives/run-vercel.ts @@ -247,6 +247,7 @@ export async function runVercel(args: string[], options: RunVercelOptions): Prom export interface RunVercelCaptureResult { ok: boolean; stdout: string; + stderr?: string; } /** @@ -262,7 +263,7 @@ export async function runVercelCaptureStdout( args: string[], options: RunVercelOptions, ): Promise { - if (options.signal?.aborted === true) return { ok: false, stdout: "" }; + if (options.signal?.aborted === true) return { ok: false, stdout: "", stderr: "" }; return new Promise((resolvePromise) => { const cwd = existingDir(options.cwd); const invocation = resolveVercelInvocation(cwd, commandArgs(args, options.nonInteractive)); @@ -280,15 +281,21 @@ export async function runVercelCaptureStdout( }); const disarmAbort = armProcessAbort(child, options.signal); const chunks: string[] = []; + const errorChunks: string[] = []; child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk.toString("utf8"))); - child.stderr?.on("data", (chunk: Buffer) => outputBuffer?.write("stderr", chunk)); + child.stderr?.on("data", (chunk: Buffer) => { + errorChunks.push(chunk.toString("utf8")); + outputBuffer?.write("stderr", chunk); + }); let settled = false; function settle(ok: boolean): void { if (settled) return; settled = true; outputBuffer?.flush(); - resolvePromise({ ok, stdout: chunks.join("") }); + const stdout = chunks.join(""); + const stderr = errorChunks.join(""); + resolvePromise(stderr.length === 0 ? { ok, stdout } : { ok, stdout, stderr }); } function reportFailure(message: string): void { if (options.onOutput) { diff --git a/packages/eve/src/setup/prompter.ts b/packages/eve/src/setup/prompter.ts index 4bf9d5a01..8e537ccac 100644 --- a/packages/eve/src/setup/prompter.ts +++ b/packages/eve/src/setup/prompter.ts @@ -11,6 +11,7 @@ import { runSelectComponent, type ChannelSetupAwaitChoice, type PromptState, + type SetupSpinnerIntent, } from "#setup/cli/index.js"; import { createRailLog, type RailSpinner } from "#setup/cli/index.js"; import pc from "picocolors"; @@ -89,9 +90,19 @@ export interface SelectNotice { text: string; } +/** One labeled fact rendered between a select question's description and options. */ +export interface SelectMetadata { + label: string; + value: string; +} + /** Options common to every {@link Prompter.select} call. */ export interface SelectCommonOptions { message: string; + /** Inert context rendered beneath the question heading and above its options. */ + description?: string; + /** Labeled facts rendered beneath the description and above the options. */ + metadata?: readonly SelectMetadata[]; options: SelectOption[]; /** * Add a type-ahead filter line. The filter is a case-insensitive substring @@ -112,7 +123,7 @@ export interface SelectCommonOptions { * each hint on its own line below the label with a blank line between options — * for small action menus whose hints carry current values. "inline" keeps hints * on the label row, suppresses numeric shortcuts, and separates the trailing - * completion action (e.g. the `/channels` task list). + * completion action (e.g. the `/add` task list). */ hintLayout?: "stacked" | "inline"; /** Outcome lines from earlier laps of a looping menu. */ @@ -239,6 +250,12 @@ export interface Prompter { /** Prints a final green ● end-cap with the message. */ outro(message: string): void; + /** Temporarily hands terminal ownership to a command that inherits stdio. */ + withInheritedStdio?(task: () => Promise): Promise; + + /** Gives a setup subprocess exclusive terminal and development-host ownership. */ + withExclusiveTerminal?(task: () => Promise): Promise; + log: { message(text: string): void; info(text: string): void; @@ -252,7 +269,7 @@ export interface Prompter { * returning a handle whose `stop()` clears it. Present on the real prompter; * optional so lightweight test fakes can omit it. */ - spinner?(message: string): RailSpinner; + spinner?(message: string, intent?: SetupSpinnerIntent): RailSpinner; }; } @@ -447,6 +464,12 @@ export function createPrompter(): Prompter { ): Promise { log.settle(); printNotices(opts.notices); + if (opts.description !== undefined) { + process.stdout.write(formatRailLine(pc.dim(opts.description), pc, process.stdout)); + } + for (const { label, value } of opts.metadata ?? []) { + process.stdout.write(formatRailLine(`${pc.dim(`${label}:`)} ${value}`, pc, process.stdout)); + } const result = guardCancel( await runSelectComponent({ message: opts.message, diff --git a/packages/eve/src/setup/registry-setup-client.ts b/packages/eve/src/setup/registry-setup-client.ts new file mode 100644 index 000000000..1e6c5e82c --- /dev/null +++ b/packages/eve/src/setup/registry-setup-client.ts @@ -0,0 +1,213 @@ +import type { + EditableSelectOptions, + MultiSelectOptions, + Prompter, + PrompterValue, + SelectNotice, + SingleSelectOptions, +} from "./prompter.js"; +import { WizardCancelledError } from "./step.js"; +import { + REGISTRY_SETUP_PROTOCOL_VERSION, + type RegistrySetupChildMessage, + type RegistrySetupFact, + type RegistrySetupOutcome, + type RegistrySetupParentMessage, + type RegistrySetupPrompt, +} from "./registry-setup-protocol.js"; + +interface SetupProcess extends NodeJS.Process { + send?: (message: RegistrySetupChildMessage) => boolean; +} + +function send(process: SetupProcess, message: RegistrySetupChildMessage): void { + if (process.send === undefined || !process.connected) { + throw new Error("The registry setup host disconnected."); + } + process.send(message); +} + +function registrySetupError(error: unknown): { message: string; details?: readonly string[] } { + if (!(error instanceof Error)) return { message: String(error) }; + const details = error.stack + ?.split("\n") + .slice(1) + .map((line) => line.trim()) + .filter(Boolean); + return details === undefined || details.length === 0 + ? { message: error.message } + : { message: error.message, details }; +} + +export interface RegistrySetupClient { + prompter: Prompter; + signal: AbortSignal; + complete(facts?: readonly RegistrySetupFact[]): void; + cancel(): void; + fail(error: unknown): void; +} + +/** Creates a prompter whose questions and output are rendered by the parent eve process. */ +export function createRegistrySetupClient( + input: { + process?: SetupProcess; + signal?: AbortSignal; + } = {}, +): RegistrySetupClient | undefined { + const childProcess = input.process ?? process; + if (childProcess.send === undefined || process.env.EVE_SETUP_PROTOCOL === undefined) + return undefined; + + const controller = new AbortController(); + if (input.signal !== undefined) { + input.signal.addEventListener("abort", () => controller.abort(input.signal?.reason), { + once: true, + }); + } + let nextId = 1; + const pending = new Map void>(); + const onMessage = (message: RegistrySetupParentMessage): void => { + if (message.type === "cancel") { + controller.abort(new WizardCancelledError()); + for (const settle of pending.values()) settle({ type: "cancel" }); + pending.clear(); + return; + } + pending.get(message.id)?.(message); + }; + childProcess.on("message", onMessage); + send(childProcess, { type: "ready", version: REGISTRY_SETUP_PROTOCOL_VERSION }); + + async function prompt(request: RegistrySetupPrompt): Promise { + controller.signal.throwIfAborted(); + const id = nextId++; + const result = await new Promise((resolve) => { + pending.set(id, resolve); + send(childProcess, { type: "prompt", id, prompt: request }); + }); + pending.delete(id); + if (result.type === "cancel" || result.cancelled === true) { + throw new WizardCancelledError(); + } + return result.value as T; + } + + async function validatedText( + kind: "text" | "password", + options: { + message: string; + placeholder?: string; + defaultValue?: string; + validate?: (value: string) => string | undefined; + notices?: readonly SelectNotice[]; + }, + ): Promise { + let notices = options.notices; + while (true) { + const value = await prompt({ + kind, + message: options.message, + placeholder: options.placeholder, + defaultValue: options.defaultValue, + notices, + }); + const error = options.validate?.(value); + if (error === undefined) return value; + notices = [{ tone: "error", text: error }]; + } + } + + function select(options: SingleSelectOptions): Promise; + function select(options: MultiSelectOptions): Promise; + function select( + options: SingleSelectOptions | MultiSelectOptions, + ): Promise { + return prompt({ kind: "select", options }); + } + + const prompter: Prompter = { + text: (options) => validatedText("text", options), + password: (options) => validatedText("password", options), + select, + async selectEditable(options: EditableSelectOptions) { + const result = await prompt< + { kind: "selected"; value: T } | { kind: "edited"; value: T; text: string } + >({ + kind: "editable-select", + options: { + ...options, + editable: { + value: options.editable.value, + defaultValue: options.editable.defaultValue, + }, + }, + }); + if (result.kind === "selected") return { kind: "selected", value: result.value }; + const error = options.editable.validate?.(result.text); + if (error !== undefined) throw new Error(error); + return { kind: "edited", value: result.value, text: result.text }; + }, + acknowledge: (options) => prompt({ kind: "acknowledge", options }), + awaitChoice(options) { + const id = nextId++; + const choice = new Promise((resolve, reject) => { + pending.set(id, (message) => { + pending.delete(id); + if (message.type === "cancel" || message.cancelled === true) { + reject(new WizardCancelledError()); + } else { + resolve(message.value as string | undefined); + } + }); + send(childProcess, { type: "prompt", id, prompt: { kind: "choice", options } }); + }); + return { + choice, + close: () => { + pending.delete(id); + send(childProcess, { type: "close-prompt", id }); + }, + }; + }, + note(message, title, options) { + send(childProcess, { type: "note", message, title, tone: options?.tone }); + }, + intro(text, subtitle) { + send(childProcess, { type: "intro", text, subtitle }); + }, + outro(text) { + send(childProcess, { type: "outro", text }); + }, + log: { + message: (text) => send(childProcess, { type: "log", level: "message", text }), + info: (text) => send(childProcess, { type: "log", level: "info", text }), + success: (text) => send(childProcess, { type: "log", level: "success", text }), + warning: (text) => send(childProcess, { type: "log", level: "warning", text }), + error: (text) => send(childProcess, { type: "log", level: "error", text }), + commandOutput: (text) => send(childProcess, { type: "log", level: "commandOutput", text }), + spinner(status, intent) { + const id = nextId++; + send(childProcess, { + type: "status", + id, + status, + intent: intent?.kind === "external-action" ? intent : undefined, + }); + return { stop: () => send(childProcess, { type: "status", id }) }; + }, + }, + }; + let terminal = false; + const finish = (outcome: RegistrySetupOutcome): void => { + if (terminal) return; + terminal = true; + send(childProcess, { type: "result", outcome }); + }; + return { + prompter, + signal: controller.signal, + complete: (facts = []) => finish({ kind: "completed", facts }), + cancel: () => finish({ kind: "cancelled" }), + fail: (error) => finish({ kind: "failed", error: registrySetupError(error) }), + }; +} diff --git a/packages/eve/src/setup/registry-setup-protocol.ts b/packages/eve/src/setup/registry-setup-protocol.ts new file mode 100644 index 000000000..8f18150da --- /dev/null +++ b/packages/eve/src/setup/registry-setup-protocol.ts @@ -0,0 +1,95 @@ +import type { + AcknowledgeOptions, + MultiSelectOptions, + PrompterValue, + SelectCommonOptions, + SelectNotice, + SelectOption, + SingleSelectOptions, +} from "./prompter.js"; + +export const REGISTRY_SETUP_PROTOCOL_VERSION = 1; + +export type RegistrySetupFact = { + label: string; + value: string; + kind?: "text" | "url" | "phone"; +}; + +export type RegistrySetupError = { + message: string; + details?: readonly string[]; +}; + +export type RegistrySetupOutcome = + | { kind: "completed"; facts: readonly RegistrySetupFact[] } + | { kind: "cancelled" } + | { kind: "failed"; error: RegistrySetupError }; + +export type RegistrySetupPrompt = + | { + kind: "text" | "password"; + message: string; + placeholder?: string; + defaultValue?: string; + notices?: readonly SelectNotice[]; + } + | { + kind: "select"; + options: SingleSelectOptions | MultiSelectOptions; + } + | { + kind: "editable-select"; + options: SelectCommonOptions & { + initialValue?: PrompterValue; + editable: { value: PrompterValue; defaultValue: string }; + }; + } + | { kind: "acknowledge"; options: AcknowledgeOptions } + | { + kind: "choice"; + options: { + status: string; + context: string; + actions: readonly SelectOption[]; + }; + }; + +export type RegistrySetupChildMessage = + | { type: "ready"; version: typeof REGISTRY_SETUP_PROTOCOL_VERSION } + | { type: "prompt"; id: number; prompt: RegistrySetupPrompt } + | { type: "close-prompt"; id: number } + | { + type: "log"; + level: "message" | "info" | "success" | "warning" | "error" | "commandOutput"; + text: string; + } + | { type: "note"; message: string; title?: string; tone?: "warning" | "success" } + | { type: "intro" | "outro"; text: string; subtitle?: string } + | { type: "result"; outcome: RegistrySetupOutcome } + | { + type: "status"; + id: number; + status?: string; + intent?: { kind: "external-action"; emphasis: string }; + }; + +export type RegistrySetupParentMessage = + | { type: "prompt-result"; id: number; value?: unknown; cancelled?: true } + | { type: "cancel" }; + +export function isRegistrySetupChildMessage(value: unknown): value is RegistrySetupChildMessage { + if (typeof value !== "object" || value === null) return false; + const type = (value as { type?: unknown }).type; + return ( + type === "ready" || + type === "prompt" || + type === "close-prompt" || + type === "log" || + type === "note" || + type === "intro" || + type === "outro" || + type === "result" || + type === "status" + ); +} diff --git a/packages/eve/src/setup/scaffold/update/update-connection-connector.integration.test.ts b/packages/eve/src/setup/scaffold/update/update-connection-connector.integration.test.ts index a5d71f1ff..8995238c1 100644 --- a/packages/eve/src/setup/scaffold/update/update-connection-connector.integration.test.ts +++ b/packages/eve/src/setup/scaffold/update/update-connection-connector.integration.test.ts @@ -34,6 +34,21 @@ describe("updateConnectionConnectorUid", () => { expect(updated).toContain('import { connect } from "@vercel/connect/eve";'); }); + test("rewrites the connector UID in the connect object form", async () => { + const path = await writeTemp( + [ + 'import { connect } from "@vercel/connect/eve";', + "export default defineMcpClientConnection({", + ' auth: connect({ connector: "honeycomb", principalType: "app" }),', + "});", + "", + ].join("\n"), + ); + + expect((await updateConnectionConnectorUid(path, "oauth/honeycomb-123")).patched).toBe(true); + expect(await readFile(path, "utf8")).toContain('connector: "oauth/honeycomb-123"'); + }); + test("returns patched: false when there is no connect() call", async () => { const path = await writeTemp("export default defineMcpClientConnection({});\n"); expect((await updateConnectionConnectorUid(path, "oauth/x")).patched).toBe(false); diff --git a/packages/eve/src/setup/scaffold/update/update-connection-connector.ts b/packages/eve/src/setup/scaffold/update/update-connection-connector.ts index 982136e25..63ba57d1a 100644 --- a/packages/eve/src/setup/scaffold/update/update-connection-connector.ts +++ b/packages/eve/src/setup/scaffold/update/update-connection-connector.ts @@ -6,6 +6,7 @@ import { readFile, writeFile } from "node:fs/promises"; * argument in generated connection files, so a narrow anchored match is safe. */ const CONNECT_CONNECTOR_REGEX = /(\bconnect\(\s*)(["'`])([^"'`]+)\2/; +const CONNECT_OBJECT_CONNECTOR_REGEX = /(\bconnector\s*:\s*)(["'`])([^"'`]+)\2/; /** * Replaces the connector UID literal in a scaffolded Connect connection @@ -23,12 +24,15 @@ export async function updateConnectionConnectorUid( return { patched: false }; } - if (!CONNECT_CONNECTOR_REGEX.test(source)) { - return { patched: false }; - } + const pattern = CONNECT_CONNECTOR_REGEX.test(source) + ? CONNECT_CONNECTOR_REGEX + : CONNECT_OBJECT_CONNECTOR_REGEX.test(source) + ? CONNECT_OBJECT_CONNECTOR_REGEX + : undefined; + if (pattern === undefined) return { patched: false }; const next = source.replace( - CONNECT_CONNECTOR_REGEX, + pattern, (_match, prefix: string, quote: string) => `${prefix}${quote}${connectorUid}${quote}`, ); await writeFile(connectionFilePath, next, "utf8"); diff --git a/packages/eve/test/tui-client/tui-packed-install-model.ts b/packages/eve/test/tui-client/tui-packed-install-model.ts index e0d88ae03..3a9ef906f 100644 --- a/packages/eve/test/tui-client/tui-packed-install-model.ts +++ b/packages/eve/test/tui-client/tui-packed-install-model.ts @@ -144,6 +144,11 @@ void (async () => { await screen.waitForText("Change model", 5_000); input.send("\x1b"); await screen.waitForText("/model dismissed.", 5_000); + // Fresh-model onboarding now follows the picker with the registry hub. + // Dismiss it too before asserting that the runner returns to chat. + await screen.waitForText("Add to your agent", 5_000); + input.send("\x1b"); + await screen.waitForText("/add dismissed.", 5_000); await screen.waitForIdlePrompt(5_000); input.type("/exit"); From b377d0e504ab9598f10381a4f0d9274088e8c4b2 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:26:15 +0000 Subject: [PATCH 2/5] refactor(eve): remove channel add surfaces Signed-off-by: owenkephart Co-Authored-By: owenkephart --- .changeset/remove-channel-add.md | 5 + docs/channels/overview.mdx | 2 +- docs/channels/slack.mdx | 4 +- docs/guides/dev-tui.md | 10 +- docs/tutorial/ship-it.mdx | 4 +- .../cli/commands/agent-instructions.test.ts | 2 +- .../commands/agent-prompt/collect-intent.md | 2 +- .../cli/commands/channels.integration.test.ts | 102 --- packages/eve/src/cli/commands/channels.ts | 67 +- .../src/cli/commands/init.integration.test.ts | 2 +- packages/eve/src/cli/commands/init.ts | 2 +- .../cli/commands/integration-setup.test.ts | 2 +- .../eve/src/cli/commands/integration-setup.ts | 50 +- .../src/cli/dev/tui/prompt-commands.test.ts | 9 +- .../eve/src/cli/dev/tui/prompt-commands.ts | 9 - .../src/cli/dev/tui/remote-auth-command.ts | 2 +- packages/eve/src/cli/dev/tui/runner.test.ts | 57 +- packages/eve/src/cli/dev/tui/runner.ts | 6 +- .../src/cli/dev/tui/setup-commands.test.ts | 334 +--------- .../eve/src/cli/dev/tui/setup-commands.ts | 101 +-- .../eve/src/cli/dev/tui/status-line.test.ts | 37 +- packages/eve/src/cli/dev/tui/status-line.ts | 9 +- .../src/cli/dev/tui/terminal-renderer.test.ts | 14 +- .../eve/src/cli/dev/tui/vercel-status.test.ts | 40 +- packages/eve/src/cli/dev/tui/vercel-status.ts | 22 +- packages/eve/src/cli/run.ts | 16 +- .../eve/src/setup/boxes/deploy-project.ts | 8 +- .../install-channel-registry-items.test.ts | 32 - .../boxes/install-channel-registry-items.ts | 34 - .../setup/boxes/one-shot-next-steps.test.ts | 84 --- .../src/setup/boxes/one-shot-next-steps.ts | 72 -- .../eve/src/setup/boxes/preflight.test.ts | 95 --- packages/eve/src/setup/boxes/preflight.ts | 75 --- .../src/setup/boxes/resolve-target.test.ts | 218 ------ .../eve/src/setup/boxes/resolve-target.ts | 232 ------- packages/eve/src/setup/boxes/scaffold.test.ts | 147 ---- packages/eve/src/setup/boxes/scaffold.ts | 93 --- .../src/setup/boxes/select-channels.test.ts | 264 -------- .../eve/src/setup/boxes/select-channels.ts | 162 ----- .../eve/src/setup/boxes/select-chat.test.ts | 141 ---- packages/eve/src/setup/boxes/select-chat.ts | 110 --- .../src/setup/boxes/select-setup-mode.test.ts | 108 --- .../eve/src/setup/boxes/select-setup-mode.ts | 90 --- .../eve/src/setup/channel-setup-deployment.ts | 42 -- packages/eve/src/setup/flows/channels.test.ts | 626 ------------------ packages/eve/src/setup/flows/channels.ts | 315 --------- packages/eve/src/setup/flows/in-project.ts | 2 +- packages/eve/src/setup/index.ts | 4 +- .../channels/environment.test.ts} | 5 +- .../channels/environment.ts} | 4 +- .../channels/index.test.ts} | 12 +- .../channels/index.ts} | 10 +- .../channels/runner.ts} | 13 +- .../channels/setup.test.ts} | 114 +--- .../channels/setup.ts} | 129 ++-- .../channels/slack.ts} | 6 +- .../channels/types.ts} | 27 +- .../channels/ui.test.ts} | 4 +- .../channels/ui.ts} | 4 +- .../channels/web.ts} | 4 +- packages/eve/src/setup/onboarding.test.ts | 104 --- packages/eve/src/setup/onboarding.ts | 225 ------- .../src/setup/scaffold/channels-catalog.ts | 2 +- .../setup/scaffold/create/add-to-project.ts | 2 +- .../eve/src/setup/scaffold/create/project.ts | 2 +- .../setup/scaffold/index.integration.test.ts | 2 +- .../eve/src/setup/vercel-project-framework.ts | 2 +- .../eve/test/tui-client/tui-slash-commands.ts | 163 ----- .../eve/test/tui-client/tui-status-line.ts | 182 ----- 69 files changed, 218 insertions(+), 4662 deletions(-) create mode 100644 .changeset/remove-channel-add.md delete mode 100644 packages/eve/src/cli/commands/channels.integration.test.ts delete mode 100644 packages/eve/src/setup/boxes/install-channel-registry-items.test.ts delete mode 100644 packages/eve/src/setup/boxes/install-channel-registry-items.ts delete mode 100644 packages/eve/src/setup/boxes/one-shot-next-steps.test.ts delete mode 100644 packages/eve/src/setup/boxes/one-shot-next-steps.ts delete mode 100644 packages/eve/src/setup/boxes/preflight.test.ts delete mode 100644 packages/eve/src/setup/boxes/preflight.ts delete mode 100644 packages/eve/src/setup/boxes/resolve-target.test.ts delete mode 100644 packages/eve/src/setup/boxes/resolve-target.ts delete mode 100644 packages/eve/src/setup/boxes/scaffold.test.ts delete mode 100644 packages/eve/src/setup/boxes/scaffold.ts delete mode 100644 packages/eve/src/setup/boxes/select-channels.test.ts delete mode 100644 packages/eve/src/setup/boxes/select-channels.ts delete mode 100644 packages/eve/src/setup/boxes/select-chat.test.ts delete mode 100644 packages/eve/src/setup/boxes/select-chat.ts delete mode 100644 packages/eve/src/setup/boxes/select-setup-mode.test.ts delete mode 100644 packages/eve/src/setup/boxes/select-setup-mode.ts delete mode 100644 packages/eve/src/setup/channel-setup-deployment.ts delete mode 100644 packages/eve/src/setup/flows/channels.test.ts delete mode 100644 packages/eve/src/setup/flows/channels.ts rename packages/eve/src/setup/{channel-setup-environment.test.ts => integrations/channels/environment.test.ts} (89%) rename packages/eve/src/setup/{channel-setup-environment.ts => integrations/channels/environment.ts} (92%) rename packages/eve/src/setup/{channel-setup-integrations.test.ts => integrations/channels/index.test.ts} (87%) rename packages/eve/src/setup/{channel-setup-integrations.ts => integrations/channels/index.ts} (64%) rename packages/eve/src/setup/{channel-setup-runner.ts => integrations/channels/runner.ts} (75%) rename packages/eve/src/setup/{boxes/add-channels.test.ts => integrations/channels/setup.test.ts} (89%) rename packages/eve/src/setup/{boxes/add-channels.ts => integrations/channels/setup.ts} (86%) rename packages/eve/src/setup/{channel-setup-slack.ts => integrations/channels/slack.ts} (91%) rename packages/eve/src/setup/{channel-setup-integration.ts => integrations/channels/types.ts} (52%) rename packages/eve/src/setup/{channel-setup-ui.test.ts => integrations/channels/ui.test.ts} (85%) rename packages/eve/src/setup/{channel-setup-ui.ts => integrations/channels/ui.ts} (88%) rename packages/eve/src/setup/{channel-setup-web.ts => integrations/channels/web.ts} (70%) delete mode 100644 packages/eve/src/setup/onboarding.test.ts delete mode 100644 packages/eve/src/setup/onboarding.ts delete mode 100644 packages/eve/test/tui-client/tui-slash-commands.ts delete mode 100644 packages/eve/test/tui-client/tui-status-line.ts diff --git a/.changeset/remove-channel-add.md b/.changeset/remove-channel-add.md new file mode 100644 index 000000000..ecffb9898 --- /dev/null +++ b/.changeset/remove-channel-add.md @@ -0,0 +1,5 @@ +--- +"eve": minor +--- + +Remove the `/channels` dev TUI command, `eve channels add`, and the unused programmatic onboarding API. Channel integrations install through `eve add`, run isolated integration setup without deploying, and leave deployment as an explicit `eve deploy` step. diff --git a/docs/channels/overview.mdx b/docs/channels/overview.mdx index 3d2e6ba4c..892ed8ea7 100644 --- a/docs/channels/overview.mdx +++ b/docs/channels/overview.mdx @@ -28,7 +28,7 @@ agent/ intake.ts ``` -Scaffold a channel file with `eve channels add` (interactive), or pass a kind: `eve channels add slack` or `eve channels add web`. You can also author the file by hand. +Install a built-in channel with `eve add channel/slack` or `eve add channel/web`. You can also author the file by hand. ## The eve HTTP channel (default) diff --git a/docs/channels/slack.mdx b/docs/channels/slack.mdx index 9d14879a2..0c5ed1933 100644 --- a/docs/channels/slack.mdx +++ b/docs/channels/slack.mdx @@ -21,7 +21,7 @@ The `create` step provisions a destination at the default Connect path. `detach` ## Add the channel -Scaffold the channel and its dependency with `eve channels add slack`, or set it up by hand: +Scaffold the channel and its dependency with `eve add channel/slack`, or set it up by hand: ```bash npm install @vercel/connect @@ -38,7 +38,7 @@ export default slackChannel({ `connectSlackCredentials` returns `{ botToken, webhookVerifier }`, keeping token rotation, multi-workspace tenancy, and request verification inside Connect rather than your code. -If eve cannot find an authenticated Vercel session, `eve channels add slack` asks whether to set up Vercel Connect or use portable environment credentials. The portable option adds the required names to `.env.example`. Before deploying, appropriate values must be supplied to your runtime environment. +If eve cannot find an authenticated Vercel session, `eve add channel/slack` asks whether to set up Vercel Connect or use portable environment credentials. The portable option adds the required names to `.env.example`. Before deploying, appropriate values must be supplied to your runtime environment. ### Deploy diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index 85a0a6586..491cdd7c9 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -13,7 +13,7 @@ On startup the TUI prints a brand line with your agent's name, plus a rotating t ```text eve weather-agent - Use /channels to add more ways to reach your agent. + Use /add to install integrations from the registry. ``` If agent discovery reported problems, an error and warning count renders between the two lines. Instructions, tools, skills, and subagents are one `eve info` away, and `/help` lists every command. The TUI also runs a startup check. A fresh `eve init` starts local `eve dev` with `/model` prefilled. That input starts onboarding: the flow installs the Vercel CLI if needed, asks you to log in if needed, opens `/model`, then offers categorized next steps for channels, connections, capabilities, and observability through the registry before the first prompt. Other `eve dev` sessions show missing setup as an attention line, with each command's outcome hanging under it on a `⎿` connector. @@ -35,7 +35,7 @@ Each command echoes as an invocation line, asks through a bordered panel that ta | 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). | +| `/add` | Browses and installs integrations from the registry. | | `/connect` | Shows the Vercel Connect MCP catalog and configures the server you pick. See [Add a connection](#add-a-connection). | | `/add` | Searches registry integrations or adds an item address entered directly. | | `/deploy` | Ships the agent to Vercel production, linking the directory first when it is unlinked. | @@ -46,7 +46,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`, `/connect`, `/add`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. +`/model`, `/connect`, `/add`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. ### Configure the model and provider @@ -56,10 +56,6 @@ The provider row opens one menu: AI Gateway via a project, AI Gateway via `AI_GA The provider row demands attention (a bold yellow "Configure model access" with a yellow "Not configured" hint that dims when unselected and uses the terminal foreground when selected) until a link or gateway credential is detected, then names the connection afterward (for example "AI Gateway (Linked to my-project in my-team)"). Setup menus mark the cursor row with a padded, filled-arrow inverse label that inherits the row's accent: blue normally and yellow for warning rows. In stacked menus, the selected row's description appears directly beneath that option. The completed command's outcome stays in the transcript after the panel closes. When a turn fails because AI Gateway authentication is missing or stale, the error points you at `/model` directly. -### Add a channel - -`/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. - ### Browse registry integrations `/add` first organizes integrations by what they add: a way to chat, tools and data, capabilities, or observability. Pick a category to open its searchable catalog from the official eve registry and the registry namespaces configured in `package.json`, or browse everything. Each integration shows its source beside its name, such as `Vercel` or a configured registry namespace. Select a result to review its source, packages, environment variables, and target files before adding it, or type an official item path, configured namespace address, or HTTP(S) URL directly into the search bar. After an add attempt finishes, the panel closes instead of returning to the catalog. diff --git a/docs/tutorial/ship-it.mdx b/docs/tutorial/ship-it.mdx index 2db857152..6f6fd36a9 100644 --- a/docs/tutorial/ship-it.mdx +++ b/docs/tutorial/ship-it.mdx @@ -7,10 +7,10 @@ The analytics assistant runs fine in the TUI. Now ship it for real, as a web das ## Add the Web Chat app -Step 1 scaffolded the agent without a web frontend. Add one now with `eve channels add`, run from the `analytics-assistant/` directory: +Step 1 scaffolded the agent without a web frontend. Add one now with `eve add channel/web`, run from the `analytics-assistant/` directory: ```bash -npx eve channels add web +npx eve add channel/web ``` This adds a Next.js app (`next.config.ts`, `app/page.tsx`, `app/_components/`) wired to the existing eve channel, plus the chat UI components and their dependencies. Run `npm install` afterward to install the added packages. The generated `next.config.ts` wraps your config with `withEve`, which wires the eve routes automatically: diff --git a/packages/eve/src/cli/commands/agent-instructions.test.ts b/packages/eve/src/cli/commands/agent-instructions.test.ts index 3a9c73744..d369d6d1c 100644 --- a/packages/eve/src/cli/commands/agent-instructions.test.ts +++ b/packages/eve/src/cli/commands/agent-instructions.test.ts @@ -42,7 +42,7 @@ describe("initAgentInstructions", () => { const instructions = initAgentInstructions(); // Channels: Slack credentials are provisioned by Connect, not hand-managed. - expect(instructions).toContain("eve channels add slack"); + expect(instructions).toContain("eve add channel/slack"); // Connections: per-user auth wires through Connect's eve helper. expect(instructions).toContain("agent/connections/"); expect(instructions).toContain("@vercel/connect/eve"); diff --git a/packages/eve/src/cli/commands/agent-prompt/collect-intent.md b/packages/eve/src/cli/commands/agent-prompt/collect-intent.md index 9ac2e5e3e..8731320ed 100644 --- a/packages/eve/src/cli/commands/agent-prompt/collect-intent.md +++ b/packages/eve/src/cli/commands/agent-prompt/collect-intent.md @@ -7,7 +7,7 @@ the coding harness's prompt tools when available, and do not guess. 2. Where should it be reachable? Every agent ships the built-in HTTP channel. On top of that: - **Web Chat** (a Next.js app): add it at init with `--channel-web-nextjs`. - - **Slack** and other platforms: add after deploy with `eve channels add slack`. + - **Slack** and other platforms: add after deploy with `eve add channel/slack`. Credentials run through **Vercel Connect**, which provisions the bot token and verifies inbound webhooks, so there is no `SLACK_BOT_TOKEN` or signing secret to manage. diff --git a/packages/eve/src/cli/commands/channels.integration.test.ts b/packages/eve/src/cli/commands/channels.integration.test.ts deleted file mode 100644 index b9e5dc295..000000000 --- a/packages/eve/src/cli/commands/channels.integration.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import type { Prompter } from "#setup/prompter.js"; - -import { - runChannelsAddCompatibilityCommand, - type ChannelsAddDependencies, - type CliLogger, -} from "./channels.js"; - -const { isEveProject } = vi.hoisted(() => ({ isEveProject: vi.fn(async () => true) })); - -vi.mock("#setup/scaffold/index.js", () => ({ - isEveProject, - listAuthoredChannels: vi.fn(async () => []), -})); - -function logger(): CliLogger & { errors: string[] } { - const errors: string[] = []; - return { errors, error: (message) => errors.push(message), log: () => {} }; -} - -function dependencies(overrides: Partial = {}): ChannelsAddDependencies { - return { - createPrompter: vi.fn(() => ({}) as Prompter), - loadAddCommand: vi.fn(async () => vi.fn(async () => {})), - loadChannelsFlow: vi.fn(async () => - vi.fn(async () => ({ kind: "done" as const, addedChannels: [] })), - ), - ...overrides, - }; -} - -afterEach(() => { - process.exitCode = undefined; -}); - -describe("runChannelsAddCompatibilityCommand", () => { - it("maps an explicit kind to the canonical registry item", async () => { - const output = logger(); - const runAdd = vi.fn(async () => {}); - - await runChannelsAddCompatibilityCommand( - output, - "/project", - { kind: "slack", options: { force: true } }, - dependencies({ loadAddCommand: async () => runAdd }), - ); - - expect(runAdd).toHaveBeenCalledWith(output, "/project", "channel/slack", { - overwrite: true, - }); - }); - - it("forwards --yes to registry setup", async () => { - const output = logger(); - const runAdd = vi.fn(async () => {}); - - await runChannelsAddCompatibilityCommand( - output, - "/project", - { kind: "slack", options: { yes: true } }, - dependencies({ loadAddCommand: async () => runAdd }), - ); - - expect(runAdd).toHaveBeenCalledWith(output, "/project", "channel/slack", { - yes: true, - }); - }); - - it.skipIf(!process.stdin.isTTY || !process.stdout.isTTY)( - "retains the bare interactive picker as a compatibility surface", - async () => { - const output = logger(); - const runFlow = vi.fn(async () => ({ kind: "done" as const, addedChannels: [] })); - const prompter = {} as Prompter; - - await runChannelsAddCompatibilityCommand( - output, - "/project", - { options: {} }, - dependencies({ createPrompter: () => prompter, loadChannelsFlow: async () => runFlow }), - ); - - expect(runFlow).toHaveBeenCalledWith({ appRoot: "/project", prompter }); - }, - ); - - it("rejects an unknown explicit kind", async () => { - const output = logger(); - - await runChannelsAddCompatibilityCommand( - output, - "/project", - { kind: "unknown", options: {} }, - dependencies(), - ); - - expect(output.errors).toEqual(['Unknown channel kind "unknown". Known: slack, web.']); - expect(process.exitCode).toBe(1); - }); -}); diff --git a/packages/eve/src/cli/commands/channels.ts b/packages/eve/src/cli/commands/channels.ts index 008827e83..a3839da98 100644 --- a/packages/eve/src/cli/commands/channels.ts +++ b/packages/eve/src/cli/commands/channels.ts @@ -1,75 +1,12 @@ -import { isEveProject, listAuthoredChannels, type ChannelKind } from "#setup/scaffold/index.js"; -import { createPrompter, type Prompter } from "#setup/prompter.js"; -import type { runChannelsFlow } from "#setup/flows/channels.js"; +import { isEveProject, listAuthoredChannels } from "#setup/scaffold/index.js"; import { NOT_AN_AGENT_MESSAGE } from "./preconditions.js"; -import type { AddCommandOptions, runAddCommand } from "./registry.js"; export interface CliLogger { error(message: string): void; log(message: string): void; } -const KNOWN_CHANNEL_KINDS: readonly ChannelKind[] = ["slack", "web"]; - -function parseChannelKind(value: string): ChannelKind { - if (KNOWN_CHANNEL_KINDS.includes(value as ChannelKind)) return value as ChannelKind; - throw new Error(`Unknown channel kind "${value}". Known: ${KNOWN_CHANNEL_KINDS.join(", ")}.`); -} - -export interface AddChannelCommandOptions { - force?: boolean; - yes?: boolean; -} - -export interface ChannelsAddDependencies { - createPrompter(): Prompter; - loadAddCommand(): Promise; - loadChannelsFlow(): Promise; -} - -const defaultChannelsAddDependencies: ChannelsAddDependencies = { - createPrompter, - loadAddCommand: async () => (await import("./registry.js")).runAddCommand, - loadChannelsFlow: async () => (await import("#setup/flows/channels.js")).runChannelsFlow, -}; - -/** Compatibility adapter from `eve channels add` to registry-backed installation. */ -export async function runChannelsAddCompatibilityCommand( - logger: CliLogger, - appRoot: string, - args: { kind?: string; options: AddChannelCommandOptions }, - dependencies: ChannelsAddDependencies = defaultChannelsAddDependencies, -): Promise { - if (!(await isEveProject(appRoot))) { - logger.error(NOT_AN_AGENT_MESSAGE); - process.exitCode = 1; - return; - } - - try { - if (args.kind !== undefined) { - const kind = parseChannelKind(args.kind); - const runAdd = await dependencies.loadAddCommand(); - const addOptions: AddCommandOptions = {}; - if (args.options.force === true) addOptions.overwrite = true; - if (args.options.yes === true) addOptions.yes = true; - await runAdd(logger, appRoot, `channel/${kind}`, addOptions); - return; - } - if (args.options.yes || !process.stdin.isTTY || !process.stdout.isTTY) { - throw new Error( - `Pass a channel kind: \`eve channels add <${KNOWN_CHANNEL_KINDS.join("|")}>\`.`, - ); - } - const runFlow = await dependencies.loadChannelsFlow(); - await runFlow({ appRoot, prompter: dependencies.createPrompter() }); - } catch (error) { - logger.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - } -} - export interface ListChannelsCommandOptions { json?: boolean; } @@ -93,7 +30,7 @@ export async function runChannelsListCommand( } if (channels.length === 0) { - logger.log("No channels defined. Run `eve channels add` to add one."); + logger.log("No channels defined. Run `eve add ` to add one."); return; } diff --git a/packages/eve/src/cli/commands/init.integration.test.ts b/packages/eve/src/cli/commands/init.integration.test.ts index a0a2ba834..9c635d93f 100644 --- a/packages/eve/src/cli/commands/init.integration.test.ts +++ b/packages/eve/src/cli/commands/init.integration.test.ts @@ -950,7 +950,7 @@ describe("runInitCommand", () => { await expect( runInitCommand(output, parentDirectory, "host-app", { channelWebNextjs: true }, deps), - ).rejects.toThrow("eve channels add web"); + ).rejects.toThrow("eve add channel/web"); await expect(pathExists(join(projectRoot, "agent"))).resolves.toBe(false); expect(deps.runPackageManagerInstall).not.toHaveBeenCalled(); diff --git a/packages/eve/src/cli/commands/init.ts b/packages/eve/src/cli/commands/init.ts index cd0b06279..c0ea8af3a 100644 --- a/packages/eve/src/cli/commands/init.ts +++ b/packages/eve/src/cli/commands/init.ts @@ -158,7 +158,7 @@ async function addToExistingProject( if (options.channelWebNextjs === true) { throw new Error( "`--channel-web-nextjs` is not supported when adding an agent to an existing project. " + - "Run `eve channels add web` from the project afterwards instead.", + "Run `eve add channel/web` from the project afterwards instead.", ); } diff --git a/packages/eve/src/cli/commands/integration-setup.test.ts b/packages/eve/src/cli/commands/integration-setup.test.ts index 74e884438..82b4a0e77 100644 --- a/packages/eve/src/cli/commands/integration-setup.test.ts +++ b/packages/eve/src/cli/commands/integration-setup.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import type { AddChannelsDeps } from "#setup/boxes/add-channels.js"; +import type { AddChannelsDeps } from "#setup/integrations/channels/setup.js"; import { deriveSlackConnectorSlug } from "#setup/scaffold/index.js"; import { runIntegrationSetupCommand } from "./integration-setup.js"; diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index 79966ea4b..e5626ca88 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -1,20 +1,18 @@ import { interactiveAsker } from "#setup/ask.js"; -import type { AddChannelsDeps } from "#setup/boxes/add-channels.js"; -import type { DeployProjectDeps } from "#setup/boxes/deploy-project.js"; -import { deployChannelSetup } from "#setup/channel-setup-deployment.js"; +import type { AddChannelsDeps } from "#setup/integrations/channels/setup.js"; import { channelSetupEnvironment, describeChannelSetupEnvironment, -} from "#setup/channel-setup-environment.js"; +} from "#setup/integrations/channels/environment.js"; import { channelSetupIntegration, createChannelSetupUi, -} from "#setup/channel-setup-integrations.js"; +} from "#setup/integrations/channels/index.js"; import { detectDeployment, projectResolutionFromDeployment } from "#setup/project-resolution.js"; import { createPrompter, type Prompter } from "#setup/prompter.js"; import { createRegistrySetupClient } from "#setup/registry-setup-client.js"; import { isEveProject, type ChannelKind } from "#setup/scaffold/index.js"; -import { createDefaultSetupState, type SetupState } from "#setup/state.js"; +import { createDefaultSetupState } from "#setup/state.js"; import { getVercelAuthStatus } from "#setup/vercel-project.js"; import { NOT_AN_AGENT_MESSAGE } from "./preconditions.js"; @@ -30,7 +28,6 @@ export interface IntegrationSetupDependencies { detectDeployment: typeof detectDeployment; getVercelAuthStatus: typeof getVercelAuthStatus; addChannelsDeps?: AddChannelsDeps; - deployProjectDeps?: DeployProjectDeps; } const defaultIntegrationSetupDependencies: IntegrationSetupDependencies = { @@ -62,7 +59,8 @@ export async function runIntegrationSetupCommand( const channelKind: ChannelKind = kind; const prompter = client?.prompter ?? dependencies.createPrompter?.() ?? createPrompter(); const signal = client?.signal ?? options.signal; - prompter.intro(`Set up ${channelSetupIntegration(channelKind).label}`); + const integration = channelSetupIntegration(channelKind); + prompter.intro(`Set up ${integration.label}`); prompter.log.message("Checking Vercel setup..."); const [deployment, authStatus] = await Promise.all([ dependencies.detectDeployment(appRoot, { signal }), @@ -71,15 +69,14 @@ export async function runIntegrationSetupCommand( const project = projectResolutionFromDeployment(deployment); const environment = channelSetupEnvironment(authStatus, project); prompter.log.info(describeChannelSetupEnvironment(environment)); - const state: SetupState = { - ...createDefaultSetupState(), - project, - projectPath: { kind: "resolved", inPlace: true, path: appRoot }, - channelSelection: [channelKind], - }; - const result = await channelSetupIntegration(channelKind).setup({ + const result = await integration.setup({ environment, - state, + state: { + ...createDefaultSetupState(), + project, + projectPath: { kind: "resolved", inPlace: true, path: appRoot }, + channelSelection: [channelKind], + }, ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), presetCreateSlackbot: options.yes ? true : undefined, presetPortableCredentials: options.yes ? true : undefined, @@ -92,26 +89,7 @@ export async function runIntegrationSetupCommand( if (process.env.EVE_SETUP === "1") process.exitCode = 130; return; } - let finalState = result.state; - const addedVercelChannel = - finalState.slackbotAttached || - (environment.vercel.kind === "available" && finalState.channels.includes("web")); - if (addedVercelChannel) { - finalState = await deployChannelSetup({ - state: finalState, - ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), - presetDeploy: - options.yes === true - ? true - : !process.stdin.isTTY || !process.stdout.isTTY - ? false - : undefined, - deps: dependencies.deployProjectDeps, - }); - } - prompter.outro( - finalState.channels.includes(channelKind) ? "Integration set up." : "No changes made.", - ); + prompter.outro("Integration set up."); client?.complete(); } catch (error) { client?.fail(error); 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 cefb327d2..2edc789e6 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.test.ts @@ -45,11 +45,6 @@ describe("parsePromptCommand", () => { name: "vc:login", argument: "", }); - expect(parsePromptCommand("/channels")).toEqual({ - type: "extension", - name: "channels", - argument: "", - }); expect(parsePromptCommand("/deploy")).toEqual({ type: "extension", name: "deploy", @@ -86,7 +81,7 @@ describe("parsePromptCommand", () => { expect(parsePromptCommand("/vc")).toBeNull(); expect(parsePromptCommand("/login")).toBeNull(); expect(parsePromptCommand("/vc:auth")).toBeNull(); - expect(parsePromptCommand("/channels extra")).toBeNull(); + expect(parsePromptCommand("/channels")).toBeNull(); expect(parsePromptCommand("tell me about /channels")).toBeNull(); expect(parsePromptCommand("/")).toBeNull(); expect(parsePromptCommand("")).toBeNull(); @@ -98,7 +93,6 @@ describe("promptCommandsFor", () => { it("exposes project commands only for local sessions", () => { const names = promptCommandsFor("local").map((command) => command.name); expect(names).toContain("model"); - expect(names).toContain("channels"); expect(names).toContain("connect"); expect(names).toContain("add"); expect(names).toContain("deploy"); @@ -113,7 +107,6 @@ describe("promptCommandsFor", () => { expect(names).toContain("vc:login"); 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("add"); expect(names).not.toContain("deploy"); diff --git a/packages/eve/src/cli/dev/tui/prompt-commands.ts b/packages/eve/src/cli/dev/tui/prompt-commands.ts index 5d755f115..2e8de3e50 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.ts @@ -1,6 +1,5 @@ export type PromptCommandExtensionName = | "model" - | "channels" | "connect" | "add" | "deploy" @@ -98,14 +97,6 @@ const PROMPT_COMMAND_DEFINITIONS = [ build: (argument) => ({ type: "loglevel", argument }), targets: ["local", "remote"], }, - { - name: "channels", - aliases: [], - description: "Add chat channels to the agent", - takesArgument: false, - build: () => ({ type: "extension", name: "channels", argument: "" }), - targets: ["local"], - }, { name: "connect", aliases: [], diff --git a/packages/eve/src/cli/dev/tui/remote-auth-command.ts b/packages/eve/src/cli/dev/tui/remote-auth-command.ts index 7307516e2..b9dcd79f8 100644 --- a/packages/eve/src/cli/dev/tui/remote-auth-command.ts +++ b/packages/eve/src/cli/dev/tui/remote-auth-command.ts @@ -71,7 +71,7 @@ function mutedRenderer( /** Runs remote `/vc:login` through one TUI panel, connection operation, and auth flow. */ export async function runRemoteAuthCommand(input: RemoteAuthCommandInput): Promise { - // The pulsing square, matching /vc:install and /vc:login (and model/channels). + // The pulsing square, matching /vc:install, /vc:login, and /model. input.renderer.begin("Authenticate via Vercel OIDC", "pulse"); let preserveFlowDiagnostics = true; let interrupted = false; diff --git a/packages/eve/src/cli/dev/tui/runner.test.ts b/packages/eve/src/cli/dev/tui/runner.test.ts index 9e1df5f23..a20195bb9 100644 --- a/packages/eve/src/cli/dev/tui/runner.test.ts +++ b/packages/eve/src/cli/dev/tui/runner.test.ts @@ -1675,9 +1675,8 @@ describe("parsePromptCommand", () => { ["/new", { type: "new" }], ["/exit", { type: "exit" }], ["/quit", { type: "exit" }], - ["/channels", { type: "extension", name: "channels", argument: "" }], ["/deploy", { type: "extension", name: "deploy", argument: "" }], - [" /channels ", { type: "extension", name: "channels", argument: "" }], + [" /channels ", null], ["/vercel", null], ["/links", null], ["deploy", null], @@ -1702,30 +1701,6 @@ describe("EveTUIRunner setup commands", () => { return { renderer, notices }; } - it("answers /channels with a local-only notice when no appRoot is configured", async () => { - const session = sessionYielding([]); - const { renderer, notices } = recordingRenderer(["/channels", undefined]); - - const runner = new EveTUIRunner({ - session, - renderer, - name: "Weather Agent", - availablePromptCommands: promptCommandsFor("remote"), - promptCommandHandler: createPromptCommandHandler({ - target: { - kind: "remote", - workspaceRoot: "/tmp/weather-agent", - serverUrl: "https://example.com/", - }, - }), - }); - await runner.run(); - - expect(notices).toHaveLength(1); - expect(notices[0]).toContain("--url"); - expect(session.send).not.toHaveBeenCalled(); - }); - it("answers /deploy with an unsupported notice when the renderer cannot suspend", async () => { const session = sessionYielding([]); const { renderer, notices } = recordingRenderer(["/deploy", undefined]); @@ -2230,13 +2205,13 @@ describe("EveTUIRunner Vercel status line", () => { }); await runner.run(); - expect(pushes).toEqual([{ identity, pendingDeploy: false }]); + expect(pushes).toEqual([{ identity }]); expect(detectIdentity).toHaveBeenCalledWith("/tmp/weather-agent", { signal: expect.any(AbortSignal), }); }); - it("applies command effects: channels mark pending, deploy clears and re-probes", async () => { + it("applies deploy effects and re-probes", async () => { const pushes: VercelStatusSnapshot[] = []; const settled = createDeferred(); let probes = 0; @@ -2246,7 +2221,7 @@ describe("EveTUIRunner Vercel status line", () => { probes += 1; return probes === 1 ? new Promise(() => {}) : Promise.resolve(identity); }); - const prompts: Array = ["/channels", "/deploy"]; + const prompts: Array = ["/deploy"]; const renderer = fakeRenderer({ renderNotice: vi.fn(), readPrompt: vi.fn(async () => { @@ -2257,14 +2232,10 @@ describe("EveTUIRunner Vercel status line", () => { }), setVercelStatus: (snapshot) => { pushes.push(snapshot); - if (pushes.length >= 3) settled.resolve(); + if (pushes.length >= 2) settled.resolve(); }, }); const outcomes: Record = { - channels: { - message: "Channels added: slack — run /deploy to ship them.", - effect: { kind: "channels-added" }, - }, deploy: { message: "Deployed.", effect: { kind: "deployed" } }, }; @@ -2278,18 +2249,14 @@ describe("EveTUIRunner Vercel status line", () => { }); await runner.run(); - expect(pushes).toEqual([ - { pendingDeploy: true }, - { pendingDeploy: false }, - { identity, pendingDeploy: false }, - ]); + expect(pushes).toEqual([{}, { identity }]); expect(detectIdentity).toHaveBeenCalledTimes(2); }); it("refreshes agent info only after a model-access change", async () => { const client = stubClient(); const info = vi.spyOn(client, "info").mockResolvedValue(AGENT_INFO); - const prompts: Array = ["/channels", "/model", undefined]; + const prompts: Array = ["/deploy", "/model", undefined]; const infoCallsAtPrompt: number[] = []; const renderer = fakeRenderer({ readPrompt: vi.fn(async () => { @@ -2297,9 +2264,9 @@ describe("EveTUIRunner Vercel status line", () => { return prompts.shift(); }), }); - const channelsOutcome: PromptCommandOutcome = { - message: "Channels added.", - effect: { kind: "channels-added" }, + const deployOutcome: PromptCommandOutcome = { + message: "Deployed.", + effect: { kind: "deployed" }, }; const modelOutcome: PromptCommandOutcome = { message: "Connected to AI Gateway.", @@ -2316,7 +2283,7 @@ describe("EveTUIRunner Vercel status line", () => { bootDetections: [], detectProjectIdentity: vi.fn(async () => undefined), promptCommandHandler: { - handle: async (command) => (command.name === "model" ? modelOutcome : channelsOutcome), + handle: async (command) => (command.name === "model" ? modelOutcome : deployOutcome), }, }); @@ -2852,7 +2819,7 @@ describe("EveTUIRunner command outcome rendering", () => { expect(results).toHaveLength(1); expect(results[0]).toContain("/model"); - expect(results[0]).toContain("/channels"); + expect(results[0]).not.toContain("/channels"); expect(session.send).not.toHaveBeenCalled(); }); diff --git a/packages/eve/src/cli/dev/tui/runner.ts b/packages/eve/src/cli/dev/tui/runner.ts index f9d67955a..4622d72ae 100644 --- a/packages/eve/src/cli/dev/tui/runner.ts +++ b/packages/eve/src/cli/dev/tui/runner.ts @@ -330,10 +330,8 @@ export type AgentTUIRenderer = { flushDelayedDevBuildErrors?(): void; /** * Sets the workspace-scoped Vercel segment of the persistent bottom - * status line: linked project identity and the session's pending-deploy - * flag. Pushed by the runner at startup (async probe) and after - * /vercel, /channels, /deploy outcomes. Renderers without a status - * line ignore it. + * status line. Pushed by the runner at startup and after Vercel-related + * setup outcomes. Renderers without a status line ignore it. */ setVercelStatus?(status: VercelStatusSnapshot): void; /** Sets the remote deployment badge and its current connection/authentication state. */ 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 4c3322e3e..54d18d6a1 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.test.ts @@ -2,8 +2,6 @@ import { describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import { HumanActionRequiredError } from "#setup/human-action.js"; -import { openUrl } from "#setup/primitives/open-url.js"; -import { WizardCancelledError } from "#setup/step.js"; import { runTuiSetupCommand, @@ -13,10 +11,6 @@ import { type TuiSetupFlows, } from "./setup-commands.js"; -// runDeployAndChat opens the chat URL in a browser; stub the opener so the unit -// test never spawns a real OS process. -vi.mock("#setup/primitives/open-url.js", () => ({ openUrl: vi.fn() })); - const APP_ROOT = "/tmp/weather-agent"; function fakePanelRenderer(): TuiSetupCommandRenderer & { @@ -60,10 +54,6 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { kind: "done", modelMessage: "Model changed to openai/gpt-5.5. Live on your next prompt.", })), - runChannelsFlow: vi.fn(async () => ({ - kind: "done", - addedChannels: [], - })), runConnectionsFlow: vi.fn(async () => ({ kind: "done", addedConnections: [], @@ -81,7 +71,7 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { } function run(input: { - command: "vc:install" | "vc:login" | "model" | "channels" | "connect" | "add" | "deploy"; + command: "vc:install" | "vc:login" | "model" | "connect" | "add" | "deploy"; flows: TuiSetupFlows; renderer?: TuiSetupCommandRenderer; initialModelStep?: "provider"; @@ -126,7 +116,6 @@ describe("runTuiSetupCommand", () => { "vc:install": "pulse", "vc:login": "pulse", model: "pulse", - channels: "pulse", connect: "pulse", add: "pulse", deploy: "spinner", @@ -342,134 +331,6 @@ describe("runTuiSetupCommand", () => { }); }); - it("reports the added channels with the deploy hint", async () => { - const flows = fakeFlows({ - runChannelsFlow: vi.fn(async () => ({ - kind: "done", - addedChannels: ["slack"], - })), - }); - - const notice = await run({ command: "channels", flows }); - - expect(notice).toEqual({ - message: "Channels added: slack — run /deploy to ship them.", - preserveFlowDiagnostics: true, - effect: { kind: "channels-added" }, - }); - expect(flows.runChannelsFlow).toHaveBeenCalledWith( - expect.objectContaining({ appRoot: APP_ROOT }), - ); - }); - - it("deploys, then opens and surfaces the Slack message deep link on deploy-and-chat", async () => { - vi.mocked(openUrl).mockClear(); - const flows = fakeFlows({ - runChannelsFlow: vi.fn(async () => ({ - kind: "deploy-and-chat", - addedChannels: ["slack"], - chat: { chatUrl: "https://slack.com/app_redirect?app=A0&team=T0", workspaceName: "Acme" }, - })), - }); - - const notice = await run({ command: "channels", flows }); - - // The app_redirect link is upgraded to the Messages tab (a DM compose) and - // opened in the browser — nothing else opens one at this step. - const expectedUrl = "https://slack.com/app_redirect?app=A0&team=T0&tab=messages"; - expect(vi.mocked(openUrl)).toHaveBeenCalledWith(expectedUrl); - expect(notice).toEqual({ - message: - "Deployed: https://my-agent.vercel.app\n" + `Chat with your agent in Slack: ${expectedUrl}`, - preserveFlowDiagnostics: true, - effect: { kind: "deployed" }, - }); - expect(flows.runDeployFlow).toHaveBeenCalledWith( - expect.objectContaining({ interactive: true }), - ); - }); - - it("reports the deploy outcome plainly when no Slack workspace URL is known", async () => { - const flows = fakeFlows({ - runChannelsFlow: vi.fn(async () => ({ - kind: "deploy-and-chat", - addedChannels: ["slack"], - chat: {}, - })), - }); - - const notice = await run({ command: "channels", flows }); - - expect(notice).toEqual({ - message: "Deployed: https://my-agent.vercel.app\nMessage your agent in Slack to see it live.", - preserveFlowDiagnostics: true, - effect: { kind: "deployed" }, - }); - }); - - it("keeps the added channels pending when deploy-and-chat is cancelled", async () => { - const flows = fakeFlows({ - runChannelsFlow: vi.fn(async () => ({ - kind: "deploy-and-chat", - addedChannels: ["slack"], - chat: {}, - })), - runDeployFlow: vi.fn(async () => ({ kind: "cancelled" })), - }); - - await expect(run({ command: "channels", flows })).resolves.toEqual({ - message: "Channels added, but /deploy was dismissed. Run /deploy to ship them.", - preserveFlowDiagnostics: true, - effect: { kind: "channels-added" }, - }); - }); - - it("keeps the added channels pending when deploy-and-chat needs a link", async () => { - const flows = fakeFlows({ - runChannelsFlow: vi.fn(async () => ({ - kind: "deploy-and-chat", - addedChannels: ["slack"], - chat: {}, - })), - runDeployFlow: vi.fn(async () => ({ kind: "needs-link" })), - }); - - await expect(run({ command: "channels", flows })).resolves.toEqual({ - message: - "Channels added, but this directory is not linked to Vercel. Run /model, then /deploy.", - preserveFlowDiagnostics: true, - effect: { kind: "channels-added" }, - }); - }); - - it("keeps the added channels pending when deploy-and-chat fails", async () => { - const flows = fakeFlows({ - runChannelsFlow: vi.fn(async () => ({ - kind: "deploy-and-chat", - addedChannels: ["slack"], - chat: {}, - })), - runDeployFlow: vi.fn(async () => { - throw new Error("build failed"); - }), - }); - - await expect(run({ command: "channels", flows })).resolves.toEqual({ - message: "Channels added, but /deploy failed: build failed", - preserveFlowDiagnostics: true, - effect: { kind: "channels-added" }, - }); - }); - - it("reports an empty channels pick", async () => { - const notice = await run({ command: "channels", flows: fakeFlows() }); - - expect(notice).toEqual({ - message: "No channels added.", - preserveFlowDiagnostics: true, - }); - }); - it.each([ [ "configured", @@ -532,23 +393,6 @@ describe("runTuiSetupCommand", () => { expect(runRegistryFlow).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 () => ({ - kind: "failed", - addedChannels: ["slack"], - message: "Slack connector UID update is required before deployment.", - })), - }); - - await expect(run({ command: "channels", flows })).resolves.toEqual({ - message: - "Channel files changed, but /channels failed: Slack connector UID update is required before deployment.", - preserveFlowDiagnostics: true, - effect: { kind: "channels-added" }, - }); - }); - it("reports the production URL after a deploy", async () => { const flows = fakeFlows(); await expect(run({ command: "deploy", flows })).resolves.toEqual({ @@ -561,104 +405,6 @@ describe("runTuiSetupCommand", () => { ); }); - it("folds flow errors and cancellations into the notice", async () => { - const failing = fakeFlows({ - runChannelsFlow: vi.fn(async () => { - throw new Error("vercel CLI not found"); - }), - }); - await expect(run({ command: "channels", flows: failing })).resolves.toEqual({ - message: "/channels failed: vercel CLI not found", - tone: "error", - preserveFlowDiagnostics: true, - }); - - const cancelling = fakeFlows({ - runDeployFlow: vi.fn(async () => { - throw new WizardCancelledError(); - }), - }); - await expect(run({ command: "deploy", flows: cancelling })).resolves.toEqual({ - message: "/deploy dismissed.", - preserveFlowDiagnostics: true, - }); - }); - - it("clears the flow status even when the flow throws mid-wait", async () => { - const renderer = fakePanelRenderer(); - const flows = fakeFlows({ - runChannelsFlow: vi.fn(async () => { - renderer.setStatus("Checking the current Vercel link..."); - throw new Error("network down"); - }), - }); - - await expect(run({ command: "channels", flows, renderer })).resolves.toEqual({ - message: "/channels failed: network down", - tone: "error", - preserveFlowDiagnostics: true, - }); - expect(renderer.setStatus).toHaveBeenLastCalledWith(undefined); - }); - - it("retains command ownership until an interrupted flow finishes unwinding", async () => { - const renderer = fakePanelRenderer(); - let releaseFlow: () => void = () => {}; - const flows = fakeFlows({ - runChannelsFlow: vi.fn( - () => - new Promise((resolve) => { - releaseFlow = () => resolve({ kind: "cancelled" }); - }), - ), - }); - - const result = run({ command: "channels", flows, renderer }); - let settled = false; - void result.finally(() => { - settled = true; - }); - renderer.fireInterrupt(); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(settled).toBe(false); - - releaseFlow(); - await expect(result).resolves.toEqual({ - message: "/channels interrupted.", - preserveFlowDiagnostics: true, - }); - expect(renderer.interruptDisposed()).toBe(true); - expect(renderer.setStatus).toHaveBeenLastCalledWith(undefined); - }); - - it("keeps channels pending when deploy-and-chat is interrupted", async () => { - const renderer = fakePanelRenderer(); - const flows = fakeFlows({ - runChannelsFlow: vi.fn(async () => ({ - kind: "deploy-and-chat", - addedChannels: ["slack"], - chat: {}, - })), - runDeployFlow: vi.fn( - ({ signal }) => - new Promise((resolve) => { - signal?.addEventListener("abort", () => resolve({ kind: "cancelled" }), { once: true }); - }), - ), - }); - - const result = run({ command: "channels", flows, renderer }); - await vi.waitFor(() => expect(flows.runDeployFlow).toHaveBeenCalled()); - renderer.fireInterrupt(); - - await expect(result).resolves.toEqual({ - message: "/channels interrupted.", - preserveFlowDiagnostics: true, - effect: { kind: "channels-added" }, - }); - }); - it("preserves model access refreshes when provider setup is interrupted", async () => { const renderer = fakePanelRenderer(); const flows = fakeFlows({ @@ -698,43 +444,6 @@ describe("runTuiSetupCommand", () => { }); }); - it("keeps cleanup diagnostics while muting abandoned progress after an interrupt", async () => { - const renderer = fakePanelRenderer(); - let releaseFlow: () => void = () => {}; - let flowSignal: AbortSignal | undefined; - const flows = fakeFlows({ - runChannelsFlow: vi.fn(async ({ prompter, signal }) => { - flowSignal = signal; - await new Promise((resolve) => { - releaseFlow = resolve; - }); - // The abandoned flow resumes after the subprocess finally settles. - // Narrative progress and prompts stay muted, but cleanup diagnostics - // must survive so the closed panel can persist them. - prompter.log.info("late line"); - prompter.log.warning("cleanup could not be verified"); - await prompter.select({ message: "late question", options: [{ value: "a", label: "A" }] }); - return { kind: "done", addedChannels: [] }; - }), - }); - - // The real TUI prompter, so the muted renderer is actually exercised. - const result = runTuiSetupCommand({ command: "channels", appRoot: APP_ROOT, renderer, flows }); - renderer.fireInterrupt(); - await vi.waitFor(() => expect(flowSignal?.aborted).toBe(true)); - - vi.mocked(renderer.renderLine).mockClear(); - vi.mocked(renderer.readSelect).mockClear(); - releaseFlow(); - await expect(result).resolves.toMatchObject({ - message: "/channels interrupted.", - }); - - expect(renderer.renderLine).toHaveBeenCalledOnce(); - expect(renderer.renderLine).toHaveBeenCalledWith("cleanup could not be verified", "warning"); - expect(renderer.readSelect).not.toHaveBeenCalled(); - }); - it("reports a completed login and refreshes the link identity", async () => { const flows = fakeFlows({ runLoginFlow: vi.fn(async () => ({ kind: "logged-in" })), @@ -866,45 +575,4 @@ describe("runTuiSetupCommand", () => { preserveFlowDiagnostics: false, }); }); - - it("routes a /channels provisioning login error to /vc:login (not the raw message)", async () => { - // Provisioning throws before any channel lands, so the flow re-throws and - // the command catch routes it — the same path /deploy uses. - const flows = fakeFlows({ - runChannelsFlow: vi.fn(async () => { - throw new HumanActionRequiredError({ - kind: "vercel-login", - command: "vercel login", - reason: "not logged in", - }); - }), - }); - await expect(run({ command: "channels", flows })).resolves.toEqual({ - message: "You're not logged in to Vercel — run /vc:login, then retry /channels.", - preserveFlowDiagnostics: true, - }); - }); - - it("routes a deploy-and-chat login action to /vc:login while keeping channels added", async () => { - const flows = fakeFlows({ - runChannelsFlow: vi.fn(async () => ({ - kind: "deploy-and-chat", - addedChannels: ["slack"], - chat: {}, - })), - runDeployFlow: vi.fn(async () => { - throw new HumanActionRequiredError({ - kind: "vercel-login", - command: "vercel login", - reason: "not logged in", - }); - }), - }); - await expect(run({ command: "channels", flows })).resolves.toEqual({ - message: - "Channels added. You're not logged in to Vercel — run /vc:login, then retry /deploy.", - preserveFlowDiagnostics: true, - effect: { kind: "channels-added" }, - }); - }); }); diff --git a/packages/eve/src/cli/dev/tui/setup-commands.ts b/packages/eve/src/cli/dev/tui/setup-commands.ts index 2c5cec524..293e1f9f9 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.ts @@ -1,5 +1,4 @@ import { HumanActionRequiredError } from "#setup/human-action.js"; -import { runChannelsFlow } from "#setup/flows/channels.js"; import { runConnectionsFlow } from "#setup/flows/connections.js"; import { runDeployFlow } from "#setup/flows/deploy.js"; import { @@ -10,9 +9,7 @@ import { runLoginFlow, type LoginFlowResult } from "#setup/flows/login.js"; import { runModelFlow, type ModelProviderOutcome } from "#setup/flows/model.js"; import { runProviderFlow, type ProviderPicker } from "#setup/flows/provider.js"; import { runRegistryFlow } from "#setup/flows/registry.js"; -import { openUrl } from "#setup/primitives/open-url.js"; import type { Prompter } from "#setup/prompter.js"; -import { slackMessageDeepLink } from "#setup/slack-connect.js"; import { WizardCancelledError } from "#setup/step.js"; import { createTuiPrompter, type TuiPrompterRenderer } from "./tui-prompter.js"; @@ -31,7 +28,6 @@ export const SETUP_FLOW_CONFIG = { "vc:install": { title: "Install the Vercel CLI", indicator: "pulse" }, "vc:login": { title: "Log in to Vercel", indicator: "pulse" }, model: { title: "Configure the agent model", indicator: "pulse" }, - channels: { title: "Agent channels", indicator: "pulse" }, connect: { title: "Agent connections", indicator: "pulse" }, add: { title: "Add to your agent", indicator: "pulse" }, deploy: { title: "Deploy to Vercel", indicator: "spinner" }, @@ -66,7 +62,6 @@ export interface TuiSetupFlows { runInstallVercelCliFlow: typeof runInstallVercelCliFlow; runLoginFlow: typeof runLoginFlow; runModelFlow: typeof runModelFlow; - runChannelsFlow: typeof runChannelsFlow; runConnectionsFlow: typeof runConnectionsFlow; runRegistryFlow: typeof runRegistryFlow; runDeployFlow: typeof runDeployFlow; @@ -127,7 +122,7 @@ function muteableRenderer( } /** - * Runs one TUI setup command (/model, /channels, /connect, /add, /deploy) over the + * Runs one TUI setup command (/model, /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 @@ -176,7 +171,6 @@ async function executeSetupCommand( runInstallVercelCliFlow, runLoginFlow, runModelFlow, - runChannelsFlow, runConnectionsFlow, runRegistryFlow, runDeployFlow, @@ -237,32 +231,6 @@ async function executeSetupCommand( } return outcome; } - case "channels": { - const result = await flows.runChannelsFlow({ appRoot, prompter, signal }); - switch (result.kind) { - case "failed": - // A provisioning failure (login / forbidden / missing CLI) throws - // before any channel file lands, so it propagates to the catch below - // and routes to its fix command; a `failed` result here is a - // post-scaffold fault (e.g. a UID reconcile), reported as-is. - return pendingChannelsResult( - `Channel files changed, but /channels failed: ${result.message}`, - ); - case "cancelled": - return { message: "/channels dismissed.", preserveFlowDiagnostics: true }; - case "deploy-and-chat": - return await runDeployAndChat(flows, { appRoot, prompter, signal }, result.chat); - case "done": - if (result.addedChannels.length === 0) { - return { message: "No channels added.", preserveFlowDiagnostics: true }; - } - return { - message: `Channels added: ${result.addedChannels.join(", ")} — run /deploy to ship them.`, - preserveFlowDiagnostics: true, - effect: { kind: "channels-added" }, - }; - } - } case "connect": { const result = await flows.runConnectionsFlow({ appRoot, @@ -458,9 +426,8 @@ function vercelCliUpgradeFailureMessage(command: string, reason?: string): strin /** * Translates a Vercel {@link HumanActionRequiredError} into the in-TUI routing * message, or `undefined` for anything else. One translator so every path that - * can surface a provisioning action — the command catch, the `/channels` - * partial-success result, and the deploy-and-chat continuation — routes - * login, forbidden-scope, and CLI recovery actions the same way rather than + * can surface a provisioning action, routing login, forbidden-scope, and CLI + * recovery actions the same way rather than * leaking the raw error text. */ function vercelActionOutcome(error: unknown, command: string): TuiSetupCommandResult | undefined { @@ -485,60 +452,6 @@ function vercelActionMessage(kind: string, command: string): string | undefined } } -/** - * The "Deploy and chat" continuation of /channels: deploy the freshly added - * Slack channel, then point the user at the workspace so they can message the - * bot. A cancelled or unlinked deploy reports exactly like /deploy and drops - * the chat hint — there is nothing live to chat with yet. - */ -async function runDeployAndChat( - flows: TuiSetupFlows, - input: { appRoot: string; prompter: Prompter; signal: AbortSignal }, - chat: { chatUrl?: string; workspaceName?: string }, -): Promise { - let result: Awaited>; - try { - result = await flows.runDeployFlow({ ...input, interactive: true }); - } catch (error) { - if (error instanceof WizardCancelledError) { - return pendingChannelsResult( - "Channels added, but /deploy was dismissed. Run /deploy to ship them.", - ); - } - const routed = vercelActionOutcome(error, "deploy"); - if (routed !== undefined) return pendingChannelsResult(`Channels added. ${routed.message}`); - const message = error instanceof Error ? error.message : String(error); - return pendingChannelsResult(`Channels added, but /deploy failed: ${message}`); - } - if (result.kind === "cancelled") { - return pendingChannelsResult( - "Channels added, but /deploy was dismissed. Run /deploy to ship them.", - ); - } - if (result.kind === "needs-link") { - return pendingChannelsResult( - "Channels added, but this directory is not linked to Vercel. Run /model, then /deploy.", - ); - } - const live = - result.productionUrl === undefined ? "Deployed." : `Deployed: ${result.productionUrl}`; - let chatLine: string; - if (chat.chatUrl === undefined) { - chatLine = "Message your agent in Slack to see it live."; - } else { - // Open the bot's Messages tab (a DM compose) ourselves. Unlike - // `connect create`, nothing else opens a browser at this step. - const chatUrl = slackMessageDeepLink(chat.chatUrl); - openUrl(chatUrl); - chatLine = `Chat with your agent in Slack: ${chatUrl}`; - } - return { - message: `${live}\n${chatLine}`, - preserveFlowDiagnostics: true, - effect: { kind: "deployed" }, - }; -} - /** Folds an {@link InstallVercelCliResult} into the command's one-line outcome. */ function installVercelCliResultMessage(result: InstallVercelCliResult): TuiSetupCommandResult { switch (result.kind) { @@ -596,14 +509,6 @@ function loginResultMessage(result: LoginFlowResult): TuiSetupCommandResult { } } -function pendingChannelsResult(message: string): TuiSetupCommandResult { - return { - message, - preserveFlowDiagnostics: true, - effect: { kind: "channels-added" }, - }; -} - /** * The persistent outcome line for /model's completed provider sub-flow. The * panel's success lines vanish with it, so the outcome carries the substance: diff --git a/packages/eve/src/cli/dev/tui/status-line.test.ts b/packages/eve/src/cli/dev/tui/status-line.test.ts index a0d2c35c7..5611bcee7 100644 --- a/packages/eve/src/cli/dev/tui/status-line.test.ts +++ b/packages/eve/src/cli/dev/tui/status-line.test.ts @@ -54,12 +54,12 @@ describe("buildStatusLine", () => { const line = buildStatusLine({ model: "anthropic/claude-sonnet-5", endpoint: connected, - vercel: { identity, pendingDeploy: true }, + vercel: { identity }, theme: plain, width: 120, }); - expect(line).toBe("anthropic/claude-sonnet-5 via ai-gateway(oidc:my-agent) /deploy pending"); + expect(line).toBe("anthropic/claude-sonnet-5 via ai-gateway(oidc:my-agent)"); }); it("folds the reasoning level and Fast mode marker into the model segment", () => { @@ -68,7 +68,7 @@ describe("buildStatusLine", () => { reasoning: "xhigh", fastMode: true, endpoint: connected, - vercel: { identity, pendingDeploy: false }, + vercel: { identity }, theme: plain, width: 120, }); @@ -112,25 +112,23 @@ describe("buildStatusLine", () => { ).toBe(" ↗ vpoke.playground-vercel.tools openai/gpt-5@high ↯"); }); - it("dims every segment except the yellow pending-deploy marker", () => { + it("dims the model segment", () => { const line = buildStatusLine({ model: "anthropic/claude-sonnet-5", endpoint: connected, - vercel: { identity, pendingDeploy: true }, + vercel: { identity }, theme, width: 120, }); expect(line).toContain("\x1b[2manthropic/claude-sonnet-5\x1b[22m"); - expect(line).toContain("\x1b[33m/deploy pending\x1b[39m"); - expect(line).not.toContain("\x1b[2m/deploy pending"); }); it("folds the linked project name into the connected gateway label", () => { const withProject = buildStatusLine({ model: "m", endpoint: connected, - vercel: { identity, pendingDeploy: false }, + vercel: { identity }, theme: plain, width: 120, }); @@ -146,21 +144,12 @@ describe("buildStatusLine", () => { expect(noProject).toBe("m via ai-gateway(oidc)"); }); - it("renders the pending marker even when no segment else resolved", () => { - const line = buildStatusLine({ - vercel: { pendingDeploy: true }, - theme: plain, - width: 120, - }); - expect(line).toBe("/deploy pending"); - }); - it("leads with the transient logs hint and keeps it as width narrows", () => { const input = { logLevel: "sandbox", model: "anthropic/claude-sonnet-5", endpoint: connected, - vercel: { identity, pendingDeploy: true }, + vercel: { identity }, theme: plain, } as const; @@ -177,16 +166,14 @@ describe("buildStatusLine", () => { it("returns undefined when every segment is empty", () => { expect(buildStatusLine({ theme: plain, width: 120 })).toBeUndefined(); - expect( - buildStatusLine({ vercel: { pendingDeploy: false }, theme: plain, width: 120 }), - ).toBeUndefined(); + expect(buildStatusLine({ vercel: {}, theme: plain, width: 120 })).toBeUndefined(); }); it("drops the endpoint, then the model, as the width narrows", () => { const input = { model: "anthropic/claude-sonnet-5", endpoint: connected, - vercel: { identity, pendingDeploy: true }, + vercel: { identity }, theme: plain, }; const full = buildStatusLine({ ...input, width: 200 })!; @@ -197,7 +184,7 @@ describe("buildStatusLine", () => { expect(noEndpoint).toContain("anthropic/claude-sonnet-5"); const noModel = buildStatusLine({ ...input, width: visibleLength(noEndpoint) - 1 })!; - expect(noModel).toBe("/deploy pending"); + expect(noModel).not.toContain("ai-gateway"); }); it("renders the three model-endpoint states", () => { @@ -212,7 +199,7 @@ describe("buildStatusLine", () => { const linked = buildStatusLine({ model: "m", endpoint: connected, - vercel: { identity, pendingDeploy: false }, + vercel: { identity }, theme: plain, width: 120, }); @@ -223,7 +210,7 @@ describe("buildStatusLine", () => { endpoint: { kind: "gateway", connected: true, credential: "api-key" }, // A linked project must NOT surface here: the key is what // authenticates, and the bar reports the credential in use. - vercel: { identity, pendingDeploy: false }, + vercel: { identity }, theme: plain, width: 120, }); diff --git a/packages/eve/src/cli/dev/tui/status-line.ts b/packages/eve/src/cli/dev/tui/status-line.ts index 2d5597000..2f3b215f1 100644 --- a/packages/eve/src/cli/dev/tui/status-line.ts +++ b/packages/eve/src/cli/dev/tui/status-line.ts @@ -107,7 +107,7 @@ function renderEndpoint( /** * Builds a leading local `:port` or remote badge followed by the model (with - * its reasoning level and Fast mode marker), endpoint, and deploy status + * its reasoning level and Fast mode marker), and endpoint * segments. Both badges are the final narrow-width fallback. Remote sessions * omit endpoint state. Returns undefined when every segment is empty. */ @@ -118,7 +118,6 @@ export function buildStatusLine(input: StatusLineInput): string | undefined { const logLevel = input.logLevel === undefined ? undefined : c.cyan(`logs: ${input.logLevel}`); const serverPort = renderServerPort(input); const model = renderModel(input); - const pending = input.vercel?.pendingDeploy ? c.yellow("/deploy pending") : undefined; const remote = input.remote === undefined ? undefined : formatRemoteStatus(input.remote, theme); const endpoint = renderEndpoint(input); const leading = remote?.full ?? serverPort; @@ -145,9 +144,9 @@ export function buildStatusLine(input: StatusLineInput): string | undefined { // leads every variant and gets the final stand-alone fallback. Without one, // the logs hint retains its previous priority. const variants = [ - compose(leading, [logLevel, modelSegment, endpointSegment, pending]), - compose(leading, [logLevel, model, pending]), - compose(leading, [logLevel, pending]), + compose(leading, [logLevel, modelSegment, endpointSegment]), + compose(leading, [logLevel, model]), + compose(leading, [logLevel]), compose(leading, [logLevel]), compose(badge, [logLevel]), compose(badge, []), 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 5fbf83fb6..c37df078a 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -4531,7 +4531,6 @@ describe("TerminalRenderer command typeahead", () => { describe("TerminalRenderer status line", () => { const vercelStatus = { identity: { projectName: "my-agent", teamName: "acme" }, - pendingDeploy: false, }; it("renders the local server, model, and Vercel link under the prompt row", async () => { @@ -4581,16 +4580,6 @@ describe("TerminalRenderer status line", () => { renderer.shutdown(); }); - it("marks a pending deploy in yellow", () => { - const { screen, renderer } = makeRenderer(); - renderer.renderNotice("anchor"); - renderer.setVercelStatus({ ...vercelStatus, pendingDeploy: true }); - - expect(screen.snapshot()).toContain("/deploy pending"); - expect(screen.rawOutput()).toContain("/deploy pending"); - renderer.shutdown(); - }); - it("suppresses the status line while a setup flow panel is open", () => { const { screen, renderer } = makeRenderer(); renderer.renderNotice("anchor"); @@ -4737,7 +4726,7 @@ describe("TerminalRenderer status line", () => { credential: "oidc", }), }); - renderer.setVercelStatus({ ...vercelStatus, pendingDeploy: true }); + renderer.setVercelStatus({ ...vercelStatus }); await renderer.renderStream( streamOf([ { type: "step-start" }, @@ -4754,7 +4743,6 @@ describe("TerminalRenderer status line", () => { const snapshot = screen.snapshot(); expect(snapshot).toContain("anthropic/claude-sonnet-5"); expect(snapshot).toContain("via ai-gateway(oidc:my-agent)"); - expect(snapshot).toContain("/deploy pending"); // A fresh conversation clears the token flow entirely (↑ 0 ↓ 0 is noise). expect(snapshot).not.toContain("↑ 0"); expect(snapshot).not.toContain("↑ 500"); diff --git a/packages/eve/src/cli/dev/tui/vercel-status.test.ts b/packages/eve/src/cli/dev/tui/vercel-status.test.ts index 57eab4b5b..fcb9c3be8 100644 --- a/packages/eve/src/cli/dev/tui/vercel-status.test.ts +++ b/packages/eve/src/cli/dev/tui/vercel-status.test.ts @@ -30,8 +30,8 @@ describe("createVercelStatusTracker", () => { tracker.refreshIdentity(); await settled(); - expect(snapshots).toEqual([{ identity, pendingDeploy: false }]); - expect(tracker.current()).toEqual({ identity, pendingDeploy: false }); + expect(snapshots).toEqual([{ identity }]); + expect(tracker.current()).toEqual({ identity }); }); it("emits a snapshot without identity for an unlinked directory", async () => { @@ -45,7 +45,7 @@ describe("createVercelStatusTracker", () => { tracker.refreshIdentity(); await settled(); - expect(snapshots).toEqual([{ pendingDeploy: false }]); + expect(snapshots).toEqual([{}]); expect(tracker.current().identity).toBeUndefined(); }); @@ -71,7 +71,7 @@ describe("createVercelStatusTracker", () => { resolveSlow!({ projectName: "stale", teamName: "stale-team" }); await settled(); - expect(snapshots).toEqual([{ identity: { projectName: "newer" }, pendingDeploy: false }]); + expect(snapshots).toEqual([{ identity: { projectName: "newer" } }]); }); it("keeps the last identity when a probe throws", async () => { @@ -91,32 +91,8 @@ describe("createVercelStatusTracker", () => { tracker.refreshIdentity(); await settled(); - expect(snapshots).toEqual([{ identity, pendingDeploy: false }]); - expect(tracker.current()).toEqual({ identity, pendingDeploy: false }); - }); - - it("sets pending on channels-added and clears it on deployed", async () => { - const { snapshots, onChange } = collect(); - let probes = 0; - const tracker = createVercelStatusTracker({ - appRoot: "/app", - onChange, - detectIdentity: async () => { - probes += 1; - return identity; - }, - }); - - tracker.applyEffect({ kind: "channels-added" }); - expect(tracker.current().pendingDeploy).toBe(true); - - tracker.applyEffect({ kind: "deployed" }); - await settled(); - - expect(tracker.current()).toEqual({ identity, pendingDeploy: false }); - // The deployed effect re-probes: the deploy flow can create the link. - expect(probes).toBe(1); - expect(snapshots.map((s) => s.pendingDeploy)).toEqual([true, false, false]); + expect(snapshots).toEqual([{ identity }]); + expect(tracker.current()).toEqual({ identity }); }); it("re-probes on refresh-identity without emitting until the probe lands", async () => { @@ -131,7 +107,7 @@ describe("createVercelStatusTracker", () => { expect(snapshots).toEqual([]); await settled(); - expect(snapshots).toEqual([{ identity, pendingDeploy: false }]); + expect(snapshots).toEqual([{ identity }]); }); it("suppresses emissions after dispose, including in-flight probes", async () => { @@ -145,7 +121,7 @@ describe("createVercelStatusTracker", () => { tracker.refreshIdentity(); tracker.dispose(); await settled(); - tracker.applyEffect({ kind: "channels-added" }); + tracker.applyEffect({ kind: "refresh-identity" }); expect(snapshots).toEqual([]); }); diff --git a/packages/eve/src/cli/dev/tui/vercel-status.ts b/packages/eve/src/cli/dev/tui/vercel-status.ts index 994c089bf..f5619be1d 100644 --- a/packages/eve/src/cli/dev/tui/vercel-status.ts +++ b/packages/eve/src/cli/dev/tui/vercel-status.ts @@ -4,8 +4,6 @@ import { detectProjectIdentity, type ProjectIdentity } from "#setup/project-reso export interface VercelStatusSnapshot { /** Resolved link identity; absent while unlinked or while a probe is in flight. */ identity?: ProjectIdentity; - /** A /channels run added ≥1 channel this session and no /deploy has shipped since. */ - pendingDeploy: boolean; } /** @@ -13,10 +11,7 @@ export interface VercelStatusSnapshot { * Session-scoped by design: a deploy from another terminal or channels added * before this session escape it — accepted v1 limits. */ -export type VercelStatusEffect = - | { kind: "channels-added" } - | { kind: "deployed" } - | { kind: "refresh-identity" }; +export type VercelStatusEffect = { kind: "deployed" } | { kind: "refresh-identity" }; export interface VercelStatusTrackerOptions { /** Absolute local application root holding the `.vercel` link directory. */ @@ -29,8 +24,7 @@ export interface VercelStatusTrackerOptions { /** * Owns the Vercel segment of the dev TUI status line: one cached link - * identity and the session-scoped pending-deploy flag. The identity probe is - * network-bound (it shells `vercel api`), so it runs only at startup and + * identity. The identity probe is network-bound (it shells `vercel api`), so it runs only at startup and * after provider setup or a /deploy — never on a poll. A linked directory * whose `vercel` CLI call fails resolves to the raw project id as the name * (see {@link detectProjectIdentity}); an unlinked one resolves to no identity, @@ -51,7 +45,6 @@ export function createVercelStatusTracker( ): VercelStatusTracker { const detectIdentity = options.detectIdentity ?? detectProjectIdentity; let identity: ProjectIdentity | undefined; - let pendingDeploy = false; // Incremented on every refresh and on dispose, so a slow probe that loses // the race (e.g. startup probe vs. a /vercel re-link's probe) can never // overwrite the newer result. @@ -59,11 +52,7 @@ export function createVercelStatusTracker( let disposed = false; let identityProbeAbort: AbortController | undefined; - const snapshot = (): VercelStatusSnapshot => { - const current: VercelStatusSnapshot = { pendingDeploy }; - if (identity !== undefined) current.identity = identity; - return current; - }; + const snapshot = (): VercelStatusSnapshot => (identity === undefined ? {} : { identity }); const emit = (): void => { if (disposed) return; @@ -101,12 +90,7 @@ export function createVercelStatusTracker( applyEffect(effect) { if (disposed) return; switch (effect.kind) { - case "channels-added": - pendingDeploy = true; - emit(); - return; case "deployed": - pendingDeploy = false; emit(); // A deploy can create the link (the flow walks the pickers when // unlinked), so the identity may have just come into existence. diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index 620accd40..feccb8736 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -192,21 +192,9 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm }, }); - const channels = program + program .command("channels") - .description("Manage user-authored channels in the current project."); - - channels - .command("add [kind]") - .description("Add channels interactively, or scaffold a channel kind (slack | web).") - .option("-f, --force", "Overwrite existing channel files") - .option("-y, --yes", "Assume yes for confirmations; requires an explicit channel kind") - .action(async (kind: string | undefined, options: { force?: boolean; yes?: boolean }) => { - const { runChannelsAddCompatibilityCommand } = await import("#cli/commands/channels.js"); - await runChannelsAddCompatibilityCommand(logger, appRoot, { kind, options }); - }); - - channels + .description("Manage user-authored channels in the current project.") .command("list") .description("List user-authored channels in the current project.") .option("--json", "Output as JSON") diff --git a/packages/eve/src/setup/boxes/deploy-project.ts b/packages/eve/src/setup/boxes/deploy-project.ts index b50343b71..1b24f97c3 100644 --- a/packages/eve/src/setup/boxes/deploy-project.ts +++ b/packages/eve/src/setup/boxes/deploy-project.ts @@ -36,7 +36,7 @@ export interface DeployProjectOptions { skip?: boolean; /** * Run even without a planned or detected project, linking interactively from - * inside `perform` (the `eve channels add` composition; onboarding always has + * inside `perform` (the `eve add channel/slack` composition; onboarding always has * a plan or skips deploy with the channels). */ ensureLinkedProject?: "interactive-vercel-link"; @@ -75,14 +75,14 @@ export interface DeployProjectPayload { * The project was linked up front by the link box, so `perform` reuses * `state.project` and never triggers a second interactive `vercel link` (the * #1020 deadlock). When no resolution exists (no link box ran, e.g. the - * `eve channels add` composition), it falls back to the interactive bare + * `eve add channel/slack` composition), it falls back to the interactive bare * `vercel link`, or throws {@link HumanActionRequiredError} headlessly. * * Once the project is linked, {@link syncHostFrameworkPreset} runs before the * deploy so a project that gained a host framework (e.g. a web channel) builds * the host app instead of the stale `eve` agent preset. It is a no-op for a * plain agent (no host framework on disk) or an already-correct preset, so - * every deploy surface — `eve channels add`, the dev TUI `/deploy`, onboarding — + * every deploy surface — `eve add channel/slack`, the dev TUI `/deploy`, onboarding — * gets the reconcile without composing a separate box. */ export function deployProject( @@ -143,7 +143,7 @@ export function deployProject( // The directory is now linked, so align the project's Framework Preset with // the host framework on disk before deploying — deploy is deliberately the - // only place this server-side setting is touched, so a local `/channels` add + // only place this server-side setting is touched, so a local integration add // never mutates deployed settings unless the user is actively deploying. // Best-effort and a no-op for a plain agent or an already-correct preset. await deps.syncHostFrameworkPreset( diff --git a/packages/eve/src/setup/boxes/install-channel-registry-items.test.ts b/packages/eve/src/setup/boxes/install-channel-registry-items.test.ts deleted file mode 100644 index 0456c05a9..000000000 --- a/packages/eve/src/setup/boxes/install-channel-registry-items.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createDefaultSetupState } from "../state.js"; -import { installChannelRegistryItems } from "./install-channel-registry-items.js"; - -function state(channels: Array<"slack" | "web">) { - return { - ...createDefaultSetupState(), - projectPath: { kind: "resolved" as const, inPlace: true, path: "/project" }, - channelSelection: channels, - }; -} - -describe("installChannelRegistryItems", () => { - it("installs every selected channel item in selection order", async () => { - const installItem = vi.fn(async () => {}); - const box = installChannelRegistryItems({ installItem }); - const setupState = state(["web", "slack"]); - - await box.perform({ state: setupState, input: undefined, sink: { write: () => {} } }); - - expect(installItem.mock.calls).toEqual([ - ["/project", "channel/web"], - ["/project", "channel/slack"], - ]); - }); - - it("skips when no channels are selected", () => { - const box = installChannelRegistryItems({ installItem: vi.fn(async () => {}) }); - expect(box.shouldRun?.(state([]))).toBe(false); - }); -}); diff --git a/packages/eve/src/setup/boxes/install-channel-registry-items.ts b/packages/eve/src/setup/boxes/install-channel-registry-items.ts deleted file mode 100644 index 45030e624..000000000 --- a/packages/eve/src/setup/boxes/install-channel-registry-items.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { AddCommandOptions } from "#cli/commands/registry.js"; - -import type { SetupState } from "../state.js"; -import type { SetupBox } from "../step.js"; - -export interface InstallChannelRegistryItemsOptions { - installItem?: (appRoot: string, item: string, options?: AddCommandOptions) => Promise; -} - -/** Installs registry-owned channel dependencies before integration setup runs. */ -export function installChannelRegistryItems( - options: InstallChannelRegistryItemsOptions = {}, -): SetupBox { - const installItem = - options.installItem ?? - (async (appRoot: string, item: string, addOptions?: AddCommandOptions) => { - const { installOfficialRegistryItem } = await import("#cli/commands/registry.js"); - await installOfficialRegistryItem(appRoot, item, addOptions); - }); - return { - id: "install-channel-registry-items", - shouldRun: (state) => state.channelSelection.length > 0, - gather: async () => undefined, - async perform({ state }) { - if (state.projectPath.kind !== "resolved") { - throw new Error("Expected a resolved project path before installing channel dependencies."); - } - for (const kind of state.channelSelection) { - await installItem(state.projectPath.path, `channel/${kind}`); - } - }, - apply: (state) => state, - }; -} diff --git a/packages/eve/src/setup/boxes/one-shot-next-steps.test.ts b/packages/eve/src/setup/boxes/one-shot-next-steps.test.ts deleted file mode 100644 index 34ce9af5d..000000000 --- a/packages/eve/src/setup/boxes/one-shot-next-steps.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; - -import type { DetectedPackageManager } from "../package-manager.js"; -import type { Prompter } from "../prompter.js"; -import { createDefaultSetupState, type SetupMode, type SetupState } from "../state.js"; -import type { OutputSink } from "../step.js"; -import { runInteractive } from "../runner.js"; -import { oneShotNextSteps } from "./one-shot-next-steps.js"; - -const silentSink: OutputSink = { write: () => {} }; - -function notingPrompter(): { prompter: Prompter; note: ReturnType } { - const base = createFakePrompter().prompter; - const note = vi.fn(); - return { prompter: { ...base, note }, note }; -} - -function scaffoldedState(options: { mode: SetupMode; inPlace: boolean }): SetupState { - return { - ...createDefaultSetupState(), - setupMode: options.mode, - projectPath: { kind: "resolved", inPlace: options.inPlace, path: "/tmp/parent/kall" }, - }; -} - -describe("oneShotNextSteps box", () => { - it("lists the steps in order: cd, pnpm install, vercel link, eve dev", async () => { - const { prompter, note } = notingPrompter(); - const box = oneShotNextSteps({ prompter }); - - await runInteractive([box], scaffoldedState({ mode: "one-shot", inPlace: false }), silentSink); - - expect(note).toHaveBeenCalledOnce(); - const [message, title] = note.mock.calls[0] as [string, string]; - expect(title).toBe("Next steps"); - // The text survives any color wrapping, so order is asserted on indexOf. - const order = ["cd ", "pnpm install", "vercel link", "eve dev"].map((text) => - message.indexOf(text), - ); - expect(order.every((index) => index >= 0)).toBe(true); - expect([...order].sort((a, b) => a - b)).toEqual(order); - // The credential alternative rides the vercel link step, before eve dev. - expect(message).toContain("or set AI_GATEWAY_API_KEY in .env.local manually"); - }); - - it("omits the cd step for an in-place scaffold", async () => { - const { prompter, note } = notingPrompter(); - const box = oneShotNextSteps({ prompter }); - - await runInteractive([box], scaffoldedState({ mode: "one-shot", inPlace: true }), silentSink); - - const [message] = note.mock.calls[0] as [string]; - expect(message).not.toContain("cd "); - expect(message).toContain("pnpm install"); - }); - - it("prints the install command for the scaffold's package manager", async () => { - const { prompter, note } = notingPrompter(); - const box = oneShotNextSteps({ - prompter, - detectPackageManager: vi.fn( - async (): Promise => ({ - kind: "bun", - source: "package-manager-field", - }), - ), - }); - - await runInteractive([box], scaffoldedState({ mode: "one-shot", inPlace: true }), silentSink); - - const [message] = note.mock.calls[0] as [string]; - expect(message).toContain("bun install"); - expect(message).not.toContain("pnpm install"); - }); - - it("self-skips on a complete run", () => { - const { prompter } = notingPrompter(); - const box = oneShotNextSteps({ prompter }); - - expect(box.shouldRun?.(scaffoldedState({ mode: "complete", inPlace: false }))).toBe(false); - }); -}); diff --git a/packages/eve/src/setup/boxes/one-shot-next-steps.ts b/packages/eve/src/setup/boxes/one-shot-next-steps.ts deleted file mode 100644 index d641656ad..000000000 --- a/packages/eve/src/setup/boxes/one-shot-next-steps.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { relative } from "node:path"; - -import pc from "picocolors"; - -import { detectPackageManager } from "../package-manager.js"; -import type { Prompter } from "../prompter.js"; -import { requireProjectPath, type SetupState } from "../state.js"; -import type { SetupBox } from "../step.js"; - -/** - * The path the user types from their shell: relative when the project is under - * the cwd, absolute otherwise (e.g. `--target-dir` pointing elsewhere). - */ -function cdTarget(projectPath: string): string { - const fromCwd = relative(process.cwd(), projectPath); - return fromCwd.length === 0 || fromCwd.startsWith("..") ? projectPath : fromCwd; -} - -export interface OneShotNextStepsOptions { - /** Reports the next-steps note. The box never prompts. */ - prompter: Prompter; - /** Test seam for the package manager that owns generated commands. */ - detectPackageManager?: typeof detectPackageManager; -} - -/** One bold command line, optionally trailed by a dimmed comment. */ -function step(command: string, comment?: string): string { - return ` ${pc.bold(command)}${comment === undefined ? "" : ` ${pc.dim(`# ${comment}`)}`}`; -} - -/** - * THE ONE-SHOT EPILOGUE BOX: a one-shot run skips every post-scaffold box, so - * it ends here with exactly the commands that take the scaffold to a running - * agent. No project or team was resolved, so `vercel link` is the credential - * step: a logged-in CLI plus the link lets the runtime mint an AI Gateway - * token, and pasting a key into `.env.local` is the manual alternative. - */ -export function oneShotNextSteps( - options: OneShotNextStepsOptions, -): SetupBox { - return { - id: "one-shot-next-steps", - - shouldRun(state) { - return state.setupMode === "one-shot"; - }, - - async gather(): Promise { - return null; - }, - - async perform({ state }): Promise { - const projectPath = requireProjectPath(state); - const packageManager = await (options.detectPackageManager ?? detectPackageManager)( - projectPath, - ); - const lines = [ - "Your agent is scaffolded but not deployed or connected yet.", - ...(state.projectPath.inPlace ? [] : [step(`cd ${cdTarget(projectPath)}`)]), - step(`${packageManager.kind} install`), - step("vercel link", "or set AI_GATEWAY_API_KEY in .env.local manually"), - step("eve dev"), - ]; - options.prompter.note(lines.join("\n"), "Next steps", { tone: "success" }); - return null; - }, - - apply(state) { - return state; - }, - }; -} diff --git a/packages/eve/src/setup/boxes/preflight.test.ts b/packages/eve/src/setup/boxes/preflight.test.ts deleted file mode 100644 index 08fb53bdd..000000000 --- a/packages/eve/src/setup/boxes/preflight.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createDefaultSetupState, type SetupState } from "../state.js"; -import type { OutputSink } from "../step.js"; -import { runHeadless, runInteractive } from "../runner.js"; -import { preflight, type PreflightDeps } from "./preflight.js"; - -const silentSink: OutputSink = { write: () => {} }; - -function depsWithCatalog(ids: Set | null): PreflightDeps { - return { fetchGatewayModelIds: vi.fn(async () => ids) }; -} - -function stateWithModel( - modelId: string, - modelWiring: SetupState["modelWiring"] = "gateway", -): SetupState { - return { ...createDefaultSetupState(), modelId, modelWiring }; -} - -describe("preflight box", () => { - it("rejects a headless model that is not in the AI Gateway catalog", async () => { - const box = preflight({ - cwd: "/x", - headless: true, - deps: depsWithCatalog(new Set(["anthropic/claude-sonnet-5"])), - }); - - await expect(runHeadless([box], stateWithModel("anthropic/bogus"), silentSink)).rejects.toThrow( - /not in the AI Gateway catalog/, - ); - }); - - it("accepts a headless model present in the catalog", async () => { - const box = preflight({ - cwd: "/x", - headless: true, - deps: depsWithCatalog(new Set(["anthropic/claude-sonnet-5"])), - }); - - await expect( - runHeadless([box], stateWithModel("anthropic/claude-sonnet-5"), silentSink), - ).resolves.toMatchObject({ modelId: "anthropic/claude-sonnet-5" }); - }); - - it("does not block when the catalog is unreachable", async () => { - const deps = depsWithCatalog(null); - const box = preflight({ cwd: "/x", headless: true, deps }); - - await expect( - runHeadless([box], stateWithModel("anything/at-all"), silentSink), - ).resolves.toBeDefined(); - expect(deps.fetchGatewayModelIds).toHaveBeenCalledWith("/x"); - }); - - it("skips the gateway model fetch in interactive runs", async () => { - const deps = depsWithCatalog(new Set(["anthropic/claude-sonnet-5"])); - const box = preflight({ cwd: "/x", deps }); - - const result = await runInteractive([box], stateWithModel("anthropic/bogus"), silentSink); - - expect(result.kind).toBe("done"); - expect(deps.fetchGatewayModelIds).not.toHaveBeenCalled(); - }); - - it("validates the model for self-managed provider wiring too", async () => { - // The byok scaffold bakes a gateway-format model id just like the gateway - // wiring does, so a headless --model is checked on every wiring. - const deps = depsWithCatalog(new Set(["anthropic/claude-sonnet-5"])); - const box = preflight({ cwd: "/x", headless: true, deps }); - - await expect( - runHeadless([box], stateWithModel("anthropic/bogus", "self"), silentSink), - ).rejects.toThrow(/not in the AI Gateway catalog/); - }); - - it("validates the model when Vercel is skipped but AI Gateway wiring is still used", async () => { - // skip-vercel + a pasted gateway key resolves to gateway wiring, which - // still routes the model through the catalog. - const box = preflight({ - cwd: "/x", - headless: true, - deps: depsWithCatalog(new Set(["anthropic/claude-sonnet-5"])), - }); - const state: SetupState = { - ...stateWithModel("anthropic/bogus", "gateway"), - vercelProject: { kind: "none" }, - aiGateway: { kind: "byok", apiGatewayKey: "vck_test" }, - }; - - await expect(runHeadless([box], state, silentSink)).rejects.toThrow( - /not in the AI Gateway catalog/, - ); - }); -}); diff --git a/packages/eve/src/setup/boxes/preflight.ts b/packages/eve/src/setup/boxes/preflight.ts deleted file mode 100644 index 26b92f8ba..000000000 --- a/packages/eve/src/setup/boxes/preflight.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { fetchGatewayModelIds } from "../gateway-models.js"; -import type { SetupState } from "../state.js"; -import type { SetupBox } from "../step.js"; - -/** - * What the gather decided to validate. `model: null` means nothing to - * validate: interactive runs pick from the gateway-backed catalog already. - */ -export interface PreflightInput { - /** Model id to validate against the AI Gateway catalog, or null to skip. */ - model: string | null; -} - -/** Injected for tests; defaults to the real AI Gateway catalog read. */ -export interface PreflightDeps { - fetchGatewayModelIds: typeof fetchGatewayModelIds; -} - -export interface PreflightOptions { - /** Directory to run `vercel` from: the project's parent (it does not exist yet). */ - cwd: string; - /** - * Headless mode: only a headless `--model` needs catalog validation, so the - * gather derives a model to check from state. Interactive runs pick from the - * gateway-backed catalog and validate nothing. The box prompts for nothing, - * so this dispatch comes from the composition site, not from a question. - */ - headless?: boolean; - deps?: PreflightDeps; -} - -/** - * THE PREFLIGHT BOX: fails fast on an unknown `--model` BEFORE any filesystem - * scaffolding, so a bad flag never leaves a half-provisioned project on disk. - * Only a headless `--model` needs the gateway round-trip, so the interactive - * gather yields a no-op input. The scaffold bakes a gateway-format model id - * on every wiring (the `byok` block routes through the Gateway too), so the - * check applies regardless of how credentials are wired. Team validation - * lives in the resolve-provisioning box. - */ -export function preflight(options: PreflightOptions): SetupBox { - const deps = options.deps ?? { fetchGatewayModelIds }; - - return { - id: "preflight", - - async gather({ state }): Promise { - // Interactive runs pick from the gateway-backed catalog, so there is - // nothing to validate; a headless `--model` is checked on every wiring. - if (!options.headless) return { model: null }; - return { model: state.modelId.length > 0 ? state.modelId : null }; - }, - - async perform({ input }): Promise { - if (input.model !== null) { - await validateModel(deps, input.model, options.cwd); - } - return null; - }, - - apply(state) { - return state; - }, - }; -} - -async function validateModel(deps: PreflightDeps, model: string, cwd: string): Promise { - const ids = await deps.fetchGatewayModelIds(cwd); - // Null = catalog unreachable; don't block creation on the network. - if (ids === null || ids.has(model)) return; - throw new Error( - `Model "${model}" is not in the AI Gateway catalog. Pass a model id from ` + - "https://ai-gateway.vercel.sh/v1/models (e.g. anthropic/claude-sonnet-5).", - ); -} diff --git a/packages/eve/src/setup/boxes/resolve-target.test.ts b/packages/eve/src/setup/boxes/resolve-target.test.ts deleted file mode 100644 index 09dd9af92..000000000 --- a/packages/eve/src/setup/boxes/resolve-target.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; - -import { headlessAsker, InteractionRequired, type Asker } from "../ask.js"; -import { interactiveAsker } from "../ask.js"; -import type { Prompter, PrompterValue } from "../prompter.js"; -import { createDefaultSetupState } from "../state.js"; -import type { OutputSink } from "../step.js"; -import { runHeadless, runInteractive } from "../runner.js"; -import { resolveTarget, type ResolveTargetDeps } from "./resolve-target.js"; - -const silentSink: OutputSink = { write: () => {} }; - -const deps: ResolveTargetDeps = { - pathExists: vi.fn(async () => false), - isEveProject: vi.fn(async () => false), -}; -const mockedPathExists = vi.mocked(deps.pathExists); -const mockedIsEveProject = vi.mocked(deps.isEveProject); - -function unexpectedPrompt(): never { - throw new Error("Unexpected prompt in a resolve-target test."); -} - -/** An interactive asker over a scripted prompter, plus its notice spy. */ -function createAsker(options: { textValues?: string[]; selectValues?: PrompterValue[] } = {}): { - asker: Asker; - prompter: Prompter; -} { - const textValues = [...(options.textValues ?? [])]; - const selectValues = [...(options.selectValues ?? [])]; - const prompter = createFakePrompter({ - text: () => textValues.shift() ?? unexpectedPrompt(), - single: () => selectValues.shift() ?? unexpectedPrompt(), - }).prompter; - return { asker: interactiveAsker(prompter), prompter }; -} - -describe("resolveTarget box", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockedPathExists.mockResolvedValue(false); - mockedIsEveProject.mockResolvedValue(false); - }); - - it("resolves the project path before scaffold writes it", async () => { - const { asker, prompter } = createAsker(); - const box = resolveTarget({ - asker, - notify: prompter.note, - presetName: "my-agent", - targetDirectory: "/tmp/parent", - deps, - }); - - const result = await runInteractive([box], createDefaultSetupState(), silentSink); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.agentName).toBe("my-agent"); - expect(result.state.projectPath).toEqual({ - kind: "resolved", - inPlace: false, - path: "/tmp/parent/my-agent", - }); - }); - - it("rejects a preset name whose directory already exists", async () => { - mockedPathExists.mockImplementation(async (path) => path === "/tmp/parent/demo-agent"); - const { asker, prompter } = createAsker(); - const box = resolveTarget({ - asker, - notify: prompter.note, - presetName: "demo-agent", - targetDirectory: "/tmp/parent", - deps, - }); - - await expect(runInteractive([box], createDefaultSetupState(), silentSink)).rejects.toThrow( - 'Directory "demo-agent" already exists', - ); - }); - - it("resumes an existing eve project when the composition opted in (headless re-runs)", async () => { - mockedPathExists.mockImplementation(async (path) => path === "/tmp/parent/demo-agent"); - mockedIsEveProject.mockResolvedValue(true); - const box = resolveTarget({ - asker: headlessAsker(), - notify: unexpectedPrompt, - presetName: "demo-agent", - targetDirectory: "/tmp/parent", - resumeExisting: true, - deps, - }); - - await expect(runHeadless([box], createDefaultSetupState(), silentSink)).resolves.toMatchObject({ - projectPath: { kind: "resolved", inPlace: false, path: "/tmp/parent/demo-agent" }, - }); - }); - - it("still refuses an existing directory that is not an eve project, even when resumable", async () => { - mockedPathExists.mockImplementation(async (path) => path === "/tmp/parent/demo-agent"); - mockedIsEveProject.mockResolvedValue(false); - const box = resolveTarget({ - asker: headlessAsker(), - notify: unexpectedPrompt, - presetName: "demo-agent", - targetDirectory: "/tmp/parent", - resumeExisting: true, - deps, - }); - - await expect(runHeadless([box], createDefaultSetupState(), silentSink)).rejects.toThrow( - 'Directory "demo-agent" already exists', - ); - }); - - it("headless without a preset name refuses with InteractionRequired naming the question", async () => { - const box = resolveTarget({ - asker: headlessAsker(), - notify: unexpectedPrompt, - targetDirectory: "/tmp/parent", - deps, - }); - - await expect(runHeadless([box], createDefaultSetupState(), silentSink)).rejects.toThrow( - InteractionRequired, - ); - await expect(runHeadless([box], createDefaultSetupState(), silentSink)).rejects.toMatchObject({ - message: expect.stringMatching(/What's your agent's name\?/), - question: expect.objectContaining({ key: "name", required: true }), - }); - }); - - it("checks interactive project names relative to the target directory", async () => { - mockedPathExists.mockImplementation(async (path) => path === "/tmp/parent/demo-agent"); - const { asker, prompter } = createAsker({ textValues: ["demo-agent", "demo-agent-2"] }); - const box = resolveTarget({ - asker, - notify: prompter.note, - targetDirectory: "/tmp/parent", - deps, - }); - - const result = await runInteractive([box], createDefaultSetupState(), silentSink); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.projectPath).toEqual({ - kind: "resolved", - inPlace: false, - path: "/tmp/parent/demo-agent-2", - }); - expect(mockedPathExists).toHaveBeenCalledWith("/tmp/parent/demo-agent"); - expect(mockedPathExists).toHaveBeenCalledWith("/tmp/parent/demo-agent-2"); - expect(prompter.note).toHaveBeenCalledWith( - 'Directory "demo-agent" already exists. Choose a different name.', - ); - }); - - it("resolves in-place scaffolds to the target directory itself", async () => { - const { asker, prompter } = createAsker(); - const box = resolveTarget({ - asker, - notify: prompter.note, - targetDirectory: "/tmp/parent", - inPlace: true, - deps, - }); - - const result = await runInteractive([box], createDefaultSetupState(), silentSink); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.projectPath).toEqual({ - kind: "resolved", - inPlace: true, - path: "/tmp/parent", - }); - }); - - it("asks for the agent name when an interactive in-place basename is not a valid slug", async () => { - const { asker, prompter } = createAsker({ textValues: ["my-agent"] }); - const box = resolveTarget({ - asker, - notify: prompter.note, - targetDirectory: "/tmp/My Project", - inPlace: true, - deps, - }); - - const result = await runInteractive([box], createDefaultSetupState(), silentSink); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.agentName).toBe("my-agent"); - expect(result.state.projectPath).toEqual({ - kind: "resolved", - inPlace: true, - path: "/tmp/My Project", - }); - }); - - it("headless in-place still refuses an invalid basename", async () => { - const box = resolveTarget({ - asker: headlessAsker(), - notify: unexpectedPrompt, - targetDirectory: "/tmp/My Project", - inPlace: true, - deps, - }); - - await expect(runHeadless([box], createDefaultSetupState(), silentSink)).rejects.toThrow( - /Cannot infer a valid project name/, - ); - }); -}); diff --git a/packages/eve/src/setup/boxes/resolve-target.ts b/packages/eve/src/setup/boxes/resolve-target.ts deleted file mode 100644 index 0f3634d09..000000000 --- a/packages/eve/src/setup/boxes/resolve-target.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { basename, join, resolve } from "node:path"; - -import { isEveProject } from "#setup/scaffold/index.js"; - -import { select, SkippedSignal, text, type Asker } from "../ask.js"; -import { pathExists } from "../path-exists.js"; -import { parseProjectName, validateProjectName } from "../project-name.js"; -import { - CURRENT_DIRECTORY_PROJECT_NAME, - type ResolvedProjectPath, - type SetupState, -} from "../state.js"; -import type { SetupBox } from "../step.js"; - -const DEFAULT_PROJECT_NAME = "my-agent"; -const NAME_PROMPT_MESSAGE = "What's your agent's name?"; - -/** Injected for tests; defaults to the real filesystem probes. */ -export interface ResolveTargetDeps { - pathExists: typeof pathExists; - isEveProject: typeof isEveProject; -} - -export interface ResolveTargetOptions { - /** Resolves the name and directory questions; the composed stack decides how. */ - asker: Asker; - /** - * Interactive-only notice surface for the duplicate-directory re-ask loop. - * Narrower than the prompter it forwards to; goes away when notices get a - * channel of their own. - */ - notify(message: string): void; - /** - * Skip the name question and use this value. Stays a factory option (not a - * `withAnswers` rung) so it keeps short-circuiting before any ask while - * still being validated, exactly as the dual-face box did. - */ - presetName?: string; - /** Parent directory the project folder is created inside. Defaults to cwd. */ - targetDirectory?: string; - /** Force scaffolding into the target directory instead of a `./` child. */ - inPlace?: boolean; - /** - * Treat an existing `./` that is already an eve project as resumable - * instead of refusing it. Composed on for headless runs so re-runs converge; - * interactive runs keep refusing so a human notices the collision. - */ - resumeExisting?: boolean; - deps?: ResolveTargetDeps; -} - -/** The name + directory decision gather produces. */ -export interface ResolveTargetInput { - agentName: string; - inPlace: boolean; -} - -export interface ResolveTargetPayload { - agentName: string; - projectPath: Extract; -} - -/** Validates a preset name; the non-asking half of the name decision. */ -function validatePresetName(presetName: string): string { - return parseProjectName(presetName); -} - -/** - * Asks the agent name through the channel. Pure name resolution: it performs - * no filesystem checks, so the directory decision can be made separately. The - * returned string is a single path segment safe to use as both a directory - * name and a Vercel project slug. - */ -async function askAgentName(asker: Asker, required: boolean): Promise { - const raw = await asker.ask( - text({ - key: "name", - message: NAME_PROMPT_MESSAGE, - placeholder: DEFAULT_PROJECT_NAME, - recommended: DEFAULT_PROJECT_NAME, - validate: (value) => validateProjectName(value.trim() || DEFAULT_PROJECT_NAME) ?? null, - required, - }), - ); - return raw.trim() || DEFAULT_PROJECT_NAME; -} - -function deriveInPlaceProjectName(targetDirectory: string | undefined): string { - const targetRoot = resolve(targetDirectory ?? process.cwd()); - const projectName = basename(targetRoot); - const validationError = validateProjectName(projectName); - if (validationError !== undefined) { - throw new Error( - `Cannot infer a valid project name from "${targetRoot}". Pass --target-dir with a valid basename, or scaffold into a renamed directory.`, - ); - } - return projectName; -} - -/** - * THE TARGET BOX (Q1 + Q2): resolve the agent name (the shared identity for - * the directory and the Vercel project) and decide whether to scaffold in - * place or into a new `./` child. The current-vs-new question only fires - * when the target directory already looks like a project; otherwise creating a - * new directory is the announced default. A headless re-run resumes an - * existing eve project directory instead of refusing it (composed via - * `resumeExisting`), so re-runs converge. - */ -export function resolveTarget( - options: ResolveTargetOptions, -): SetupBox { - const deps = options.deps ?? { pathExists, isEveProject }; - const parent = (): string => resolve(options.targetDirectory ?? process.cwd()); - const inPlaceBasename = (): string => basename(parent()); - - /** - * Throws if `./` already exists under `parent`, unless the composition - * opted into resuming an existing eve project there (headless re-runs). Used - * for the "create a new directory" branch, where clobbering an existing - * folder is unsafe. - */ - async function assertNewDirectoryAvailable(name: string): Promise { - if (name === CURRENT_DIRECTORY_PROJECT_NAME) return; - const targetPath = resolve(parent(), name); - if (!(await deps.pathExists(targetPath))) return; - if (options.resumeExisting === true && (await deps.isEveProject(targetPath))) return; - throw new Error(`Directory "${name}" already exists. Choose a different name.`); - } - - /** A directory the user is already working in looks like a project to scaffold into. */ - async function looksLikeProject(dir: string): Promise { - const [hasPackageJson, hasVercelDir] = await Promise.all([ - deps.pathExists(join(dir, "package.json")), - deps.pathExists(join(dir, ".vercel")), - ]); - return hasPackageJson || hasVercelDir; - } - - /** Re-asks until `./` is free under `parent`. */ - async function askFreeDirectoryName(initial: string): Promise { - let candidate = initial; - while (true) { - if (candidate === CURRENT_DIRECTORY_PROJECT_NAME) return candidate; - if (!(await deps.pathExists(resolve(parent(), candidate)))) return candidate; - options.notify(`Directory "${candidate}" already exists. Choose a different name.`); - candidate = await askAgentName(options.asker, true); - } - } - - function inPlaceInput(): ResolveTargetInput { - // A passed name is the shared identity even in place; only fall back to the - // directory basename when no name was given. - const agentName = - options.presetName !== undefined - ? validatePresetName(options.presetName) - : deriveInPlaceProjectName(options.targetDirectory); - return { agentName, inPlace: true }; - } - - return { - id: "resolve-target", - - async gather(): Promise { - if (options.inPlace) { - // An interactive in-place run can recover from a directory whose - // basename is not a valid project slug (uppercase, spaces) by asking - // for the agent identity; the directory itself stays as-is. The - // question is skippable so a headless stack skips it, and the box - // falls through to the derive, which reports the invalid basename. - if ( - options.presetName === undefined && - validateProjectName(inPlaceBasename()) !== undefined - ) { - try { - return { agentName: await askAgentName(options.asker, false), inPlace: true }; - } catch (error) { - if (!(error instanceof SkippedSignal)) throw error; - } - } - return inPlaceInput(); - } - - if (options.presetName !== undefined) { - // A positionally-supplied name keeps the create-a-new-directory default. - const agentName = validatePresetName(options.presetName); - await assertNewDirectoryAvailable(agentName); - return { agentName, inPlace: false }; - } - - // A headless stack refuses here: the name is unguessable and required. - const agentName = await askAgentName(options.asker, true); - - if (await looksLikeProject(parent())) { - const choice = await options.asker.ask( - select<"current" | "new">({ - key: "target-directory", - message: "This directory already looks like a project. Where should the agent live?", - options: [ - { id: "current", label: "Scaffold into this directory", value: "current" }, - { id: "new", label: `Create a new directory ./${agentName}`, value: "new" }, - ], - recommended: "new", - // Only reachable after the name question was answered, i.e. in an - // interactive stack; required keeps any other stack from guessing. - required: true, - }), - ); - if (choice === "current") { - return { agentName, inPlace: true }; - } - } - - return { agentName: await askFreeDirectoryName(agentName), inPlace: false }; - }, - - async perform({ input }): Promise { - const projectName = input.inPlace ? CURRENT_DIRECTORY_PROJECT_NAME : input.agentName; - return { - agentName: input.agentName, - projectPath: { - kind: "resolved", - inPlace: input.inPlace, - path: resolve(parent(), projectName), - }, - }; - }, - - apply(state, payload) { - return { ...state, agentName: payload.agentName, projectPath: payload.projectPath }; - }, - }; -} diff --git a/packages/eve/src/setup/boxes/scaffold.test.ts b/packages/eve/src/setup/boxes/scaffold.test.ts deleted file mode 100644 index 7b98a9536..000000000 --- a/packages/eve/src/setup/boxes/scaffold.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; - -import type { Prompter } from "../prompter.js"; -import { createDefaultSetupState, type SetupState } from "../state.js"; -import type { OutputSink } from "../step.js"; -import { runHeadless, runInteractive } from "../runner.js"; -import { scaffold, type ScaffoldDeps } from "./scaffold.js"; - -const silentSink: OutputSink = { write: () => {} }; -const TEST_EVE_PACKAGE = { version: "latest", nodeEngine: ">=24" } as const; - -function fakeDeps(overrides: Partial = {}): ScaffoldDeps { - return { - scaffoldBaseProject: vi.fn(async () => "/tmp/my-agent"), - isEveProject: vi.fn(async () => false), - ...overrides, - }; -} - -function createPrompter(): Prompter { - return createFakePrompter().prompter; -} - -function resolvedState(): SetupState { - return { - ...createDefaultSetupState(), - agentName: "my-agent", - modelId: "openai/gpt-5-mini", - projectPath: { kind: "resolved", inPlace: false, path: "/tmp/my-agent" }, - }; -} - -describe("scaffold box", () => { - it("scaffolds the agent template and records the scaffolded path", async () => { - const deps = fakeDeps({ scaffoldBaseProject: vi.fn(async () => "/tmp/elsewhere/my-agent") }); - const box = scaffold({ - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - targetDirectory: "/tmp/parent", - headless: true, - deps, - }); - - const next = await runHeadless([box], resolvedState(), silentSink); - - expect(deps.scaffoldBaseProject).toHaveBeenCalledWith( - expect.objectContaining({ - projectName: "my-agent", - model: "openai/gpt-5-mini", - byokProvider: false, - targetDirectory: "/tmp/parent", - evePackage: TEST_EVE_PACKAGE, - }), - ); - // apply re-writes the path to the one actually scaffolded. - expect(next.projectPath).toEqual({ - kind: "resolved", - inPlace: false, - path: "/tmp/elsewhere/my-agent", - }); - }); - - it("scaffolds into the current directory for an in-place run", async () => { - const deps = fakeDeps(); - const box = scaffold({ - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - headless: true, - deps, - }); - const state: SetupState = { - ...resolvedState(), - projectPath: { kind: "resolved", inPlace: true, path: "/tmp/my-agent" }, - }; - - const next = await runHeadless([box], state, silentSink); - - expect(deps.scaffoldBaseProject).toHaveBeenCalledWith( - expect.objectContaining({ projectName: "." }), - ); - expect(next.projectPath).toEqual({ kind: "resolved", inPlace: true, path: "/tmp/my-agent" }); - }); - - it("scaffolds an inline byok provider block when the model is self-wired", async () => { - const deps = fakeDeps(); - const box = scaffold({ - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - headless: true, - deps, - }); - const state: SetupState = { ...resolvedState(), modelWiring: "self" }; - - await runHeadless([box], state, silentSink); - - expect(deps.scaffoldBaseProject).toHaveBeenCalledWith( - expect.objectContaining({ byokProvider: true }), - ); - }); - - it("headless re-run on an existing eve project skips scaffolding and continues", async () => { - const deps = fakeDeps({ isEveProject: vi.fn(async () => true) }); - const prompter = createPrompter(); - const box = scaffold({ prompter, evePackage: TEST_EVE_PACKAGE, headless: true, deps }); - - const next = await runHeadless([box], resolvedState(), silentSink); - - expect(deps.scaffoldBaseProject).not.toHaveBeenCalled(); - expect(prompter.log.message).toHaveBeenCalledWith( - "Existing eve project detected; continuing setup...", - ); - expect(next.projectPath).toEqual({ kind: "resolved", inPlace: false, path: "/tmp/my-agent" }); - }); - - it("interactive run scaffolds even when the directory is already an eve project", async () => { - const deps = fakeDeps({ isEveProject: vi.fn(async () => true) }); - const box = scaffold({ prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, deps }); - - const result = await runInteractive([box], resolvedState(), silentSink); - - expect(result.kind).toBe("done"); - expect(deps.scaffoldBaseProject).toHaveBeenCalledTimes(1); - }); - - it("headless overwriteExisting re-scaffolds an existing eve project and warns per overwrite", async () => { - const deps = fakeDeps({ isEveProject: vi.fn(async () => true) }); - const prompter = createPrompter(); - const box = scaffold({ - prompter, - evePackage: TEST_EVE_PACKAGE, - overwriteExisting: true, - headless: true, - deps, - }); - - await runHeadless([box], resolvedState(), silentSink); - - expect(deps.scaffoldBaseProject).toHaveBeenCalledWith( - expect.objectContaining({ overwriteExisting: true }), - ); - const call = vi.mocked(deps.scaffoldBaseProject).mock.calls[0]?.[0]; - await call?.onOverwriteFile?.("agent/agent.ts"); - expect(prompter.log.warning).toHaveBeenCalledWith("Overwrote agent/agent.ts"); - }); -}); diff --git a/packages/eve/src/setup/boxes/scaffold.ts b/packages/eve/src/setup/boxes/scaffold.ts deleted file mode 100644 index 6118f5bc7..000000000 --- a/packages/eve/src/setup/boxes/scaffold.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - isEveProject, - scaffoldBaseProject, - type EvePackageContract, -} from "#setup/scaffold/index.js"; - -import type { Prompter } from "../prompter.js"; -import { CURRENT_DIRECTORY_PROJECT_NAME, requireProjectPath, type SetupState } from "../state.js"; -import type { SetupBox } from "../step.js"; - -/** Injected for tests; defaults to the real eve-scaffold helpers. */ -export interface ScaffoldDeps { - scaffoldBaseProject: typeof scaffoldBaseProject; - isEveProject: typeof isEveProject; -} - -export interface ScaffoldOptions { - /** Reports scaffold progress and overwrite warnings. The box never prompts. */ - prompter: Prompter; - evePackage?: EvePackageContract; - /** Parent directory the project folder is created inside. Defaults to cwd. */ - targetDirectory?: string; - /** Allow the in-place scaffold to replace eve scaffold files that already exist. */ - overwriteExisting?: boolean; - /** - * Headless mode: a headless re-run over an existing eve project skips - * scaffolding (idempotent re-entry); an interactive run always scaffolds and - * lets `scaffoldBaseProject` own the in-place conflict rules. The box prompts - * for nothing, so this dispatch comes from the composition site. - */ - headless?: boolean; - deps?: ScaffoldDeps; -} - -/** - * Whether the run is headless. A headless re-run over an existing eve project - * skips scaffolding (idempotent re-entry); an interactive run always scaffolds. - */ -export interface ScaffoldInput { - headless: boolean; -} - -/** - * THE SCAFFOLD BOX: writes the base agent template into the path resolved by - * the target box. It prompts for nothing; the gather only records which mode - * ran, because a headless re-run on an already-scaffolded eve project skips the - * write and continues setup instead of failing. - */ -export function scaffold(options: ScaffoldOptions): SetupBox { - const deps = options.deps ?? { scaffoldBaseProject, isEveProject }; - - return { - id: "scaffold", - - async gather(): Promise { - // No questions: the only difference between modes is whether a re-run over - // an existing eve project skips the write, a composition-time fact. - return { headless: options.headless ?? false }; - }, - - async perform({ state, input }): Promise { - const { prompter } = options; - const scaffoldProjectName = state.projectPath.inPlace - ? CURRENT_DIRECTORY_PROJECT_NAME - : state.agentName; - const projectPath = requireProjectPath(state); - if (input.headless && !options.overwriteExisting && (await deps.isEveProject(projectPath))) { - prompter.log.message("Existing eve project detected; continuing setup..."); - return projectPath; - } - - prompter.log.message("Scaffolding project files..."); - const scaffoldedPath = await deps.scaffoldBaseProject({ - projectName: scaffoldProjectName, - model: state.modelId, - byokProvider: state.modelWiring === "self", - targetDirectory: options.targetDirectory, - overwriteExisting: options.overwriteExisting, - onOverwriteFile: (filePath) => prompter.log.warning(`Overwrote ${filePath}`), - evePackage: options.evePackage, - }); - prompter.log.success(`Scaffolded project at ${scaffoldedPath}`); - return scaffoldedPath; - }, - - apply(state, projectPath) { - return { - ...state, - projectPath: { kind: "resolved", inPlace: state.projectPath.inPlace, path: projectPath }, - }; - }, - }; -} diff --git a/packages/eve/src/setup/boxes/select-channels.test.ts b/packages/eve/src/setup/boxes/select-channels.test.ts deleted file mode 100644 index 79a4c5c80..000000000 --- a/packages/eve/src/setup/boxes/select-channels.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; - -import { headlessAsker, InteractionRequired, interactiveAsker, type Asker } from "../ask.js"; -import type { MultiSelectOptions, PrompterValue, SelectOption } from "../prompter.js"; -import { createDefaultSetupState, type SetupState } from "../state.js"; -import type { OutputSink } from "../step.js"; -import { runHeadless, runInteractive } from "../runner.js"; -import { selectChannels } from "./select-channels.js"; - -const silentSink: OutputSink = { write: () => {} }; - -type MultipleHandler = (opts: MultiSelectOptions) => Promise; - -function createAsker(multiple?: MultipleHandler): Asker { - return interactiveAsker(createFakePrompter(multiple ? { multiple } : {}).prompter); -} - -function stateDeployingToVercel(): SetupState { - return { - ...createDefaultSetupState(), - vercelProject: { kind: "new", project: "agent", team: "team" }, - }; -} - -describe("selectChannels box", () => { - it("uses preset channels without asking", async () => { - const box = selectChannels({ - variant: "onboarding", - asker: headlessAsker(), - presetChannels: ["web"], - }); - - const next = await runHeadless([box], stateDeployingToVercel(), silentSink); - - expect(next.channelSelection).toEqual(["web"]); - }); - - it("prompts a multiselect and records the selection", async () => { - const multiselect: MultipleHandler = vi.fn(async () => ["web", "slack"]); - const box = selectChannels({ variant: "onboarding", asker: createAsker(multiselect) }); - - const result = await runInteractive([box], stateDeployingToVercel(), silentSink); - - expect(multiselect).toHaveBeenCalledOnce(); - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.channelSelection).toEqual(["web", "slack"]); - }); - - it("keeps Slack selectable during onboarding before the deployment decision", async () => { - // The deployment question now comes after the channel picker, so the - // onboarding variant never gates Slack; picking it is what resolves the - // later provisioning box to Vercel. - let options: readonly SelectOption[] = []; - const multiselect: MultipleHandler = vi.fn(async (opts) => { - options = opts.options; - return ["tui", "slack"]; - }); - const box = selectChannels({ variant: "onboarding", asker: createAsker(multiselect) }); - - // Default state has no Vercel plan yet: the question has not been asked. - const result = await runInteractive([box], createDefaultSetupState(), silentSink); - - const slack = options.find((option) => option.value === "slack"); - expect(slack?.disabled).toBeUndefined(); - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.channelSelection).toEqual(["slack"]); - }); - - it("in-project: disables Slack in the picker when no project link was detected", async () => { - let options: readonly SelectOption[] = []; - const multiselect: MultipleHandler = vi.fn(async (opts) => { - options = opts.options; - return ["web"]; - }); - const box = selectChannels({ asker: createAsker(multiselect), variant: "in-project" }); - - // Default state has no detected on-disk link. - await runInteractive([box], createDefaultSetupState(), silentSink); - - const slack = options.find((option) => option.value === "slack"); - expect(slack?.disabled).toBe(true); - expect(slack?.disabledReason).toBe("needs a Vercel project"); - }); - - it("in-project: rejects a preset Slack selection without a link, in both runners", async () => { - const box = selectChannels({ - asker: headlessAsker(), - presetChannels: ["slack"], - variant: "in-project", - }); - - await expect(runHeadless([box], createDefaultSetupState(), silentSink)).rejects.toThrow( - /Slack requires a Vercel project/, - ); - await expect(runInteractive([box], createDefaultSetupState(), silentSink)).rejects.toThrow( - /Slack requires a Vercel project/, - ); - }); - - it("in-project: rejects an interactive Slack pick without a link", async () => { - const multiselect: MultipleHandler = vi.fn(async () => ["tui", "slack"]); - const box = selectChannels({ asker: createAsker(multiselect), variant: "in-project" }); - - await expect(runInteractive([box], createDefaultSetupState(), silentSink)).rejects.toThrow( - /Slack requires a Vercel project/, - ); - }); - - it("in-project: enables Slack from the detected project link", async () => { - // In-project setup has no onboarding plan; the on-disk link alone gates. - const state: SetupState = { - ...createDefaultSetupState(), - project: { kind: "linked", projectId: "prj_demo" }, - }; - const box = selectChannels({ - asker: headlessAsker(), - presetChannels: ["slack"], - variant: "in-project", - }); - - const next = await runHeadless([box], state, silentSink); - - expect(next.channelSelection).toEqual(["slack"]); - }); - - it("requires a selection and offers a locked, always-on Terminal UI option", async () => { - let captured: MultiSelectOptions | undefined; - const multiselect: MultipleHandler = vi.fn(async (opts) => { - captured = opts; - return ["tui", "web"]; - }); - const box = selectChannels({ variant: "onboarding", asker: createAsker(multiselect) }); - - await runInteractive([box], stateDeployingToVercel(), silentSink); - - expect(captured?.required).toBe(true); - const tui = captured?.options.find((option) => option.value === "tui"); - expect(tui?.locked).toBe(true); - expect(tui?.disabled).toBeUndefined(); - }); - - it("strips the always-on Terminal UI sentinel from the scaffolded channel selection", async () => { - const multiselect: MultipleHandler = vi.fn(async () => ["tui", "web"]); - const box = selectChannels({ variant: "onboarding", asker: createAsker(multiselect) }); - - const result = await runInteractive([box], stateDeployingToVercel(), silentSink); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.channelSelection).toEqual(["web"]); - }); - - it("headless without preset channels refuses with InteractionRequired naming the question", async () => { - const box = selectChannels({ variant: "onboarding", asker: headlessAsker() }); - - await expect(runHeadless([box], stateDeployingToVercel(), silentSink)).rejects.toThrow( - InteractionRequired, - ); - await expect(runHeadless([box], stateDeployingToVercel(), silentSink)).rejects.toMatchObject({ - message: expect.stringMatching(/Where will you chat with your agent\?/), - question: expect.objectContaining({ key: "channels", required: true }), - }); - }); - - it("merges disabled channel reasons into the picker rows", async () => { - let options: readonly SelectOption[] = []; - const multiselect: MultipleHandler = vi.fn(async (opts) => { - options = opts.options; - return []; - }); - const box = selectChannels({ - asker: createAsker(multiselect), - variant: "channels-add", - disabledChannelReasons: { - web: "POST /eve/v1/session already registered", - slack: "Slack channel already registered", - }, - }); - - await runInteractive([box], createDefaultSetupState(), silentSink); - - expect(options).toEqual([ - expect.objectContaining({ - value: "web", - disabled: true, - disabledReason: "POST /eve/v1/session already registered", - }), - expect.objectContaining({ - value: "slack", - disabled: true, - disabledReason: "Slack channel already registered", - }), - ]); - }); - - it("channels-add variant drops the REPL row, allows empty submit, and keeps Slack open", async () => { - let captured: MultiSelectOptions | undefined; - const multiselect: MultipleHandler = vi.fn(async (opts) => { - captured = opts; - return []; - }); - const box = selectChannels({ asker: createAsker(multiselect), variant: "channels-add" }); - - // Default state is unlinked with no Vercel plan; the channels-add - // composition still offers Slack because its add box links on demand. - const result = await runInteractive([box], createDefaultSetupState(), silentSink); - - expect(captured?.required).toBe(false); - expect(captured?.options.map((option) => option.value)).toEqual(["web", "slack"]); - const slack = captured?.options.find((option) => option.value === "slack"); - expect(slack?.disabled).toBeUndefined(); - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.channelSelection).toEqual([]); - }); - - it("channels-add variant accepts a preset Slack selection without a Vercel project", async () => { - const box = selectChannels({ - asker: headlessAsker(), - variant: "channels-add", - presetChannels: ["slack"], - }); - - const next = await runHeadless([box], createDefaultSetupState(), silentSink); - - expect(next.channelSelection).toEqual(["slack"]); - }); - - it("runs validateSelection on the preset path before recording the selection", async () => { - const validateSelection = vi.fn(async () => { - throw new Error("existing eve session channel"); - }); - const box = selectChannels({ - asker: headlessAsker(), - variant: "channels-add", - presetChannels: ["web"], - validateSelection, - }); - - await expect(runInteractive([box], createDefaultSetupState(), silentSink)).rejects.toThrow( - "existing eve session channel", - ); - expect(validateSelection).toHaveBeenCalledWith(["web"]); - }); - - it("runs validateSelection on a picked selection with the REPL row stripped", async () => { - const validateSelection = vi.fn(async () => {}); - const multiselect: MultipleHandler = vi.fn(async () => ["tui", "web"]); - const box = selectChannels({ - variant: "onboarding", - asker: createAsker(multiselect), - validateSelection, - }); - - const result = await runInteractive([box], stateDeployingToVercel(), silentSink); - - expect(validateSelection).toHaveBeenCalledWith(["web"]); - expect(result.kind).toBe("done"); - }); -}); diff --git a/packages/eve/src/setup/boxes/select-channels.ts b/packages/eve/src/setup/boxes/select-channels.ts deleted file mode 100644 index eaee2e9ec..000000000 --- a/packages/eve/src/setup/boxes/select-channels.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { SCAFFOLDABLE_CHANNELS, type ChannelKind } from "#setup/scaffold/index.js"; -import type { DisabledChannelReasons } from "#setup/cli/index.js"; - -import type { Asker, MultiSelectOption } from "../ask.js"; -import { hasVercelProject, type SetupState } from "../state.js"; -import type { SetupBox } from "../step.js"; - -const SLACK_REQUIRES_VERCEL = - "Slack requires a Vercel project. Link this directory (`vercel link`) and re-run to add Slack."; - -/** The channel question, shared with the dev TUI's /channels action list. */ -export const CHANNELS_PROMPT_MESSAGE = "Where will you chat with your agent?"; - -/** - * Sentinel for the local terminal UI (`eve dev`) row in the channel picker. - * The terminal UI needs no scaffolding and no deploy, so its row is locked: - * the picker auto-selects it and the user cannot toggle it off. It writes no - * channel files, so it is stripped from the selection before that becomes - * {@link ChannelKind}-typed scaffold input. - */ -const TUI_PICKER_VALUE = "tui"; -type ChannelPickerValue = ChannelKind | typeof TUI_PICKER_VALUE; - -export interface SelectChannelsOptions { - /** Resolves the channels question; the composed stack decides how. */ - asker: Asker; - /** - * Resolve to these channels without asking. Stays a factory option (not a - * `withAnswers` rung) so it keeps short-circuiting the picker exactly as the - * dual-face box did, while still passing the Slack/Vercel gate and - * `validateSelection` like a picked selection. - */ - presetChannels?: ChannelKind[]; - /** - * Picker shape, chosen explicitly because the variants differ in whether a - * selection is required and whether the REPL row is shown. The - * "channels-add" picker allows an empty submission and keeps Slack selectable - * because capability planning later chooses Connect or environment credentials. - */ - variant: "onboarding" | "in-project" | "channels-add"; - /** - * Reasons for channel kinds that cannot be added to this project (existing - * registrations); matching rows render disabled with the reason. - */ - disabledChannelReasons?: DisabledChannelReasons; - /** - * Rejects a selection before any later box runs effects. Runs on both the - * asked and the preset path, so a flag-driven selection is validated exactly - * like a picked one. A throw aborts the run. - */ - validateSelection?(channels: readonly ChannelKind[]): Promise | void; -} - -/** - * THE CHANNELS BOX: interview-phase channel picker. Choosing channels is human - * input, so it runs before any filesystem work; the channels box scaffolds the - * chosen channels afterward from `state.channelSelection`. During onboarding - * the deployment decision has not been made yet, so Slack is never gated here: - * picking it is what makes the later provisioning box resolve to Vercel. Only - * the legacy in-project variant gates Slack on the detected on-disk link. - * - * The onboarding picker is required and always carries a locked, pre-selected - * Terminal UI row, so an empty selection is impossible: every agent can at least be - * chatted with locally in the terminal. Web and Slack stack on top of it. - */ -export function selectChannels( - options: SelectChannelsOptions, -): SetupBox { - const channelsAdd = options.variant === "channels-add"; - const inProject = options.variant === "in-project"; - - /** Asked and preset selections both reject a Slack request without a Vercel project. */ - function assertSlackHasVercel(noVercel: boolean, channels: readonly ChannelPickerValue[]): void { - if (noVercel && channels.includes("slack")) { - throw new Error(SLACK_REQUIRES_VERCEL); - } - } - - /** - * Slack's Vercel gate: only the in-project variant has a deployment fact to - * gate on (the detected link). Onboarding decides deployment after this box, - * and channels-add links on demand. - */ - function slackLacksVercel(state: Readonly): boolean { - return inProject && !hasVercelProject(state); - } - - return { - id: "select-channels", - - async gather({ state }): Promise { - const noVercel = slackLacksVercel(state); - if (options.presetChannels !== undefined) { - assertSlackHasVercel(noVercel, options.presetChannels); - await options.validateSelection?.(options.presetChannels); - return options.presetChannels; - } - const scaffoldableOptions: MultiSelectOption[] = - SCAFFOLDABLE_CHANNELS.map((channel): MultiSelectOption => { - const disabledReason = options.disabledChannelReasons?.[channel.kind]; - if (disabledReason !== undefined) { - return { - id: channel.kind, - value: channel.kind, - label: channel.label, - hint: channel.hint, - disabled: true, - disabledReason, - }; - } - if (noVercel && channel.kind === "slack") { - return { - id: channel.kind, - value: channel.kind, - label: channel.label, - disabled: true, - disabledReason: "needs a Vercel project", - }; - } - return { - id: channel.kind, - value: channel.kind, - label: channel.label, - hint: channel.hint, - }; - }); - const tuiOption: MultiSelectOption = { - id: TUI_PICKER_VALUE, - value: TUI_PICKER_VALUE, - label: "Terminal UI", - locked: true, - lockedReason: "always available", - }; - const selected = await options.asker.askMany({ - key: "channels", - message: CHANNELS_PROMPT_MESSAGE, - options: channelsAdd ? scaffoldableOptions : [...scaffoldableOptions, tuiOption], - // A headless run without preset channels must fail rather than guess a - // channel set, in either variant, as the dual-face box did. - required: true, - // The empty-submission gate is the onboarding variant's: channels-add - // accepts an empty pick (its "No channels added." path). - requireSelection: !channelsAdd, - }); - assertSlackHasVercel(noVercel, selected); - // The select reducer force-selects the locked Terminal UI row, so it - // comes back in the submission; strip it before the selection becomes - // ChannelKind-typed scaffold input. - const channels = selected.filter((value): value is ChannelKind => value !== TUI_PICKER_VALUE); - await options.validateSelection?.(channels); - return channels; - }, - - async perform({ input }): Promise { - return input; - }, - - apply(state, payload) { - return { ...state, channelSelection: payload }; - }, - }; -} diff --git a/packages/eve/src/setup/boxes/select-chat.test.ts b/packages/eve/src/setup/boxes/select-chat.test.ts deleted file mode 100644 index 463e237da..000000000 --- a/packages/eve/src/setup/boxes/select-chat.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, it, test, vi } from "vitest"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; - -import { headlessAsker, InteractionRequired, interactiveAsker, type Asker } from "../ask.js"; -import type { Prompter, PrompterValue, SingleSelectOptions } from "../prompter.js"; -import { createDefaultSetupState, type SetupState } from "../state.js"; -import type { OutputSink } from "../step.js"; -import { runHeadless, runInteractive } from "../runner.js"; -import { buildChatPreferenceOptions, selectChat } from "./select-chat.js"; - -const silentSink: OutputSink = { write: () => {} }; - -/** Proves a path never reaches the channel at all. */ -function untouchableAsker(): Asker { - return { - ask(question): Promise { - throw new Error(`untouchableAsker was asked "${question.key}"`); - }, - askMany(question): Promise { - throw new Error(`untouchableAsker was asked "${question.key}"`); - }, - }; -} - -type SingleHandler = (opts: SingleSelectOptions) => PrompterValue; - -function createSelectPrompter(handler: SingleHandler): { - prompter: Prompter; - single: SingleHandler; -} { - const single = vi.fn(handler); - return { prompter: createFakePrompter({ single }).prompter, single }; -} - -function stateWithChannels(channels: SetupState["channels"], slackbotAttached = false): SetupState { - return { ...createDefaultSetupState(), channels: [...channels], slackbotAttached }; -} - -describe("buildChatPreferenceOptions", () => { - test("offers Slack only after the connector is attached", () => { - expect( - buildChatPreferenceOptions({ - availableChannels: ["slack"], - slackbotAttached: false, - }).map((option) => option.value), - ).not.toContain("slack"); - - expect( - buildChatPreferenceOptions({ - availableChannels: ["slack"], - slackbotAttached: true, - }).map((option) => option.value), - ).toContain("slack"); - }); - - test("offers Web only when the web channel was scaffolded", () => { - expect( - buildChatPreferenceOptions({ - availableChannels: [], - slackbotAttached: false, - }).map((option) => option.value), - ).toEqual(["repl", "api", "skip"]); - - expect( - buildChatPreferenceOptions({ - availableChannels: ["web"], - slackbotAttached: false, - }).map((option) => option.value), - ).toEqual(["web", "repl", "api", "skip"]); - }); -}); - -describe("selectChat box", () => { - it("uses the preset preference without asking, in both runners", async () => { - const box = selectChat({ asker: untouchableAsker(), presetPreference: "repl" }); - - const interactive = await runInteractive([box], createDefaultSetupState(), silentSink); - expect(interactive.kind).toBe("done"); - if (interactive.kind !== "done") return; - expect(interactive.state.chat).toBe("repl"); - - const headless = await runHeadless([box], createDefaultSetupState(), silentSink); - expect(headless.chat).toBe("repl"); - }); - - it("prompts with dynamic options and the web-first initial cursor", async () => { - let captured: SingleSelectOptions | undefined; - const { prompter } = createSelectPrompter((opts) => { - captured = opts; - return "web"; - }); - const box = selectChat({ asker: interactiveAsker(prompter) }); - - const result = await runInteractive( - [box], - stateWithChannels(["web", "slack"], true), - silentSink, - ); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.chat).toBe("web"); - expect(captured?.options.map((option) => option.value)).toEqual([ - "web", - "slack", - "repl", - "api", - "skip", - ]); - expect(captured?.initialValue).toBe("web"); - }); - - it("falls back to the Slack cursor, then to no initial cursor", async () => { - let captured: SingleSelectOptions | undefined; - const { prompter } = createSelectPrompter((opts) => { - captured = opts; - return "repl"; - }); - const box = selectChat({ asker: interactiveAsker(prompter) }); - - await runInteractive([box], stateWithChannels(["slack"], true), silentSink); - expect(captured?.initialValue).toBe("slack"); - - await runInteractive([box], stateWithChannels(["slack"], false), silentSink); - expect(captured?.options.map((option) => option.value)).toEqual(["repl", "api", "skip"]); - expect(captured?.initialValue).toBeUndefined(); - }); - - it("headless without a preset refuses with InteractionRequired naming the question", async () => { - const box = selectChat({ asker: headlessAsker() }); - - await expect(runHeadless([box], createDefaultSetupState(), silentSink)).rejects.toThrow( - InteractionRequired, - ); - await expect(runHeadless([box], createDefaultSetupState(), silentSink)).rejects.toMatchObject({ - message: expect.stringMatching(/Start a chat with your agent now/), - question: expect.objectContaining({ key: "chat", required: true }), - }); - }); -}); diff --git a/packages/eve/src/setup/boxes/select-chat.ts b/packages/eve/src/setup/boxes/select-chat.ts deleted file mode 100644 index 6218dfdca..000000000 --- a/packages/eve/src/setup/boxes/select-chat.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { select, type Asker, type SelectOption } from "../ask.js"; -import type { ChannelKind, ChatPreference, SetupState } from "../state.js"; -import type { SetupBox } from "../step.js"; - -const CHAT_PROMPT_MESSAGE = "Start a chat with your agent now"; - -/** The scaffolded facts the chat option list is derived from. */ -export interface ChatPreferenceContext { - /** Channels scaffolded earlier in the flow; controls option visibility. */ - availableChannels: ChannelKind[]; - /** Whether the Slack app was attached to the channel route and is ready to use. */ - slackbotAttached: boolean; -} - -/** - * Builds the chat-preference option list. Channel-backed options come - * first (in the order Web -> Slack) when available, then the framework - * built-ins (Terminal UI, API), then the explicit skip option last. Exported - * for the unit test. - */ -export function buildChatPreferenceOptions( - args: ChatPreferenceContext, -): SelectOption[] { - const option = ( - value: ChatPreference, - label: string, - hint?: string, - ): SelectOption => ({ id: value, label, value, hint }); - const options: SelectOption[] = []; - if (args.availableChannels.includes("web")) { - options.push(option("web", "Web chat", "next.js")); - } - if (args.availableChannels.includes("slack") && args.slackbotAttached) { - options.push(option("slack", "Slack", "open workspace")); - } - options.push(option("repl", "Terminal UI")); - options.push(option("api", "API")); - options.push(option("skip", "Skip and chat later")); - return options; -} - -/** - * Picks the initial cursor position so the most "user-built" option is - * highlighted by default: Web Chat if scaffolded, then Slack (only when - * the bot was successfully attached), then nothing (cursor falls on the first - * option, i.e. REPL). - */ -function pickInitialPreference(args: ChatPreferenceContext): ChatPreference | undefined { - if (args.availableChannels.includes("web")) return "web"; - if (args.availableChannels.includes("slack") && args.slackbotAttached) return "slack"; - return undefined; -} - -export interface SelectChatOptions { - /** Resolves the chat question; the composed stack decides how. */ - asker: Asker; - /** - * Resolve to this value without asking. The headless default ("skip") lives - * at the composition site, not here, so a missing headless preset fails - * fast. Stays a factory option (not a `withAnswers` rung) because a preset - * must keep bypassing the option list, which hides choices the current - * state did not scaffold, exactly as the dual-face box did. - */ - presetPreference?: ChatPreference; -} - -/** - * THE CHAT BOX: final prompt of the create flow. Asks one required "chat" - * select through the box's asker: where the user wants to chat with their - * agent. Options are dynamic on what was scaffolded earlier. Web Chat appears - * only if `web` is in the scaffolded channels, Slack appears only if `slack` - * was scaffolded AND the bot was attached during the channel setup step. - * REPL, API, and Skip are always present. - */ -export function selectChat( - options: SelectChatOptions, -): SetupBox { - return { - id: "select-chat", - - async gather({ state }): Promise { - if (options.presetPreference !== undefined) { - return options.presetPreference; - } - const context: ChatPreferenceContext = { - availableChannels: [...state.channels], - slackbotAttached: state.slackbotAttached, - }; - return options.asker.ask( - select({ - key: "chat", - message: CHAT_PROMPT_MESSAGE, - options: buildChatPreferenceOptions(context), - recommended: pickInitialPreference(context), - // A headless run without a preset must fail rather than guess a - // surface, as the dual-face box did. - required: true, - }), - ); - }, - - async perform({ input }): Promise { - return input; - }, - - apply(state, payload) { - return { ...state, chat: payload }; - }, - }; -} diff --git a/packages/eve/src/setup/boxes/select-setup-mode.test.ts b/packages/eve/src/setup/boxes/select-setup-mode.test.ts deleted file mode 100644 index 0baf24731..000000000 --- a/packages/eve/src/setup/boxes/select-setup-mode.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import { DEFAULT_AGENT_MODEL_ID } from "#shared/default-agent-model.js"; - -import { headlessAsker, interactiveAsker, type Asker } from "../ask.js"; -import type { Prompter, PrompterValue, SingleSelectOptions } from "../prompter.js"; -import { createDefaultSetupState } from "../state.js"; -import type { OutputSink } from "../step.js"; -import { runHeadless, runInteractive } from "../runner.js"; -import { selectSetupMode } from "./select-setup-mode.js"; - -const silentSink: OutputSink = { write: () => {} }; - -/** Proves a path never reaches the channel at all. */ -function untouchableAsker(): Asker { - return { - ask(question): Promise { - throw new Error(`untouchableAsker was asked "${question.key}"`); - }, - askMany(question): Promise { - throw new Error(`untouchableAsker was asked "${question.key}"`); - }, - }; -} - -type SingleHandler = (opts: SingleSelectOptions) => PrompterValue; - -function createSelectPrompter(handler: SingleHandler): { - prompter: Prompter; - single: SingleHandler; -} { - const single = vi.fn(handler); - return { prompter: createFakePrompter({ single }).prompter, single }; -} - -describe("selectSetupMode box", () => { - it("picking one-shot records the mode and pins the default model", async () => { - const { prompter } = createSelectPrompter(() => "one-shot"); - const box = selectSetupMode({ asker: interactiveAsker(prompter) }); - - const result = await runInteractive([box], createDefaultSetupState(), silentSink); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.setupMode).toBe("one-shot"); - expect(result.state.modelId).toBe(DEFAULT_AGENT_MODEL_ID); - }); - - it("picking complete leaves the model for the model box", async () => { - const { prompter } = createSelectPrompter(() => "complete"); - const box = selectSetupMode({ asker: interactiveAsker(prompter) }); - - const result = await runInteractive([box], createDefaultSetupState(), silentSink); - - expect(result.kind).toBe("done"); - if (result.kind !== "done") return; - expect(result.state.setupMode).toBe("complete"); - expect(result.state.modelId).toBe(""); - }); - - it("pre-selects complete as the recommended row", async () => { - let captured: SingleSelectOptions | undefined; - const { prompter } = createSelectPrompter((opts) => { - captured = opts; - return "complete"; - }); - const box = selectSetupMode({ asker: interactiveAsker(prompter) }); - - await runInteractive([box], createDefaultSetupState(), silentSink); - - expect(captured?.initialValue).toBe("complete"); - }); - - it("a preset mode short-circuits both runners without asking", async () => { - const box = selectSetupMode({ asker: untouchableAsker(), presetMode: "one-shot" }); - - const interactive = await runInteractive([box], createDefaultSetupState(), silentSink); - expect(interactive.kind).toBe("done"); - if (interactive.kind !== "done") return; - expect(interactive.state.setupMode).toBe("one-shot"); - expect(interactive.state.modelId).toBe(DEFAULT_AGENT_MODEL_ID); - - const headless = await runHeadless([box], createDefaultSetupState(), silentSink); - expect(headless.setupMode).toBe("one-shot"); - }); - - it("a preset model overrides the one-shot default", async () => { - const box = selectSetupMode({ - asker: untouchableAsker(), - presetMode: "one-shot", - presetModel: "openai/gpt-5-mini", - }); - - const next = await runHeadless([box], createDefaultSetupState(), silentSink); - - expect(next.modelId).toBe("openai/gpt-5-mini"); - }); - - it("headless without a preset skips the question and stays complete", async () => { - const box = selectSetupMode({ asker: headlessAsker() }); - - const next = await runHeadless([box], createDefaultSetupState(), silentSink); - - expect(next.setupMode).toBe("complete"); - expect(next.modelId).toBe(""); - }); -}); diff --git a/packages/eve/src/setup/boxes/select-setup-mode.ts b/packages/eve/src/setup/boxes/select-setup-mode.ts deleted file mode 100644 index bb03b605c..000000000 --- a/packages/eve/src/setup/boxes/select-setup-mode.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { DEFAULT_AGENT_MODEL_ID } from "#shared/default-agent-model.js"; - -import { select, SkippedSignal, type Asker } from "../ask.js"; -import type { SetupMode, SetupState } from "../state.js"; -import type { SetupBox } from "../step.js"; - -const MODE_PROMPT_MESSAGE = "How much should we set up now?"; - -export interface SelectSetupModeOptions { - /** Resolves the mode question; the composed stack decides how. */ - asker: Asker; - /** - * Skip the mode question and use this value. Stays a factory option (not a - * `withAnswers` rung) so it short-circuits before any ask, which is what - * lets a headless `--one-shot` run resolve without a terminal. - */ - presetMode?: SetupMode; - /** - * Model baked into a one-shot scaffold instead of {@link DEFAULT_AGENT_MODEL_ID}. - * Threaded from the same `--model` preset the model box consumes, so the - * flag keeps working when the model box is skipped. - */ - presetModel?: string; -} - -/** The mode plus the model a one-shot run pins, since the model box is skipped. */ -export interface SelectSetupModePayload { - mode: SetupMode; - modelId?: string; -} - -/** - * THE SETUP-MODE BOX: decide whether the run is the complete onboarding flow - * or a one-shot scaffold. One-shot pins the default model here because every - * later interview box (including the model picker) is gated off. The question - * is skippable, so a headless stack without a preset resolves to "complete" - * and current headless behavior is unchanged. - */ -export function selectSetupMode( - options: SelectSetupModeOptions, -): SetupBox { - return { - id: "select-setup-mode", - - async gather(): Promise { - if (options.presetMode !== undefined) return options.presetMode; - try { - return await options.asker.ask( - select({ - key: "setup-mode", - message: MODE_PROMPT_MESSAGE, - options: [ - { - id: "complete", - label: "Complete setup", - value: "complete", - hint: "model, channels, connections, deploy", - }, - { - id: "one-shot", - label: "One-shot", - value: "one-shot", - hint: "just write the project files", - }, - ], - recommended: "complete", - }), - ); - } catch (error) { - if (error instanceof SkippedSignal) return "complete"; - throw error; - } - }, - - async perform({ input }): Promise { - if (input === "one-shot") { - return { mode: input, modelId: options.presetModel ?? DEFAULT_AGENT_MODEL_ID }; - } - return { mode: input }; - }, - - apply(state, payload) { - return { - ...state, - setupMode: payload.mode, - modelId: payload.modelId ?? state.modelId, - }; - }, - }; -} diff --git a/packages/eve/src/setup/channel-setup-deployment.ts b/packages/eve/src/setup/channel-setup-deployment.ts deleted file mode 100644 index df5e84655..000000000 --- a/packages/eve/src/setup/channel-setup-deployment.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { DeployProjectDeps } from "./boxes/deploy-project.js"; -import { deployProject } from "./boxes/deploy-project.js"; -import type { ChannelSetupUi } from "./channel-setup-ui.js"; -import { runInteractive } from "./runner.js"; -import { snapshotSetupState, type SetupState } from "./state.js"; -import type { OutputSink } from "./step.js"; - -/** Offers and, when accepted, performs deployment after channel setup completes. */ -export async function deployChannelSetup(input: { - state: SetupState; - ui: ChannelSetupUi; - signal?: AbortSignal; - presetDeploy?: boolean; - deps?: DeployProjectDeps; -}): Promise { - const shouldDeploy = - input.presetDeploy ?? - (await input.ui.confirm({ - key: "deploy-channels", - message: "Deploy this project to Vercel now?", - recommended: true, - })); - if (!shouldDeploy) { - input.ui.nextSteps(["Run `eve deploy` to deploy when ready."]); - return input.state; - } - - const sink: OutputSink = { write: (line) => input.ui.prompter.log.message(line) }; - const result = await runInteractive( - [ - deployProject({ - prompter: input.ui.prompter, - ensureLinkedProject: "interactive-vercel-link", - deps: input.deps, - }), - ], - input.state, - sink, - { snapshot: snapshotSetupState, signal: input.signal }, - ); - return result.kind === "done" ? result.state : input.state; -} diff --git a/packages/eve/src/setup/flows/channels.test.ts b/packages/eve/src/setup/flows/channels.test.ts deleted file mode 100644 index 6d328234d..000000000 --- a/packages/eve/src/setup/flows/channels.test.ts +++ /dev/null @@ -1,626 +0,0 @@ -import { join } from "node:path"; - -import { describe, expect, it, vi } from "vitest"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import type { AddChannelsDeps } from "#setup/boxes/add-channels.js"; -import { CHANNELS_PROMPT_MESSAGE } from "#setup/boxes/select-channels.js"; -import type { ExistingChannelRegistrations } from "#setup/channel-add-conflicts.js"; -import { HumanActionRequiredError } from "#setup/human-action.js"; -import type { DeploymentInfo } from "#setup/project-resolution.js"; -import type { PrompterValue, SelectOption, SingleSelectOptions } from "#setup/prompter.js"; -import { deriveSlackConnectorSlug } from "#setup/scaffold/index.js"; -import { WizardCancelledError } from "#setup/step.js"; -import type { VercelAuthStatus } from "#setup/vercel-project.js"; - -import { runChannelsFlow, SEE_IT_LIVE_MESSAGE } from "./channels.js"; - -// The flow probes Vercel auth at startup; default it to authenticated so the -// existing cases never spawn a real `vercel whoami`. The auth-aware row tests -// inject `getVercelAuthStatus` explicitly to override this. -vi.mock("../vercel-project.js", async (importOriginal) => ({ - ...(await importOriginal()), - getVercelAuthStatus: vi.fn(async () => "authenticated"), -})); - -const APP_ROOT = "/app/my-agent"; -const UNLINKED: DeploymentInfo = { state: "unlinked" }; -const LINKED: DeploymentInfo = { state: "linked", projectId: "prj_1", orgId: "org_1" }; - -const NO_REGISTRATIONS: ExistingChannelRegistrations = { - disabledChannelReasons: {}, - webRouteOwners: [], - slackOwners: [], - webAppPresent: false, -}; - -/** An Esc on the channel list, in a scripted pick sequence. */ -const CANCEL = Symbol("cancel"); - -/** - * Scripts the action-list loop: each list paint consumes the next pick (and - * records the painted rows), while every other single-select goes to `rest`. - */ -function scriptList( - picks: ReadonlyArray, - rest?: (opts: SingleSelectOptions) => PrompterValue, -) { - const queue = [...picks]; - const listPaints: SelectOption[][] = []; - const single = (opts: SingleSelectOptions): PrompterValue => { - if (opts.message !== CHANNELS_PROMPT_MESSAGE) { - if (rest !== undefined) return rest(opts); - throw new Error(`Unexpected select: ${opts.message}`); - } - listPaints.push(opts.options); - const next = queue.shift(); - if (next === undefined) { - throw new Error("The channel list was asked more times than the test scripted."); - } - if (next === CANCEL) throw new WizardCancelledError(); - return next; - }; - return { single, listPaints }; -} - -function createAddChannelsDeps() { - return { - ensureChannel: vi.fn(async (options) => - options.kind === "web" - ? { - kind: "web", - action: "created", - filesWritten: [join(options.projectRoot, "app/page.tsx")], - filesSkipped: [], - packageJsonUpdated: [], - } - : { - kind: "slack", - action: "created", - filesWritten: [join(options.projectRoot, "agent/channels/slack.ts")], - filesSkipped: [], - packageJsonUpdated: [], - slackConnectorSlug: - options.slackConnectorSlug ?? (await deriveSlackConnectorSlug(options.projectRoot)), - }, - ), - deriveSlackConnectorSlug, - provisionSlackbot: vi.fn(async () => ({ - state: "attached", - connectorUid: "slack/my-agent", - })), - reconcileSlackUid: vi.fn(async () => true), - detectPackageManager: vi.fn(async () => ({ - kind: "pnpm", - source: "default", - })), - runPackageManagerInstall: vi.fn(async () => true), - runVercel: vi.fn(async () => true), - detectDeployment: vi.fn(async () => UNLINKED), - }; -} - -describe("runChannelsFlow", () => { - it("adds the picked channel, repaints it as a checked task, and Done exits", async () => { - const inspect = vi - .fn(async () => NO_REGISTRATIONS) - .mockResolvedValueOnce(NO_REGISTRATIONS) - .mockResolvedValueOnce({ ...NO_REGISTRATIONS, webAppPresent: true }); - const { single, listPaints } = scriptList(["web", "done"]); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - inspectExistingChannelRegistrations: inspect, - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "done", addedChannels: ["web"] }); - expect(addChannelsDeps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "web", projectRoot: APP_ROOT }), - ); - // The list repaints from a fresh inspection: the added channel is checked. - expect(listPaints).toHaveLength(2); - const webRow = listPaints[1]?.find((option) => option.value === "web"); - expect(webRow).toMatchObject({ - completed: true, - focusHint: "Already installed", - label: "Web Chat", - }); - expect(webRow?.hint).toBeUndefined(); - }); - - it("offers a Done row and exits with no additions", async () => { - const { single, listPaints } = scriptList(["done"]); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - inspectExistingChannelRegistrations: vi.fn(async () => NO_REGISTRATIONS), - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "done", addedChannels: [] }); - expect(addChannelsDeps.ensureChannel).not.toHaveBeenCalled(); - 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 () => { - let captured: SingleSelectOptions | undefined; - const fake = createFakePrompter({ - single: (opts) => { - captured = opts; - return "done"; - }, - }); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - inspectExistingChannelRegistrations: vi.fn(async () => ({ - ...NO_REGISTRATIONS, - webAppPresent: true, - slackOwners: ["agent/channels/slack.ts"], - })), - addChannels: createAddChannelsDeps(), - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "done", addedChannels: [] }); - expect(captured?.initialValue).toBe("done"); - }); - - it("leaves the cursor default alone while a channel is still addable", async () => { - let captured: SingleSelectOptions | undefined; - const fake = createFakePrompter({ - single: (opts) => { - captured = opts; - return "done"; - }, - }); - - await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - // Web Chat is still addable, so the cursor keeps its first-focusable default. - inspectExistingChannelRegistrations: vi.fn(async () => NO_REGISTRATIONS), - addChannels: createAddChannelsDeps(), - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(captured?.initialValue).toBeUndefined(); - }); - - it("disables kinds that are already registered by authored channels", async () => { - const { single, listPaints } = scriptList(["done"]); - const fake = createFakePrompter({ single }); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - inspectExistingChannelRegistrations: vi.fn(async () => ({ - ...NO_REGISTRATIONS, - disabledChannelReasons: { slack: "already configured" }, - })), - addChannels: createAddChannelsDeps(), - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "done", addedChannels: [] }); - const slackRow = listPaints[0]?.find((option) => option.value === "slack"); - expect(slackRow).toMatchObject({ disabled: true, disabledReason: "already configured" }); - }); - - /** Drives the channel list once and returns the Slack row from the first paint. */ - async function slackRowFor(input: { - deployment: DeploymentInfo; - authStatus: VercelAuthStatus; - }): Promise | undefined> { - const { single, listPaints } = scriptList(["done"]); - const fake = createFakePrompter({ single }); - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => input.deployment), - inspectExistingChannelRegistrations: vi.fn(async () => NO_REGISTRATIONS), - getVercelAuthStatus: vi.fn(async () => input.authStatus), - addChannels: createAddChannelsDeps(), - installRegistryItem: vi.fn(async () => {}), - }, - }); - expect(result).toEqual({ kind: "done", addedChannels: [] }); - return listPaints[0]?.find((option) => option.value === "slack"); - } - - it.each([ - [UNLINKED, "authenticated"], - [UNLINKED, "logged-out"], - [LINKED, "logged-out"], - [UNLINKED, "cli-missing"], - [LINKED, "unavailable"], - ] as const)( - "keeps Slack addable for deployment %o and Vercel status %s", - async (deployment, authStatus) => { - const slackRow = await slackRowFor({ deployment, authStatus }); - expect(slackRow).toMatchObject({ value: "slack", label: "Slack" }); - expect(slackRow?.disabled).toBeUndefined(); - }, - ); - - it("shows the active Terminal UI as a checked task and keeps Web Chat addable", async () => { - const { single, listPaints } = scriptList(["web", "done"]); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - inspectExistingChannelRegistrations: vi.fn(async () => ({ - ...NO_REGISTRATIONS, - webRouteOwners: ["channels/eve.ts"], - })), - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }); - - // The scaffolded eve.ts serves the REPL; it must not block the Next.js app. - expect(result).toEqual({ kind: "done", addedChannels: ["web"] }); - expect(addChannelsDeps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "web" }), - ); - expect(listPaints[0]?.[0]).toMatchObject({ - value: "repl", - label: "Terminal UI", - completed: true, - focusHint: "Already installed", - }); - const webRow = listPaints[0]?.find((option) => option.value === "web"); - expect(webRow?.locked).toBeUndefined(); - }); - - it("checks Web Chat when the Next.js app is already present", async () => { - const { single, listPaints } = scriptList(["done"]); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - inspectExistingChannelRegistrations: vi.fn(async () => ({ - ...NO_REGISTRATIONS, - webRouteOwners: ["channels/eve.ts"], - webAppPresent: true, - })), - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "done", addedChannels: [] }); - expect(addChannelsDeps.ensureChannel).not.toHaveBeenCalled(); - const webRow = listPaints[0]?.find((option) => option.value === "web"); - expect(webRow).toMatchObject({ - completed: true, - focusHint: "Already installed", - label: "Web Chat", - }); - expect(webRow?.hint).toBeUndefined(); - }); - - it("defensively treats a completed value returned by a prompter as a no-op", async () => { - const { single, listPaints } = scriptList(["repl", "web", "done"]); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - inspectExistingChannelRegistrations: vi.fn(async () => ({ - ...NO_REGISTRATIONS, - webAppPresent: true, - })), - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "done", addedChannels: [] }); - expect(addChannelsDeps.ensureChannel).not.toHaveBeenCalled(); - expect(listPaints).toHaveLength(3); - }); - - it("folds an Esc with no additions to cancelled", async () => { - const { single } = scriptList([CANCEL]); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - inspectExistingChannelRegistrations: vi.fn(async () => NO_REGISTRATIONS), - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "cancelled" }); - expect(addChannelsDeps.ensureChannel).not.toHaveBeenCalled(); - }); - - it("reports additions on Esc exactly like Done — they already happened on disk", async () => { - const { single } = scriptList(["web", CANCEL]); - const fake = createFakePrompter({ single }); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - inspectExistingChannelRegistrations: vi.fn(async () => NO_REGISTRATIONS), - addChannels: createAddChannelsDeps(), - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "done", addedChannels: ["web"] }); - }); - - it("returns to the list when a channel's sub-flow is cancelled", async () => { - const { single, listPaints } = scriptList(["slack", "done"]); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - addChannelsDeps.provisionSlackbot.mockResolvedValue({ state: "cancelled" }); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn( - async () => ({ state: "linked", projectId: "prj_1", orgId: "org_1" }) as DeploymentInfo, - ), - inspectExistingChannelRegistrations: vi.fn(async () => NO_REGISTRATIONS), - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "done", addedChannels: [] }); - expect(addChannelsDeps.ensureChannel).not.toHaveBeenCalled(); - expect(listPaints).toHaveLength(2); - }); - - it("reports a channel whose files landed before cancellation", async () => { - const controller = new AbortController(); - const { single } = scriptList(["web"]); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - addChannelsDeps.ensureChannel.mockImplementation(async (options) => { - controller.abort(new WizardCancelledError()); - return { - kind: "web", - action: "created", - filesWritten: [join(options.projectRoot, "app/page.tsx")], - filesSkipped: [], - packageJsonUpdated: [], - }; - }); - const inspect = vi - .fn(async () => NO_REGISTRATIONS) - .mockResolvedValueOnce(NO_REGISTRATIONS) - .mockResolvedValueOnce({ ...NO_REGISTRATIONS, webAppPresent: true }); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - signal: controller.signal, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - inspectExistingChannelRegistrations: inspect, - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "done", addedChannels: ["web"] }); - }); - - it("repaints the durable channel before reporting a later Slack failure", async () => { - const { single, listPaints } = scriptList(["slack", "done"], (opts) => { - if (/slackbot/i.test(opts.message)) return "yes"; - throw new Error(`Unexpected select: ${opts.message}`); - }); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - addChannelsDeps.ensureChannel.mockResolvedValue({ - kind: "slack", - action: "skipped", - filesWritten: [], - filesSkipped: [join(APP_ROOT, "agent/channels/slack.ts")], - packageJsonUpdated: [], - }); - addChannelsDeps.reconcileSlackUid.mockResolvedValue(false); - const inspect = vi - .fn(async () => NO_REGISTRATIONS) - .mockResolvedValueOnce(NO_REGISTRATIONS) - .mockResolvedValueOnce({ - ...NO_REGISTRATIONS, - slackOwners: ["channels/slack.ts"], - }); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn( - async () => ({ state: "linked", projectId: "prj_1", orgId: "org_1" }) as DeploymentInfo, - ), - inspectExistingChannelRegistrations: inspect, - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ - kind: "failed", - addedChannels: ["slack"], - message: "Slack connector UID update is required before deployment.", - }); - expect(listPaints).toHaveLength(2); - expect(listPaints[1]?.find((option) => option.value === "slack")).toMatchObject({ - completed: true, - focusHint: "Already installed", - }); - }); - - it("propagates a provisioning auth error (nothing landed) so the caller can route it", async () => { - const { single } = scriptList(["slack", "done"], (opts) => { - if (/slackbot/i.test(opts.message)) return "yes"; - throw new Error(`Unexpected select: ${opts.message}`); - }); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - // Provisioning runs before the slack file is scaffolded, so a logged-out - // failure throws with no channel landed; the flow must re-throw it (the - // command handler routes it to /vc:login) rather than swallow it. - addChannelsDeps.provisionSlackbot = vi.fn(async () => { - throw new HumanActionRequiredError({ - kind: "vercel-login", - command: "vercel login", - reason: "not logged in", - }); - }); - await expect( - runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => LINKED), - inspectExistingChannelRegistrations: vi.fn(async () => NO_REGISTRATIONS), - getVercelAuthStatus: vi.fn(async (): Promise => "authenticated"), - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }), - ).rejects.toBeInstanceOf(HumanActionRequiredError); - }); - - it("adds Slack when the directory is linked, with no link pickers", async () => { - const { single } = scriptList(["slack", "done"], (opts) => { - if (opts.message === SEE_IT_LIVE_MESSAGE) return "later"; - if (/slackbot/i.test(opts.message)) return "yes"; - throw new Error(`Unexpected select: ${opts.message}`); - }); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn( - async () => ({ state: "linked", projectId: "prj_1", orgId: "org_1" }) as DeploymentInfo, - ), - inspectExistingChannelRegistrations: vi.fn(async () => NO_REGISTRATIONS), - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "done", addedChannels: ["slack"] }); - expect(addChannelsDeps.provisionSlackbot).toHaveBeenCalled(); - }); - - it("offers 'See it live' after a Slack connection and returns deploy-and-chat on Deploy", async () => { - const seen: string[] = []; - const { single } = scriptList(["slack"], (opts) => { - seen.push(opts.message); - if (opts.message === SEE_IT_LIVE_MESSAGE) return "deploy"; - if (/slackbot/i.test(opts.message)) return "yes"; - throw new Error(`Unexpected select: ${opts.message}`); - }); - const fake = createFakePrompter({ single }); - const addChannelsDeps = createAddChannelsDeps(); - // A workspace URL on the connection rides back so the caller can link to it. - addChannelsDeps.provisionSlackbot = vi.fn(async () => ({ - state: "attached", - connectorUid: "slack/my-agent", - chatUrl: "https://app.slack.com/client/T123", - workspaceName: "Acme", - })); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn( - async () => ({ state: "linked", projectId: "prj_1", orgId: "org_1" }) as DeploymentInfo, - ), - inspectExistingChannelRegistrations: vi.fn(async () => NO_REGISTRATIONS), - addChannels: addChannelsDeps, - installRegistryItem: vi.fn(async () => {}), - }, - }); - - // "Deploy" ends the loop straight away — no second list paint, no Done. - expect(result).toEqual({ - kind: "deploy-and-chat", - addedChannels: ["slack"], - chat: { chatUrl: "https://app.slack.com/client/T123", workspaceName: "Acme" }, - }); - expect(seen).toContain(SEE_IT_LIVE_MESSAGE); - }); - - it("does not offer 'See it live' for a non-Slack channel", async () => { - const seen: string[] = []; - const { single } = scriptList(["web", "done"], (opts) => { - seen.push(opts.message); - throw new Error(`Unexpected select: ${opts.message}`); - }); - const fake = createFakePrompter({ single }); - - const result = await runChannelsFlow({ - appRoot: APP_ROOT, - prompter: fake.prompter, - deps: { - detectDeployment: vi.fn(async () => UNLINKED), - inspectExistingChannelRegistrations: vi.fn(async () => NO_REGISTRATIONS), - addChannels: createAddChannelsDeps(), - installRegistryItem: vi.fn(async () => {}), - }, - }); - - expect(result).toEqual({ kind: "done", addedChannels: ["web"] }); - expect(seen).not.toContain(SEE_IT_LIVE_MESSAGE); - }); -}); diff --git a/packages/eve/src/setup/flows/channels.ts b/packages/eve/src/setup/flows/channels.ts deleted file mode 100644 index fd13c24d3..000000000 --- a/packages/eve/src/setup/flows/channels.ts +++ /dev/null @@ -1,315 +0,0 @@ -import type { ChannelKind } from "#setup/scaffold/index.js"; -import type { AddCommandOptions } from "#cli/commands/registry.js"; -import { toErrorMessage } from "#shared/errors.js"; - -import { interactiveAsker } from "../ask.js"; -import type { AddChannelsDeps } from "../boxes/add-channels.js"; -import { CHANNELS_PROMPT_MESSAGE } from "../boxes/select-channels.js"; -import { channelSetupEnvironment } from "../channel-setup-environment.js"; -import { - CHANNEL_SETUP_INTEGRATIONS, - channelSetupIntegration, - createChannelSetupUi, -} from "../channel-setup-integrations.js"; -import { - assertCanAddSelectedChannels, - inspectExistingChannelRegistrations, - type ExistingChannelRegistrations, -} from "../channel-add-conflicts.js"; -import { detectDeployment, projectResolutionFromDeployment } from "../project-resolution.js"; -import type { Prompter, SelectOption, SingleSelectOptions } from "../prompter.js"; -import { WizardCancelledError } from "../step.js"; - -import { createDefaultSetupState, type SetupState } from "../state.js"; -import { getVercelAuthStatus } from "../vercel-project.js"; -import { withSpinner } from "../with-spinner.js"; - -/** Injected for tests; defaults to the real detection and box effects. */ -export interface ChannelsFlowDeps { - detectDeployment: typeof detectDeployment; - inspectExistingChannelRegistrations: typeof inspectExistingChannelRegistrations; - getVercelAuthStatus: typeof getVercelAuthStatus; - addChannels?: AddChannelsDeps; - installRegistryItem(appRoot: string, item: string, options?: AddCommandOptions): Promise; -} - -export type ChannelsFlowResult = - | { - kind: "done"; - addedChannels: readonly string[]; - } - | { - /** - * The user chose "Deploy and chat" on the post-Slack "See it live" - * prompt. The caller deploys, then points them at this workspace. - */ - kind: "deploy-and-chat"; - addedChannels: readonly string[]; - chat: { chatUrl?: string; workspaceName?: string }; - } - | { kind: "cancelled" } - | { - kind: "failed"; - addedChannels: readonly string[]; - message: string; - }; - -/** Title for Slack's optional deploy-and-chat continuation. */ -export const SEE_IT_LIVE_MESSAGE = "See it live"; - -async function offerDeployAndChat(prompter: Prompter): Promise { - try { - return ( - (await prompter.select<"deploy" | "later">({ - message: SEE_IT_LIVE_MESSAGE, - options: [ - { value: "deploy", label: "Deploy and chat" }, - { value: "later", label: "Later" }, - ], - })) === "deploy" - ); - } catch (error) { - if (error instanceof WizardCancelledError) return false; - throw error; - } -} - -/** One row on the channel task list: a channel, the local TUI, or Done. */ -type ChannelListRow = ChannelKind | "done" | "repl"; - -function channelAlreadyAdded( - registrations: ExistingChannelRegistrations, - channel: ChannelKind, -): boolean { - return channel === "web" ? registrations.webAppPresent : registrations.slackOwners.length > 0; -} - -function appendChannel(channels: readonly ChannelKind[], channel: ChannelKind): ChannelKind[] { - return channels.includes(channel) ? [...channels] : [...channels, channel]; -} - -type ChannelPickResult = { kind: "picked"; value: ChannelListRow } | { kind: "cancelled" }; - -async function pickChannel( - prompter: Prompter, - registrations: ExistingChannelRegistrations, -): Promise { - const rows = channelListRows(registrations); - // When every channel is already added or unavailable, the only action left - // is to finish: default to "Done" instead of a completed row. - const onlyDoneRemains = !rows.some( - (row) => row.value !== "done" && row.completed !== true && row.disabled !== true, - ); - const request: SingleSelectOptions = { - message: CHANNELS_PROMPT_MESSAGE, - options: rows, - hintLayout: "inline", - }; - if (onlyDoneRemains) request.initialValue = "done"; - - try { - return { kind: "picked", value: await prompter.select(request) }; - } catch (error) { - if (error instanceof WizardCancelledError) return { kind: "cancelled" }; - throw error; - } -} - -function channelLandedDuringSubflow( - before: ExistingChannelRegistrations, - after: ExistingChannelRegistrations, - channel: ChannelKind, -): boolean { - return !channelAlreadyAdded(before, channel) && channelAlreadyAdded(after, channel); -} - -function deployAndChatDetails(state: Readonly): { - chatUrl?: string; - workspaceName?: string; -} { - const details: { chatUrl?: string; workspaceName?: string } = {}; - if (state.slackChatUrl !== undefined) details.chatUrl = state.slackChatUrl; - if (state.slackWorkspaceName !== undefined) details.workspaceName = state.slackWorkspaceName; - return details; -} - -/** - * The action list reads like a task list: the active Terminal UI and configured - * channels render checked and remain cursor-addressable for an "Already - * installed" hint, but cannot be selected. Conflicting channels are disabled - * with the reason, and the rest are pickable. The Web Chat row tracks the - * Next.js app itself (`webAppPresent`), not the authored session-route channel - * used by this REPL. - */ -function channelListRows( - registrations: ExistingChannelRegistrations, -): SelectOption[] { - const rows: SelectOption[] = [ - { - value: "repl", - label: "Terminal UI", - completed: true, - focusHint: "Already installed", - }, - ]; - for (const channel of CHANNEL_SETUP_INTEGRATIONS) { - if (channelAlreadyAdded(registrations, channel.kind)) { - rows.push({ - value: channel.kind, - label: channel.label, - completed: true, - focusHint: "Already installed", - }); - continue; - } - const disabledReason = registrations.disabledChannelReasons[channel.kind]; - if (disabledReason !== undefined) { - rows.push({ value: channel.kind, label: channel.label, disabled: true, disabledReason }); - continue; - } - const row: SelectOption = { value: channel.kind, label: channel.label }; - if (channel.hint !== undefined) row.hint = channel.hint; - rows.push(row); - } - rows.push({ value: "done", label: "Done", trailingAction: true }); - return rows; -} - -/** - * THE CHANNELS FLOW for the dev TUI's `/channels`: a task list that loops. - * Pick an unregistered channel, run its add sub-flow (Slack provisioning - * included), and land back on the repainted list with that channel checked; - * "Done" or Esc leaves. Filesystem effects can land before the runner applies - * their in-memory payload, so every cancelled or failed sub-flow re-inspects - * authored registrations and preserves a channel that became durable. Esc on - * the list after something was added reports the additions exactly like Done; - * only an empty exit folds to cancelled. - * - * The outer loop owns only picker lifecycle, conflict validation, and durable - * re-inspection. Each selected integration owns its prompts, provisioning, - * scaffold choices, deployment continuation, and next-step guidance. - */ -export async function runChannelsFlow(input: { - appRoot: string; - prompter: Prompter; - signal?: AbortSignal; - deps?: Partial; -}): Promise { - const { appRoot, prompter, signal } = input; - const deps: ChannelsFlowDeps = { - detectDeployment, - inspectExistingChannelRegistrations, - getVercelAuthStatus, - installRegistryItem: async (projectRoot, item, options) => { - const { installOfficialRegistryItem } = await import("#cli/commands/registry.js"); - await installOfficialRegistryItem(projectRoot, item, options); - }, - ...input.deps, - }; - - // Link detection and the auth probe are independent `vercel` round-trips; - // the registration compile is local. One ephemeral spinner covers all three - // so the list paints with no persisted loading lines. Login is a separate - // axis from link: a logged-out (or CLI-missing) session blocks a Vercel-backed - // channel even when the directory is linked. - const [deployment, initialRegistrations, authStatus] = await withSpinner( - prompter, - "Checking the project…", - () => - Promise.all([ - deps.detectDeployment(appRoot, { signal }), - deps.inspectExistingChannelRegistrations(appRoot), - deps.getVercelAuthStatus(appRoot, { signal }), - ]), - ); - signal?.throwIfAborted(); - let registrations = initialRegistrations; - - // The detected on-disk link is the only seeded fact, exactly like - // `eve channels add`. The state carries forward across picks so a link or - // slackbot established for one channel is not redone for the next. - const environment = channelSetupEnvironment( - authStatus, - projectResolutionFromDeployment(deployment), - ); - let state: SetupState = { - ...createDefaultSetupState(), - project: projectResolutionFromDeployment(deployment), - projectPath: { kind: "resolved", inPlace: true, path: appRoot }, - }; - let retainedFailure: string | undefined; - - while (true) { - const picked = await pickChannel(prompter, registrations); - if (picked.kind === "cancelled") { - if (state.channels.length === 0) return { kind: "cancelled" }; - break; - } - const pick = picked.value; - if (pick === "done") break; - if (pick === "repl" || channelAlreadyAdded(registrations, pick)) continue; - - assertCanAddSelectedChannels([pick], registrations); - await deps.installRegistryItem(appRoot, `channel/${pick}`); - let result: Awaited["setup"]>>; - try { - result = await channelSetupIntegration(pick).setup({ - environment, - state: { ...state, channelSelection: [pick] }, - ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), - signal, - skipDependencyMutation: true, - deps: deps.addChannels, - }); - } catch (error) { - // Cancellation can arrive after files land. Re-inspect without an - // abort-aware spinner in that case so durable success is still reported. - const observed = - signal?.aborted === true - ? await deps.inspectExistingChannelRegistrations(appRoot) - : await withSpinner(prompter, "Checking the project…", () => - deps.inspectExistingChannelRegistrations(appRoot), - ); - if (channelLandedDuringSubflow(registrations, observed, pick)) { - state = { ...state, channels: appendChannel(state.channels, pick) }; - registrations = observed; - if (!(error instanceof WizardCancelledError)) retainedFailure = toErrorMessage(error); - if (signal?.aborted === true) break; - continue; - } - if (error instanceof WizardCancelledError) { - registrations = observed; - continue; - } - // A provisioning failure (login / forbidden / missing CLI) throws before - // the channel file is scaffolded, so it never lands here — it propagates - // to the command handler, which routes it to its fix command. - throw error; - } - if (result.kind === "done") state = result.state; - const observed = - signal?.aborted === true - ? await deps.inspectExistingChannelRegistrations(appRoot) - : await withSpinner(prompter, "Checking the project…", () => - deps.inspectExistingChannelRegistrations(appRoot), - ); - if (channelLandedDuringSubflow(registrations, observed, pick)) { - state = { ...state, channels: appendChannel(state.channels, pick) }; - } - registrations = observed; - if (signal?.aborted === true) break; - if (result.kind === "cancelled") continue; - if (pick === "slack" && state.slackbotAttached && (await offerDeployAndChat(prompter))) { - return { - kind: "deploy-and-chat", - addedChannels: state.channels, - chat: deployAndChatDetails(state), - }; - } - } - - if (retainedFailure === undefined) { - return { kind: "done", addedChannels: state.channels }; - } - return { kind: "failed", addedChannels: state.channels, message: retainedFailure }; -} diff --git a/packages/eve/src/setup/flows/in-project.ts b/packages/eve/src/setup/flows/in-project.ts index 8fd3751d7..c47881643 100644 --- a/packages/eve/src/setup/flows/in-project.ts +++ b/packages/eve/src/setup/flows/in-project.ts @@ -12,7 +12,7 @@ import type { OutputSink } from "../step.js"; * named X" row), and the resolved in-place project path. The channels flow * seeds its own state instead — it must keep the default empty agent name so * the Slack connector slug falls back to the package.json name, exactly like - * `eve channels add`. + * `eve add channel/slack`. */ export function inProjectSetupState( appRoot: string, diff --git a/packages/eve/src/setup/index.ts b/packages/eve/src/setup/index.ts index 401c1b352..755b03eb9 100644 --- a/packages/eve/src/setup/index.ts +++ b/packages/eve/src/setup/index.ts @@ -1,5 +1,4 @@ -// Public setup primitives remain available for programmatic onboarding flows, -// even though eve currently exposes no setup wizard command. +// Public setup primitives shared by CLI flows and external setup tooling. export { type OutputSink, type SetupBox, WizardCancelledError } from "./step.js"; export { InteractionRequired } from "./ask.js"; export { @@ -46,7 +45,6 @@ export { type HeadlessNextStep, HeadlessPromptError, } from "./headless.js"; -export { composeOnboardingBoxes, type OnboardingBoxesOptions } from "./onboarding.js"; export { createPromptCommandOutput, type PromptCommandLog } from "./cli/index.js"; export { getPackageManagerStrategy, diff --git a/packages/eve/src/setup/channel-setup-environment.test.ts b/packages/eve/src/setup/integrations/channels/environment.test.ts similarity index 89% rename from packages/eve/src/setup/channel-setup-environment.test.ts rename to packages/eve/src/setup/integrations/channels/environment.test.ts index 5a9c26670..58a0f5912 100644 --- a/packages/eve/src/setup/channel-setup-environment.test.ts +++ b/packages/eve/src/setup/integrations/channels/environment.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - channelSetupEnvironment, - describeChannelSetupEnvironment, -} from "./channel-setup-environment.js"; +import { channelSetupEnvironment, describeChannelSetupEnvironment } from "./environment.js"; describe("channel setup environment", () => { it("keeps authenticated and unlinked as an available Vercel setup", () => { diff --git a/packages/eve/src/setup/channel-setup-environment.ts b/packages/eve/src/setup/integrations/channels/environment.ts similarity index 92% rename from packages/eve/src/setup/channel-setup-environment.ts rename to packages/eve/src/setup/integrations/channels/environment.ts index 449832273..536a044cc 100644 --- a/packages/eve/src/setup/channel-setup-environment.ts +++ b/packages/eve/src/setup/integrations/channels/environment.ts @@ -1,5 +1,5 @@ -import type { ProjectResolution } from "./project-resolution.js"; -import type { VercelAuthStatus } from "./vercel-project.js"; +import type { ProjectResolution } from "../../project-resolution.js"; +import type { VercelAuthStatus } from "../../vercel-project.js"; /** Read-only hosting facts available to channel-owned setup hooks. */ export interface ChannelSetupEnvironment { diff --git a/packages/eve/src/setup/channel-setup-integrations.test.ts b/packages/eve/src/setup/integrations/channels/index.test.ts similarity index 87% rename from packages/eve/src/setup/channel-setup-integrations.test.ts rename to packages/eve/src/setup/integrations/channels/index.test.ts index 6433e690c..5e84be3cf 100644 --- a/packages/eve/src/setup/channel-setup-integrations.test.ts +++ b/packages/eve/src/setup/integrations/channels/index.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import { interactiveAsker } from "./ask.js"; -import type { AddChannelsDeps } from "./boxes/add-channels.js"; -import { channelSetupEnvironment } from "./channel-setup-environment.js"; -import { channelSetupIntegration, createChannelSetupUi } from "./channel-setup-integrations.js"; -import { createDefaultSetupState } from "./state.js"; -import { WizardCancelledError } from "./step.js"; +import { interactiveAsker } from "../../ask.js"; +import type { AddChannelsDeps } from "./setup.js"; +import { channelSetupEnvironment } from "./environment.js"; +import { channelSetupIntegration, createChannelSetupUi } from "./index.js"; +import { createDefaultSetupState } from "../../state.js"; +import { WizardCancelledError } from "../../step.js"; function context(prompter = createFakePrompter().prompter) { return { diff --git a/packages/eve/src/setup/channel-setup-integrations.ts b/packages/eve/src/setup/integrations/channels/index.ts similarity index 64% rename from packages/eve/src/setup/channel-setup-integrations.ts rename to packages/eve/src/setup/integrations/channels/index.ts index 3f016eaa2..ac6573a91 100644 --- a/packages/eve/src/setup/channel-setup-integrations.ts +++ b/packages/eve/src/setup/integrations/channels/index.ts @@ -1,7 +1,7 @@ -import type { ChannelSetupIntegration } from "./channel-setup-integration.js"; -import { SLACK_CHANNEL_SETUP } from "./channel-setup-slack.js"; -import { WEB_CHANNEL_SETUP } from "./channel-setup-web.js"; -import type { ChannelKind } from "./scaffold/index.js"; +import type { ChannelSetupIntegration } from "./types.js"; +import { SLACK_CHANNEL_SETUP } from "./slack.js"; +import { WEB_CHANNEL_SETUP } from "./web.js"; +import type { ChannelKind } from "../../scaffold/index.js"; /** Built-in channel integrations in canonical picker order. */ export const CHANNEL_SETUP_INTEGRATIONS: readonly ChannelSetupIntegration[] = [ @@ -16,4 +16,4 @@ export function channelSetupIntegration(kind: ChannelKind): ChannelSetupIntegrat return integration; } -export { createChannelSetupUi } from "./channel-setup-ui.js"; +export { createChannelSetupUi } from "./ui.js"; diff --git a/packages/eve/src/setup/channel-setup-runner.ts b/packages/eve/src/setup/integrations/channels/runner.ts similarity index 75% rename from packages/eve/src/setup/channel-setup-runner.ts rename to packages/eve/src/setup/integrations/channels/runner.ts index 6af855b8c..ffeccea90 100644 --- a/packages/eve/src/setup/channel-setup-runner.ts +++ b/packages/eve/src/setup/integrations/channels/runner.ts @@ -1,8 +1,8 @@ -import { addChannels } from "./boxes/add-channels.js"; -import type { ChannelSetupContext, ChannelSetupResult } from "./channel-setup-integration.js"; -import { runInteractive } from "./runner.js"; -import { snapshotSetupState, type SetupState } from "./state.js"; -import type { OutputSink } from "./step.js"; +import { addChannels } from "./setup.js"; +import type { ChannelSetupContext, ChannelSetupResult } from "./types.js"; +import { runInteractive } from "../../runner.js"; +import type { AddChannelsState } from "./setup.js"; +import type { OutputSink } from "../../step.js"; /** Runs the shared scaffold box with decisions supplied by a channel integration. */ export async function runChannelSetup( @@ -26,8 +26,7 @@ export async function runChannelSetup( deps: context.deps, }); const sink: OutputSink = { write: (line) => context.ui.prompter.log.message(line) }; - const result = await runInteractive([box], context.state as SetupState, sink, { - snapshot: snapshotSetupState, + const result = await runInteractive([box], context.state as AddChannelsState, sink, { signal: context.signal, }); return result.kind === "done" ? { kind: "done", state: result.state } : { kind: "cancelled" }; diff --git a/packages/eve/src/setup/boxes/add-channels.test.ts b/packages/eve/src/setup/integrations/channels/setup.test.ts similarity index 89% rename from packages/eve/src/setup/boxes/add-channels.test.ts rename to packages/eve/src/setup/integrations/channels/setup.test.ts index b1288e36a..f5634f0b8 100644 --- a/packages/eve/src/setup/boxes/add-channels.test.ts +++ b/packages/eve/src/setup/integrations/channels/setup.test.ts @@ -5,12 +5,12 @@ import { normalizeSlackConnectorSlug } from "#setup/scaffold/index.js"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import { headlessAsker, interactiveAsker } from "../ask.js"; -import type { Prompter } from "../prompter.js"; -import { createDefaultSetupState, snapshotSetupState, type SetupState } from "../state.js"; -import type { OutputSink } from "../step.js"; -import { runHeadless, runInteractive } from "../runner.js"; -import { addChannels, type AddChannelsDeps, type AddChannelsOptions } from "./add-channels.js"; +import { headlessAsker, interactiveAsker } from "../../ask.js"; +import type { Prompter } from "../../prompter.js"; +import { createDefaultSetupState, snapshotSetupState, type SetupState } from "../../state.js"; +import type { OutputSink } from "../../step.js"; +import { runHeadless, runInteractive } from "../../runner.js"; +import { addChannels, type AddChannelsDeps, type AddChannelsOptions } from "./setup.js"; const silentSink: OutputSink = { write: () => {} }; const snapshot = { snapshot: snapshotSetupState }; @@ -29,9 +29,9 @@ function createPrompter(): Prompter { */ function makeBox( options: Omit & { headless?: boolean }, -): ReturnType { +): ReturnType> { const headless = options.headless ?? false; - return addChannels({ + return addChannels({ ...options, asker: headless ? headlessAsker() : interactiveAsker(options.prompter), headless, @@ -136,7 +136,7 @@ describe("addChannels box", () => { const run = runHeadless([box], resolvedState(["web", "slack"]), silentSink, snapshot); await expect(run).rejects.toThrow( - "Slack setup is interactive. Run `eve channels add slack` from an interactive terminal.", + "Slack setup is interactive. Run `eve add channel/slack` from an interactive terminal.", ); // This is a command-mode mismatch, not a browser action the caller can resume. await expect(run).rejects.not.toBeInstanceOf(HumanActionRequiredError); @@ -209,7 +209,6 @@ describe("addChannels box", () => { ); expect(next.channels).toEqual(["web"]); expect(next.webScaffolded).toBe(true); - expect(next.deploymentPending).toBe(true); }); it("installs dependencies after recording channels and marks the deploy install done", async () => { @@ -222,7 +221,6 @@ describe("addChannels box", () => { onOutput: expect.any(Function), }); expect(next.channels).toEqual(["web"]); - expect(next.deploymentDependenciesInstalled).toBe(true); }); it("keeps channels recorded when the install fails, leaving the deploy install pending", async () => { @@ -232,19 +230,12 @@ describe("addChannels box", () => { const box = makeBox({ prompter, evePackage: TEST_EVE_PACKAGE, deps }); // An earlier success must go stale: the scaffold just changed package.json. - const next = await runHeadless( - [box], - { ...resolvedState(), deploymentDependenciesInstalled: true }, - silentSink, - snapshot, - ); + const next = await runHeadless([box], resolvedState(), silentSink, snapshot); expect(prompter.log.warning).toHaveBeenCalledWith( "Dependency installation failed. The new channels stay unloadable until `pnpm install` or a deploy succeeds.", ); expect(next.channels).toEqual(["web"]); - expect(next.deploymentPending).toBe(true); - expect(next.deploymentDependenciesInstalled).toBe(false); }); it("skips the install when no channel was recorded", async () => { @@ -259,21 +250,14 @@ describe("addChannels box", () => { }); const box = makeBox({ prompter: createPrompter(), evePackage: TEST_EVE_PACKAGE, deps }); - const next = await runHeadless( - [box], - { ...resolvedState(), deploymentDependenciesInstalled: true }, - silentSink, - snapshot, - ); + await runHeadless([box], resolvedState(), silentSink, snapshot); expect(deps.runPackageManagerInstall).not.toHaveBeenCalled(); - // Nothing recorded, nothing installed: the earlier install stays valid. - expect(next.deploymentDependenciesInstalled).toBe(true); }); it("honors the configureVercelServices override over the Vercel-project gate", async () => { const deps = createDeps(); - // `eve channels add` pins the services config on even when unlinked, the + // The integration setup can pin the services config on even when unlinked, the // behavior the dissolved engine had (ensureChannel defaulted it to true). const box = makeBox({ prompter: createPrompter(), @@ -365,7 +349,6 @@ describe("addChannels box", () => { expect(next.kind).toBe("done"); if (next.kind === "done") { expect(next.state.channels).toEqual([]); - expect(next.state.deploymentPending).toBe(false); } }); @@ -446,12 +429,6 @@ describe("addChannels box", () => { expect(result.kind).toBe("done"); if (result.kind === "done") { expect(result.state.channels).toEqual(["slack"]); - expect(result.state.slackbotCreated).toBe(true); - expect(result.state.slackbotAttached).toBe(true); - expect(result.state.slackConnectorUid).toBe("slack/my-agent-2"); - expect(result.state.slackChatUrl).toBe("https://slack.com/app_redirect?app=A0&team=T0"); - expect(result.state.slackWorkspaceName).toBe("Vercel"); - expect(result.state.deploymentPending).toBe(true); } }); @@ -558,7 +535,6 @@ describe("addChannels box", () => { // cannot arm a deploy; a skipped Slack file write still records the channel. expect(next.channels).toEqual([]); expect(next.webScaffolded).toBe(false); - expect(next.deploymentPending).toBe(false); }); it("continues without Slack when creation fails under warn-and-continue", async () => { @@ -589,7 +565,7 @@ describe("addChannels box", () => { expect(result.state.slackScaffolded).toBe(false); expect(result.state.slackbotCreated).toBe(false); expect(prompter.log.warning).toHaveBeenCalledWith( - "Slackbot creation failed. Continuing without Slack — add it later with `eve channels add slack`.", + "Slackbot creation failed. Continuing without Slack — add it later with `eve add channel/slack`.", ); // The slack channel scaffold never ran (only web's). expect(deps.ensureChannel).toHaveBeenCalledTimes(1); @@ -660,7 +636,7 @@ describe("addChannels box", () => { if (result.kind !== "done") return; expect(result.state.channels).toEqual([]); expect(result.state.slackbotCreated).toBe(false); - // Not "eve channels add slack": re-creating would orphan the connector + // Not "eve add channel/slack": re-creating would orphan the connector // that already exists; the attach remediation was printed by the provision. expect(prompter.log.warning).toHaveBeenCalledWith( "Slackbot provisioning did not attach this project. Slack channel was not added. Continuing without Slack — finish event delivery with the `vercel connect attach` command above.", @@ -737,7 +713,7 @@ describe("addChannels box", () => { expect(result.state.channels).toEqual([]); expect(result.state.slackScaffolded).toBe(false); expect(prompter.log.warning).toHaveBeenCalledWith( - "Slackbot is not connected to a Slack workspace. Slack channel was not added. Continuing without Slack — the install timed out and was cleaned up; re-run `eve channels add slack` to try again.", + "Slackbot is not connected to a Slack workspace. Slack channel was not added. Continuing without Slack — the install timed out and was cleaned up; re-run `eve add channel/slack` to try again.", ); }); @@ -851,59 +827,6 @@ describe("addChannels box", () => { expect(state.slackbotCreated).toBe(false); }); - it("reuses an attached slackbot on rerun and scaffolds its exact UID", async () => { - const deps = createDeps(); - const state: SetupState = { - ...resolvedState(["slack"]), - slackbotCreated: true, - slackbotAttached: true, - slackConnectorUid: "slack/my-agent", - deploymentPending: true, - }; - const box = makeBox({ - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - const result = await runInteractive([box], state, silentSink, snapshot); - - expect(deps.provisionSlackbot).not.toHaveBeenCalled(); - expect(deps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "slack", - slackConnectorUid: "slack/my-agent", - slackConnectorSlug: "my-agent", - }), - ); - expect(deps.reconcileSlackUid).not.toHaveBeenCalled(); - expect(result.kind).toBe("done"); - if (result.kind === "done") { - expect(result.state.channels).toEqual(["slack"]); - } - }); - - it("throws on rerun when the recorded slackbot never attached", async () => { - const deps = createDeps(); - const state: SetupState = { - ...resolvedState(["slack"]), - slackbotCreated: true, - slackbotAttached: false, - }; - const box = makeBox({ - prompter: createPrompter(), - evePackage: TEST_EVE_PACKAGE, - presetCreateSlackbot: true, - deps, - }); - - await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - "Slackbot provisioning did not attach this project. Slack channel was not added.", - ); - expect(deps.provisionSlackbot).not.toHaveBeenCalled(); - }); - it("throws when Slack is selected without a Vercel project", async () => { const deps = createDeps(); const state: SetupState = { ...noVercelState(), channelSelection: ["slack"] }; @@ -915,12 +838,12 @@ describe("addChannels box", () => { }); await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - /Slack requires a Vercel project/, + /requires a linked Vercel project/, ); expect(deps.provisionSlackbot).not.toHaveBeenCalled(); }); - it("throws when the project resolution is missing while deploying to Vercel", async () => { + it("throws when the project resolution is missing", async () => { const deps = createDeps(); const state = resolvedState(["slack"]); // project stays unresolved: the link box did not record a resolution. @@ -933,7 +856,7 @@ describe("addChannels box", () => { }); await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - /none was resolved/, + /requires a linked Vercel project/, ); expect(deps.provisionSlackbot).not.toHaveBeenCalled(); }); @@ -959,7 +882,6 @@ describe("addChannels box", () => { expect(next.channels).toEqual(["web"]); expect(next.webScaffolded).toBe(true); - expect(next.deploymentPending).toBe(true); expect(state.channels).toEqual([]); expect(state.webScaffolded).toBe(false); }); diff --git a/packages/eve/src/setup/boxes/add-channels.ts b/packages/eve/src/setup/integrations/channels/setup.ts similarity index 86% rename from packages/eve/src/setup/boxes/add-channels.ts rename to packages/eve/src/setup/integrations/channels/setup.ts index da01a1a2f..9bf1ae882 100644 --- a/packages/eve/src/setup/boxes/add-channels.ts +++ b/packages/eve/src/setup/integrations/channels/setup.ts @@ -20,23 +20,34 @@ import { mergeProjectResolution, projectResolutionFromDeployment, type ProjectResolution, -} from "../project-resolution.js"; -import type { Asker } from "../ask.js"; -import type { Prompter } from "../prompter.js"; +} from "../../project-resolution.js"; +import type { Asker } from "../../ask.js"; +import type { Prompter } from "../../prompter.js"; import { provisionSlackbot, reconcileSlackUid, type ProvisionSlackbotOptions, type ProvisionSlackbotResult, -} from "../slackbot.js"; -import { hasVercelProject, requireProjectPath, type SetupState } from "../state.js"; -import { WizardCancelledError, type SetupBox } from "../step.js"; +} from "../../slackbot.js"; +import { WizardCancelledError, type SetupBox } from "../../step.js"; + +/** State required by channel setup, kept narrow so the integration can move packages. */ +export interface AddChannelsState { + projectPath: + | string + | { kind: "unresolved"; inPlace: boolean } + | { kind: "resolved"; inPlace: boolean; path: string }; + project: ProjectResolution; + channelSelection: ChannelKind[]; + channels: ChannelKind[]; + webScaffolded: boolean; + slackScaffolded: boolean; +} -const SLACK_REQUIRES_VERCEL = - "Slack requires a Vercel project. Re-run and choose to deploy to Vercel to add Slack."; +const SLACK_REQUIRES_VERCEL = "Slack setup with Vercel Connect requires a linked Vercel project."; const SLACK_HEADLESS_ERROR = - "Slack setup is interactive. Run `eve channels add slack` from an interactive terminal."; + "Slack setup is interactive. Run `eve add channel/slack` from an interactive terminal."; const SLACKBOT_NOT_ATTACHED_ERROR = "Slackbot provisioning did not attach this project. Slack channel was not added."; @@ -75,7 +86,7 @@ function slackbotFailureCopy(result: SlackbotFailure): SlackbotFailureCopy { return { reason: SLACKBOT_NOT_INSTALLED_ERROR, followUp: - "Continuing without Slack — the install timed out and was cleaned up; re-run `eve channels add slack` to try again.", + "Continuing without Slack — the install timed out and was cleaned up; re-run `eve add channel/slack` to try again.", }; case "cleanup-failed": return { @@ -87,13 +98,13 @@ function slackbotFailureCopy(result: SlackbotFailure): SlackbotFailureCopy { return { reason: SLACKBOT_LOOKUP_FAILED_ERROR, followUp: - "Continuing without Slack — restore Vercel CLI access, then re-run `eve channels add slack`.", + "Continuing without Slack — restore Vercel CLI access, then re-run `eve add channel/slack`.", }; case "installation-check-failed": return { reason: SLACKBOT_INSTALLATION_CHECK_FAILED_ERROR, followUp: - "Continuing without Slack — verify Vercel Connect is reachable, then re-run `eve channels add slack`.", + "Continuing without Slack — verify Vercel Connect is reachable, then re-run `eve add channel/slack`.", }; case "existing-not-installed": return { @@ -116,7 +127,7 @@ function slackbotFailureCopy(result: SlackbotFailure): SlackbotFailureCopy { case "create-failed": return { reason: "Slackbot creation failed.", - followUp: "Continuing without Slack — add it later with `eve channels add slack`.", + followUp: "Continuing without Slack — add it later with `eve add channel/slack`.", }; } } @@ -157,7 +168,7 @@ export interface AddChannelsOptions { evePackage?: EvePackageContract; /** Reuse the preferred existing Slack connector without prompting. */ presetCreateSlackbot?: boolean; - /** Overwrite existing channel files (`eve channels add --force`). */ + /** Overwrite existing channel files (`eve add --overwrite channel/slack`). */ force?: boolean; /** Credential source for Slack. Defaults to Vercel Connect. */ slackCredentials?: "vercel-connect" | "environment"; @@ -170,16 +181,15 @@ export interface AddChannelsOptions { /** * Opt-in fallback when Slack is chosen interactively but `state.project` is * unresolved: run the interactive bare `vercel link` before provisioning the - * slackbot. Only the `eve channels add` composition sets this; onboarding - * resolves the project up front via the link box and keeps the hard gate. + * slackbot. The Slack integration sets this so Vercel Connect setup can link + * an unlinked project before provisioning. */ ensureLinkedProject?: "interactive-vercel-link"; /** * What a failed slackbot provision (create or attach) does to the run. The - * default, "abort", fails the whole box — right for `eve channels add slack`, - * where Slack is the point. Onboarding passes "warn-and-continue": the agent - * still scaffolds, deploys, and chats without Slack (recorded as nothing, so - * a later `eve channels add slack` starts clean). + * default, "abort", fails the whole box — right for `eve add channel/slack`, + * where Slack is the point. "warn-and-continue" records nothing so a later + * `eve add channel/slack` starts clean. */ slackbotFailure?: "abort" | "warn-and-continue"; deps?: AddChannelsDeps; @@ -217,8 +227,7 @@ export interface AddChannelsPayload { slackScaffolded: boolean; /** * Whether the post-scaffold dependency install succeeded. False both when no - * channels were recorded (nothing ran) and when the install failed; only a - * success lets `apply` mark the deploy-time install as already done. + * channels were recorded and when the install failed. */ dependenciesChanged: boolean; dependenciesInstalled: boolean; @@ -244,8 +253,7 @@ function warnCompetingNextConfigFiles( } /** - * THE CHANNEL SCAFFOLD BOX. Scaffolds the channels chosen up front by the - * select-channels box (`state.channelSelection`): writes the Web Chat files, + * Channel integration setup. Scaffolds the requested channel: writes the Web Chat files, * provisions the Slackbot through Vercel Connect, writes the Slack channel * definition, reconciles a Connect-assigned connector UID, and installs the * dependencies the scaffold added to `package.json` so a running `eve dev` @@ -254,9 +262,9 @@ function warnCompetingNextConfigFiles( * and reads `state.project` directly, resolved earlier by the link box or the * in-project seed. */ -export function addChannels( +export function addChannels( options: AddChannelsOptions, -): SetupBox { +): SetupBox { const deps = options.deps ?? { ensureChannel, deriveSlackConnectorSlug, @@ -271,7 +279,7 @@ export function addChannels( async function scaffoldSlackChannel( log: ChannelSetupLog, - state: Readonly, + state: Readonly, projectPath: string, slug: SlackConnectorSlug, payload: AddChannelsPayload, @@ -297,8 +305,7 @@ export function addChannels( wroteExactConnectorUid = result.action !== "skipped"; payload.slackScaffolded = true; } - // Slack is recorded even when the file already existed: the channel is - // live either way and the pending deploy must carry it. + // Slack is recorded even when the file already existed: the channel is live either way. payload.channelsAdded.push("slack"); return wroteExactConnectorUid; } @@ -354,7 +361,7 @@ export function addChannels( async function addWebChannelToPayload( log: ChannelSetupLog, - state: Readonly, + state: Readonly, projectPath: string, packageManager: PackageManagerKind, payload: AddChannelsPayload, @@ -374,7 +381,7 @@ export function addChannels( kind: "web", packageManager, force: options.force, - configureVercelServices: options.configureVercelServices ?? hasVercelProject(state), + configureVercelServices: options.configureVercelServices ?? isProjectResolved(state.project), skipDependencyMutation: options.skipDependencyMutation, }; if (options.evePackage !== undefined) { @@ -402,14 +409,13 @@ export function addChannels( return; } - // A skipped Web scaffold (the project already runs Next.js) records - // nothing, so it cannot arm a deploy for files that were never written. + // A skipped Web scaffold (the project already runs Next.js) records nothing. log.info("Next.js project detected. Skipping Web Chat scaffolding."); } - function assertSlackProjectReady(state: Readonly): void { + function assertSlackProjectReady(state: Readonly): void { if (options.ensureLinkedProject !== undefined) return; - if (!hasVercelProject(state)) throw new Error(SLACK_REQUIRES_VERCEL); + if (!isProjectResolved(state.project)) throw new Error(SLACK_REQUIRES_VERCEL); if (!isProjectResolved(state.project)) { throw new Error("Expected a linked Vercel project for Slack, but none was resolved."); } @@ -451,7 +457,7 @@ export function addChannels( async function scaffoldAttachedSlackChannel( log: ChannelSetupLog, - state: Readonly, + state: Readonly, projectPath: string, slug: SlackConnectorSlug, payload: AddChannelsPayload, @@ -475,7 +481,7 @@ export function addChannels( async function addSlackChannelToPayload( log: ChannelSetupLog, - state: Readonly, + state: Readonly, input: AddChannelsInput, projectPath: string, payload: AddChannelsPayload, @@ -483,7 +489,7 @@ export function addChannels( ): Promise { if (!state.channelSelection.includes("slack")) return; - const slug = await deps.deriveSlackConnectorSlug(projectPath, state.agentName); + const slug = await deps.deriveSlackConnectorSlug(projectPath); if (options.slackCredentials === "environment") { if (!state.slackScaffolded) { const result = await deps.ensureChannel({ @@ -508,22 +514,6 @@ export function addChannels( } assertSlackProjectReady(state); - if (state.slackbotCreated) { - // Rerun with a provisioned slackbot: never create a second connector. - if (!state.slackbotAttached) throw new Error(SLACKBOT_NOT_ATTACHED_ERROR); - if (!state.deploymentPending) return; - - const connectorUid = state.slackConnectorUid; - if (connectorUid === undefined) { - throw new Error("Slack connector UID was not resolved. Slack deployment did not start."); - } - await scaffoldAttachedSlackChannel(log, state, projectPath, slug, payload, { - state: "attached", - connectorUid, - }); - return; - } - if (!isProjectResolved(payload.project)) { // Only reachable with the ensureLinkedProject seam; without it the gate // above already required a resolved project. @@ -539,8 +529,7 @@ export function addChannels( const slackbot = await provisionSlackbotWithControls(log, projectPath, slug, signal); signal?.throwIfAborted(); if (slackbot.state === "cancelled") { - // Provisioning already cleaned up its connector. Fold into a cancelled - // run so /channels repaints the list like any other cancelled sub-flow. + // Provisioning already cleaned up its connector. throw new WizardCancelledError(); } if (slackbot.state !== "attached") { @@ -592,13 +581,19 @@ export function addChannels( } async function performAddChannels( - state: Readonly, + state: Readonly, input: AddChannelsInput, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); const log = options.prompter.log; - const projectPath = requireProjectPath(state); + const projectPath = + typeof state.projectPath === "string" + ? state.projectPath + : state.projectPath.kind === "resolved" + ? state.projectPath.path + : undefined; + if (projectPath === undefined) throw new Error("Project path has not been resolved."); const payload: AddChannelsPayload = { channelsAdded: [], webScaffolded: state.webScaffolded, @@ -654,29 +649,17 @@ export function addChannels( channels.push(channel); } } - const next: SetupState = { + const next: State = { ...state, channels, webScaffolded: payload.webScaffolded, slackScaffolded: payload.slackScaffolded, - deploymentPending: state.deploymentPending || payload.channelsAdded.length > 0, - // Only manifest mutations invalidate an earlier dependency install. - deploymentDependenciesInstalled: payload.dependenciesChanged - ? payload.dependenciesInstalled - : state.deploymentDependenciesInstalled, project: mergeProjectResolution(state.project, payload.project), - }; + } as State; if (payload.slackbot === undefined) { return next; } - return { - ...next, - slackbotCreated: true, - slackbotAttached: true, - slackConnectorUid: payload.slackbot.connectorUid, - slackChatUrl: payload.slackbot.chatUrl, - slackWorkspaceName: payload.slackbot.workspaceName, - }; + return next; }, }; } diff --git a/packages/eve/src/setup/channel-setup-slack.ts b/packages/eve/src/setup/integrations/channels/slack.ts similarity index 91% rename from packages/eve/src/setup/channel-setup-slack.ts rename to packages/eve/src/setup/integrations/channels/slack.ts index 4b667c635..ae3f33c9a 100644 --- a/packages/eve/src/setup/channel-setup-slack.ts +++ b/packages/eve/src/setup/integrations/channels/slack.ts @@ -1,6 +1,6 @@ -import type { ChannelSetupIntegration } from "./channel-setup-integration.js"; -import { runChannelSetup } from "./channel-setup-runner.js"; -import { WizardCancelledError } from "./step.js"; +import type { ChannelSetupIntegration } from "./types.js"; +import { runChannelSetup } from "./runner.js"; +import { WizardCancelledError } from "../../step.js"; async function choosePortableCredentials( context: Parameters[0], diff --git a/packages/eve/src/setup/channel-setup-integration.ts b/packages/eve/src/setup/integrations/channels/types.ts similarity index 52% rename from packages/eve/src/setup/channel-setup-integration.ts rename to packages/eve/src/setup/integrations/channels/types.ts index 47f87484a..414004f56 100644 --- a/packages/eve/src/setup/channel-setup-integration.ts +++ b/packages/eve/src/setup/integrations/channels/types.ts @@ -1,13 +1,26 @@ -import type { AddChannelsDeps } from "./boxes/add-channels.js"; -import type { ChannelSetupEnvironment } from "./channel-setup-environment.js"; -import type { ChannelSetupUi } from "./channel-setup-ui.js"; -import type { ChannelKind } from "./scaffold/index.js"; -import type { SetupState } from "./state.js"; +import type { AddChannelsDeps } from "./setup.js"; +import type { ChannelSetupEnvironment } from "./environment.js"; +import type { ChannelSetupUi } from "./ui.js"; +import type { ChannelKind } from "../../scaffold/index.js"; +import type { ProjectResolution } from "../../project-resolution.js"; + +/** Narrow state owned by one channel setup invocation. */ +export interface ChannelSetupState { + readonly projectPath: + | string + | { kind: "unresolved"; inPlace: boolean } + | { kind: "resolved"; inPlace: boolean; path: string }; + readonly project: ProjectResolution; + readonly channelSelection: ChannelKind[]; + readonly channels: ChannelKind[]; + readonly webScaffolded: boolean; + readonly slackScaffolded: boolean; +} /** Shared inputs available to a channel-owned setup implementation. */ export interface ChannelSetupContext { readonly environment: ChannelSetupEnvironment; - readonly state: Readonly; + readonly state: Readonly; readonly ui: ChannelSetupUi; readonly signal?: AbortSignal; readonly force?: boolean; @@ -21,7 +34,7 @@ export interface ChannelSetupContext { /** Structured outcome from a channel-owned setup implementation. */ export type ChannelSetupResult = - | { readonly kind: "done"; readonly state: SetupState } + | { readonly kind: "done"; readonly state: ChannelSetupState } | { readonly kind: "cancelled" }; /** Setup behavior paired with canonical channel catalog metadata. */ diff --git a/packages/eve/src/setup/channel-setup-ui.test.ts b/packages/eve/src/setup/integrations/channels/ui.test.ts similarity index 85% rename from packages/eve/src/setup/channel-setup-ui.test.ts rename to packages/eve/src/setup/integrations/channels/ui.test.ts index 39c510c91..e6db1e316 100644 --- a/packages/eve/src/setup/channel-setup-ui.test.ts +++ b/packages/eve/src/setup/integrations/channels/ui.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import { interactiveAsker } from "./ask.js"; -import { createChannelSetupUi } from "./channel-setup-ui.js"; +import { interactiveAsker } from "../../ask.js"; +import { createChannelSetupUi } from "./ui.js"; describe("createChannelSetupUi", () => { it("renders integration-owned next steps through the shared notice", () => { diff --git a/packages/eve/src/setup/channel-setup-ui.ts b/packages/eve/src/setup/integrations/channels/ui.ts similarity index 88% rename from packages/eve/src/setup/channel-setup-ui.ts rename to packages/eve/src/setup/integrations/channels/ui.ts index 8d8db0723..e44b9101e 100644 --- a/packages/eve/src/setup/channel-setup-ui.ts +++ b/packages/eve/src/setup/integrations/channels/ui.ts @@ -1,5 +1,5 @@ -import { confirm, SkippedSignal, type Asker } from "./ask.js"; -import type { Prompter } from "./prompter.js"; +import { confirm, SkippedSignal, type Asker } from "../../ask.js"; +import type { Prompter } from "../../prompter.js"; /** UI capabilities available to a channel-owned setup hook. */ export interface ChannelSetupUi { diff --git a/packages/eve/src/setup/channel-setup-web.ts b/packages/eve/src/setup/integrations/channels/web.ts similarity index 70% rename from packages/eve/src/setup/channel-setup-web.ts rename to packages/eve/src/setup/integrations/channels/web.ts index 0cf4cf171..5f71a6df6 100644 --- a/packages/eve/src/setup/channel-setup-web.ts +++ b/packages/eve/src/setup/integrations/channels/web.ts @@ -1,5 +1,5 @@ -import type { ChannelSetupIntegration } from "./channel-setup-integration.js"; -import { runChannelSetup } from "./channel-setup-runner.js"; +import type { ChannelSetupIntegration } from "./types.js"; +import { runChannelSetup } from "./runner.js"; /** Web Chat's channel-owned setup behavior. */ export const WEB_CHANNEL_SETUP: ChannelSetupIntegration = { diff --git a/packages/eve/src/setup/onboarding.test.ts b/packages/eve/src/setup/onboarding.test.ts deleted file mode 100644 index eae9fac07..000000000 --- a/packages/eve/src/setup/onboarding.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; - -import { composeOnboardingBoxes } from "./onboarding.js"; -import { createDefaultSetupState, type SetupState } from "./state.js"; - -function composeBoxes() { - return composeOnboardingBoxes({ - prompter: createFakePrompter().prompter, - }); -} - -function resolvedState(setupMode: SetupState["setupMode"]): SetupState { - return { - ...createDefaultSetupState(), - setupMode, - projectPath: { kind: "resolved", inPlace: false, path: "/tmp/my-agent" }, - }; -} - -/** Boxes a one-shot run must skip: every interview and post-scaffold step. */ -const GATED_IDS = [ - "resolve-provisioning", - "select-model", - "select-channels", - "select-connections", - "detect-ai-gateway", - "link-project", - "apply-ai-gateway-credential", - "install-channel-registry-items", - "add-channels", - "add-connections", - "deploy-project", - "select-chat", -]; - -describe("composeOnboardingBoxes", () => { - it("orders the interview: name, mode, model, channels, connections, then deployment", () => { - const ids = composeBoxes().map((box) => box.id); - - expect(ids[0]).toBe("resolve-target"); - expect(ids[1]).toBe("select-setup-mode"); - expect(ids[ids.length - 1]).toBe("one-shot-next-steps"); - expect(ids).toContain("scaffold"); - // The agent is described first; where it runs is the last interview - // decision, because the channel and connection selections inform it. All - // of it stays ahead of any filesystem write. - expect(ids[2]).toBe("select-model"); - expect(ids.indexOf("select-channels")).toBe(ids.indexOf("select-model") + 1); - expect(ids.indexOf("select-connections")).toBe(ids.indexOf("select-channels") + 1); - expect(ids.indexOf("resolve-provisioning")).toBe(ids.indexOf("select-connections") + 1); - expect(ids.indexOf("resolve-provisioning")).toBeLessThan(ids.indexOf("scaffold")); - expect(ids.indexOf("add-connections")).toBeGreaterThan(ids.indexOf("scaffold")); - expect(ids.indexOf("install-channel-registry-items")).toBeLessThan(ids.indexOf("add-channels")); - }); - - it("one-shot gates every interview and post-scaffold box but keeps the scaffold path", () => { - const boxes = composeBoxes(); - const state = resolvedState("one-shot"); - - for (const id of GATED_IDS) { - const box = boxes.find((candidate) => candidate.id === id); - expect(box, id).toBeDefined(); - expect(box?.shouldRun?.(state), id).toBe(false); - } - for (const id of ["preflight", "scaffold", "one-shot-next-steps"]) { - const box = boxes.find((candidate) => candidate.id === id); - expect(box, id).toBeDefined(); - expect(box?.shouldRun?.(state) ?? true, id).toBe(true); - } - }); - - it("deploys during onboarding only when Slack was scaffolded", () => { - const boxes = composeBoxes(); - const deploy = boxes.find((box) => box.id === "deploy-project"); - const base = { - ...resolvedState("complete"), - deploymentPending: true, - vercelProject: { kind: "new", project: "my-agent", team: "acme" } as const, - }; - - // Web-only onboarding: deployment work is pending but Slack is absent. - expect(deploy?.shouldRun?.({ ...base, webScaffolded: true })).toBe(false); - expect(deploy?.shouldRun?.({ ...base, slackScaffolded: true })).toBe(true); - }); - - it("complete setup defers to each box's own shouldRun", () => { - const boxes = composeBoxes(); - const state = resolvedState("complete"); - - // The link box self-skips without a planned project even in complete mode. - const link = boxes.find((box) => box.id === "link-project"); - expect(link?.shouldRun?.(state)).toBe(false); - - // The channel interview runs in complete mode and is skipped one-shot. - const channels = boxes.find((box) => box.id === "select-channels"); - expect(channels?.shouldRun?.(state) ?? true).toBe(true); - - // The one-shot epilogue never fires on a complete run. - const epilogue = boxes.find((box) => box.id === "one-shot-next-steps"); - expect(epilogue?.shouldRun?.(state)).toBe(false); - }); -}); diff --git a/packages/eve/src/setup/onboarding.ts b/packages/eve/src/setup/onboarding.ts deleted file mode 100644 index 13e8cb511..000000000 --- a/packages/eve/src/setup/onboarding.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { resolve } from "node:path"; - -import { headlessAsker, interactiveAsker, type Asker } from "./ask.js"; -import { addChannels } from "./boxes/add-channels.js"; -import { addConnections } from "./boxes/add-connections.js"; -import { applyAiGatewayCredential } from "./boxes/apply-ai-gateway-credential.js"; -import { deployProject } from "./boxes/deploy-project.js"; -import { detectAiGateway } from "./boxes/detect-ai-gateway.js"; -import { linkVercelProject } from "./boxes/link-project.js"; -import { installChannelRegistryItems } from "./boxes/install-channel-registry-items.js"; -import { oneShotNextSteps } from "./boxes/one-shot-next-steps.js"; -import { preflight } from "./boxes/preflight.js"; -import { resolveProvisioning } from "./boxes/resolve-provisioning.js"; -import { resolveTarget } from "./boxes/resolve-target.js"; -import { scaffold } from "./boxes/scaffold.js"; -import { selectChannels } from "./boxes/select-channels.js"; -import { selectChat } from "./boxes/select-chat.js"; -import { selectConnections } from "./boxes/select-connections.js"; -import { selectModel } from "./boxes/select-model.js"; -import { selectSetupMode } from "./boxes/select-setup-mode.js"; -import type { Prompter } from "./prompter.js"; -import type { AnySetupBox } from "./runner.js"; -import type { EvePackageContract } from "./scaffold/index.js"; -import type { - ArgsHeadlessAiGateway, - ArgsHeadlessProject, - ChannelKind, - ChatPreference, - ProvisioningMode, - SetupMode, - SetupState, -} from "./state.js"; - -/** - * Options for {@link composeOnboardingBoxes}. These carry exactly what the - * create flow's options carried: the preset answers that let each box resolve - * without prompting, plus the directory and headless dispatch decisions. - */ -export interface OnboardingBoxesOptions { - prompter: Prompter; - /** Skip the name prompt and use this value. */ - presetName?: string; - /** Skip the setup-mode prompt and use this value. */ - presetMode?: SetupMode; - /** Skip the model prompt and use this value. */ - presetModel?: string; - /** Skip the channels prompt and use these kinds. */ - presetChannels?: ChannelKind[]; - /** Skip the connections picker and scaffold these catalog slugs. */ - presetConnections?: string[]; - /** Skip the "Create slackbot?" prompt inside the add-to-agent step. */ - presetCreateSlackbot?: boolean; - /** Headless-only Vercel provisioning flags. Ignored on the interactive path. */ - provisioning?: { project: ArgsHeadlessProject; aiGateway: ArgsHeadlessAiGateway }; - /** Skip the chat-preference prompt and use this value. */ - presetChatPreference?: ChatPreference; - /** Parent directory the project folder is created inside. Defaults to cwd. */ - targetDirectory?: string; - /** Scaffold into cwd or targetDirectory instead of creating a child directory. */ - inPlace?: boolean; - /** Allow the in-place scaffold to replace eve scaffold files that already exist. */ - overwriteExisting?: boolean; - /** Skip the post-channel Vercel deployment entirely. */ - presetNoDeploy?: boolean; - /** - * Headless mode: never prompt or spawn an interactive Vercel command. Boxes - * needing a human/browser action throw `HumanActionRequiredError`. Slack - * setup remains an interactive/onboarding handoff, not a headless flow. - */ - headless?: boolean; - /** eve package metadata baked into the scaffolded `package.json`. */ - evePackage?: EvePackageContract; -} - -/** - * Gates a box at composition time without teaching the box about flow shapes. - * ANDs with the box's own `shouldRun` so existing self-skips keep working. - */ -function onlyWhen( - predicate: (state: Readonly) => boolean, - box: AnySetupBox, -): AnySetupBox { - return { - ...box, - shouldRun: (state) => predicate(state) && (box.shouldRun?.(state) ?? true), - }; -} - -/** - * Gates a box to complete-setup runs: a one-shot run stops at the scaffold, so - * every interview and post-scaffold box is wrapped with this instead of - * teaching each box about setup modes. - */ -function completeSetupOnly(box: AnySetupBox): AnySetupBox { - return onlyWhen((state) => state.setupMode === "complete", box); -} - -/** - * Composes the full programmatic onboarding flow. - * - * 1. The interview phase: name, then the agent itself (model, channels, - * connections), then where it runs (the provisioning plans). - * 2. Input preflight before filesystem writes. - * 3. The scaffold box writes or reuses the agent template. - * 4. Project and gateway facts are detected and executed from the plans. - * 5. Channel setup writes Web/Slack surfaces and returns setup facts. - * 6. Deploy runs once when Slack was added (the connector needs a public URL). - * 7. Chat preference is picked from the final channel state. - */ -export function composeOnboardingBoxes(options: OnboardingBoxesOptions): AnySetupBox[] { - // The headless provisioning flags are read only on the headless path; an - // interactive run prompts for every provisioning decision instead. - const mode: ProvisioningMode = options.headless - ? { - headless: true, - project: options.provisioning?.project ?? {}, - aiGateway: options.provisioning?.aiGateway ?? {}, - } - : { headless: false }; - // The ask channel for the unified boxes, built here because the composition - // already owns the prompter and the headless dispatch decision; commands - // keep passing exactly what they passed before. Migrated presets stay - // factory options on their boxes (see each box's option docs), so no - // withAnswers rung is composed yet. - const asker: Asker = options.headless ? headlessAsker() : interactiveAsker(options.prompter); - // Decide-once, execute-in-order. The interview boxes gather the directory, - // agent, and provisioning decisions as plans; the link box executes the - // project plan after scaffold and records the resolution in `state.project`, - // which every later box reads. The on-disk `.vercel` link is the single - // source of truth. - return [ - resolveTarget({ - asker, - notify: (message) => options.prompter.note(message), - presetName: options.presetName, - targetDirectory: options.targetDirectory, - inPlace: options.inPlace, - // Only headless re-runs converge onto an existing eve project directory; - // interactive runs keep refusing so a human notices the collision. - resumeExisting: options.headless, - }), - selectSetupMode({ - asker, - presetMode: options.presetMode, - presetModel: options.presetModel, - }), - // The complete-setup interview. The agent is described first — model, - // channels, connections — and only then where it runs: the provisioning - // box reads those selections, so Slack or a Connect-backed connection - // resolves the deployment question to Vercel without asking it. This also - // keeps the interview phase (prompts) ahead of the programmatic phase - // (file writes, linking, deploy). - ...[ - selectModel({ asker, presetModel: options.presetModel }), - selectChannels({ - asker, - presetChannels: options.presetChannels, - variant: "onboarding", - }), - selectConnections({ - asker, - presetConnections: options.presetConnections, - headless: options.headless, - }), - resolveProvisioning({ - asker, - prompter: options.prompter, - targetDirectory: options.targetDirectory, - mode, - }), - ].map(completeSetupOnly), - preflight({ - cwd: resolve(options.targetDirectory ?? process.cwd()), - headless: options.headless, - }), - scaffold({ - prompter: options.prompter, - evePackage: options.evePackage, - targetDirectory: options.targetDirectory, - overwriteExisting: options.overwriteExisting, - headless: options.headless, - }), - // The complete-setup execution phase: everything a one-shot run defers. - ...[ - detectAiGateway(), - linkVercelProject({ prompter: options.prompter, headless: options.headless }), - applyAiGatewayCredential({ prompter: options.prompter }), - installChannelRegistryItems(), - addChannels({ - asker, - prompter: options.prompter, - evePackage: options.evePackage, - presetCreateSlackbot: options.presetCreateSlackbot, - headless: options.headless, - // A failed slackbot must not abort onboarding: the agent still - // scaffolds, deploys, and chats; Slack can be added later. - slackbotFailure: "warn-and-continue", - skipDependencyMutation: true, - }), - addConnections({ prompter: options.prompter }), - ].map(completeSetupOnly), - // Onboarding deploys only for Slack: the connector needs a public - // production URL before it can receive events, while Web Chat runs - // locally through `eve dev`. Adding channels to an existing project - // (in-project setup, `eve channels add`) keeps deploying for any - // channel — this gate is onboarding-only. - completeSetupOnly( - onlyWhen( - (state) => state.slackScaffolded, - deployProject({ - prompter: options.prompter, - skip: options.presetNoDeploy, - headless: options.headless, - }), - ), - ), - completeSetupOnly( - selectChat({ - asker, - presetPreference: options.presetChatPreference, - }), - ), - oneShotNextSteps({ prompter: options.prompter }), - ]; -} diff --git a/packages/eve/src/setup/scaffold/channels-catalog.ts b/packages/eve/src/setup/scaffold/channels-catalog.ts index 1020ff877..65cf1e268 100644 --- a/packages/eve/src/setup/scaffold/channels-catalog.ts +++ b/packages/eve/src/setup/scaffold/channels-catalog.ts @@ -1,6 +1,6 @@ /** * Build-time catalog of channels used by programmatic setup and - * `eve channels add`. + * `eve add channel/slack`. * * Channel *identity* (slug, name, and whether it is scaffoldable) is owned by * `@vercel/eve-catalog`, the cross-surface source of truth shared with the docs diff --git a/packages/eve/src/setup/scaffold/create/add-to-project.ts b/packages/eve/src/setup/scaffold/create/add-to-project.ts index c826a2975..6fe79f947 100644 --- a/packages/eve/src/setup/scaffold/create/add-to-project.ts +++ b/packages/eve/src/setup/scaffold/create/add-to-project.ts @@ -107,7 +107,7 @@ export async function addAgentToProject( "aiPackageVersion", options.aiPackageVersion ?? DEFAULT_AI_PACKAGE_VERSION, ); - // Channels and connections scaffolded later (`eve channels add slack`, + // Channels and connections scaffolded later (`eve add channel/slack`, // possibly while `eve dev` is running) import `@vercel/connect`; shipping // it from init means adding them never introduces a missing dependency. const connectVersion = resolveVersionToken( diff --git a/packages/eve/src/setup/scaffold/create/project.ts b/packages/eve/src/setup/scaffold/create/project.ts index a74c92bad..44f887ddc 100644 --- a/packages/eve/src/setup/scaffold/create/project.ts +++ b/packages/eve/src/setup/scaffold/create/project.ts @@ -364,7 +364,7 @@ export async function scaffoldBaseProject(options: ScaffoldBaseProjectOptions): "aiPackageVersion", options.aiPackageVersion ?? DEFAULT_AI_PACKAGE_VERSION, ), - // Channels and connections scaffolded later (`eve channels add slack`, + // Channels and connections scaffolded later (`eve add channel/slack`, // possibly while `eve dev` is running) import `@vercel/connect`; shipping // it from init means adding them never introduces a missing dependency. connectPackageVersion: resolveVersionToken( diff --git a/packages/eve/src/setup/scaffold/index.integration.test.ts b/packages/eve/src/setup/scaffold/index.integration.test.ts index 81a121d91..a4b7fee51 100644 --- a/packages/eve/src/setup/scaffold/index.integration.test.ts +++ b/packages/eve/src/setup/scaffold/index.integration.test.ts @@ -888,7 +888,7 @@ describe("scaffoldBaseProject", () => { expect(agentSource).not.toContain("modelOptions"); const packageJson = await readFile(join(projectRoot, "package.json"), "utf8"); expect(packageJson).toContain('"eve": "^0.25.0"'); - // Channels added later (`eve channels add slack`, possibly next to a + // Channels added later (`eve add channel/slack`, possibly next to a // running `eve dev`) import @vercel/connect; init ships it so a later // channel add never introduces a missing dependency. expect(packageJson).toContain('"@vercel/connect": "0.2.2"'); diff --git a/packages/eve/src/setup/vercel-project-framework.ts b/packages/eve/src/setup/vercel-project-framework.ts index 996bfad8b..77a4a2ff8 100644 --- a/packages/eve/src/setup/vercel-project-framework.ts +++ b/packages/eve/src/setup/vercel-project-framework.ts @@ -236,7 +236,7 @@ function describeError(error: unknown): string { * host framework the project declares on disk (e.g. Next.js). * * A project created as a standalone eve agent keeps the `eve` preset; adding a - * host framework via `eve channels add web` leaves it stale, so the deploy would + * host framework via `eve add channel/web` leaves it stale, so the deploy would * build the agent instead of the host app. Since that command already deploys on * the user's behalf, this switches the preset directly (no prompt) and notes the * change. No host framework, an unlinked directory, or an already-correct preset diff --git a/packages/eve/test/tui-client/tui-slash-commands.ts b/packages/eve/test/tui-client/tui-slash-commands.ts deleted file mode 100644 index 6e682de6a..000000000 --- a/packages/eve/test/tui-client/tui-slash-commands.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { Client } from "eve/client"; -import { - createPromptCommandHandler, - EveTUIRunner, - MockScreen, - MockUserInput, - promptCommandsFor, -} from "./lib/tui.ts"; - -import { theme } from "./lib/theme.ts"; - -/** - * End-to-end proof of the TUI prompt commands. - * - * 1. A turn against an unreachable server renders an error region. - * 2. `/new` clears the transcript and starts a fresh session, the error - * region disappears and the screen returns to the empty prompt. - * 3. `/deploy` (in a remote command context) renders the local-only notice - * instead of suspending into a setup flow. - * 4. `/exit` terminates the runner, exactly as Ctrl+C would. - * 5. Local `/channels` opens the real setup picker, Escape cancels it, and - * the TUI returns to the prompt. - * - * Needs no agent server and no model credentials. - */ -const UNREACHABLE_HOST = "http://127.0.0.1:49214"; -process.env.EVE_TUI_UNICODE = "1"; - -void (async () => { - const client = new Client({ host: UNREACHABLE_HOST }); - const screen = new MockScreen({ columns: 100, rows: 40 }); - const input = new MockUserInput(); - const runner = new EveTUIRunner({ - session: client.session(), - client, - screen, - userInput: input, - name: "TUI slash commands", - availablePromptCommands: promptCommandsFor("remote"), - promptCommandHandler: createPromptCommandHandler({ - target: { - kind: "remote", - serverUrl: UNREACHABLE_HOST, - workspaceRoot: process.cwd(), - }, - }), - }); - - const runPromise = runner.run().catch((error: unknown) => { - if (error instanceof Error && error.message === "Interrupted") { - return; - } - throw error; - }); - - try { - await screen.waitForIdlePrompt(5_000); - - input.type("boom"); - input.enter(); - await screen.waitForText("Error", 10_000); - console.log(theme.muted("[tui-slash-commands] error region rendered")); - - // Wait until `readPrompt` is active again so the next keystrokes - // aren't dropped in the gap between turns. - await screen.waitForIdlePrompt(5_000); - input.type("/new"); - input.enter(); - await screen.waitForIdlePrompt(5_000); - if (screen.snapshot().includes("Error")) { - throw new Error(`/new did not clear the transcript:\n${screen.snapshot()}`); - } - console.log(theme.muted("[tui-slash-commands] /new cleared the transcript")); - - // Remote setup commands return a local-only notice instead of opening a flow. - input.type("/deploy"); - input.enter(); - await screen.waitForText("/deploy needs eve dev running the local server", 5_000); - console.log(theme.muted("[tui-slash-commands] /deploy rendered the local-only notice")); - - await screen.waitForIdlePrompt(5_000); - input.type("/exit"); - input.enter(); - - // `/exit` must resolve `run()` on its own, no Ctrl+C needed. - await withTimeout(runPromise, 5_000, "/exit did not terminate the runner"); - console.log(theme.muted("[tui-slash-commands] /exit terminated the TUI")); - - await runLocalChannelsCancellation(); - } catch (error) { - input.ctrlC(); - await runPromise.catch(() => {}); - throw error; - } -})().catch((error: unknown) => { - console.error(theme.danger("\n[tui] tui-slash-commands smoke test failed:"), error); - process.exitCode = 1; -}); - -async function runLocalChannelsCancellation(): Promise { - const appRoot = await mkdtemp(join(tmpdir(), "eve-tui-setup-")); - const client = new Client({ host: UNREACHABLE_HOST }); - const screen = new MockScreen({ columns: 100, rows: 40 }); - const input = new MockUserInput(); - const runner = new EveTUIRunner({ - session: client.session(), - client, - screen, - userInput: input, - name: "TUI local setup", - appRoot, - availablePromptCommands: promptCommandsFor("local"), - promptCommandHandler: createPromptCommandHandler({ - target: { kind: "local", serverUrl: UNREACHABLE_HOST, workspaceRoot: appRoot }, - }), - }); - const runPromise = runner.run(); - - try { - await mkdir(join(appRoot, "agent"), { recursive: true }); - await writeFile(join(appRoot, "agent/agent.ts"), "export default {};\n", "utf8"); - await writeFile( - join(appRoot, "package.json"), - `${JSON.stringify({ name: "tui-local-setup", private: true }, null, 2)}\n`, - "utf8", - ); - - await screen.waitForIdlePrompt(5_000); - input.type("/channels"); - input.enter(); - await screen.waitForText("Where will you chat with your agent?", 10_000); - input.send("\x1b"); - await screen.waitForText("/channels dismissed.", 5_000); - await screen.waitForIdlePrompt(5_000); - console.log(theme.muted("[tui-slash-commands] local /channels cancelled back to prompt")); - - input.type("/exit"); - input.enter(); - await withTimeout(runPromise, 5_000, "local /exit did not terminate the runner"); - } catch (error) { - input.ctrlC(); - await runPromise.catch(() => {}); - throw error; - } finally { - await rm(appRoot, { recursive: true, force: true }); - } -} - -async function withTimeout(promise: Promise, ms: number, message: string): Promise { - let timer: ReturnType | undefined; - const timeout = new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error(message)), ms); - }); - try { - return await Promise.race([promise, timeout]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} diff --git a/packages/eve/test/tui-client/tui-status-line.ts b/packages/eve/test/tui-client/tui-status-line.ts deleted file mode 100644 index 1641d847a..000000000 --- a/packages/eve/test/tui-client/tui-status-line.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { Client } from "eve/client"; -import { createPromptCommandHandler, EveTUIRunner, MockScreen, MockUserInput } from "./lib/tui.ts"; - -import { theme } from "./lib/theme.ts"; - -/** - * End-to-end proof of the TUI's persistent status line and its workspace-scoped - * deploy state. - * - * 1. `/channels` adding a channel marks `deploy pending` on the line. - * 2. `/new` clears the transcript but the pending flag survives — deploy - * state is workspace-scoped, not conversation-scoped. - * 3. `/deploy` clears the pending marker. - * 4. An unlinked directory renders no Vercel segment at all. - * - * The linked project identity is no longer a standalone status-line segment; - * it folds into the model-endpoint segment (`via ai-gateway(oidc:project)`), - * which only - * renders for a reachable server that reports a gateway-routed model on - * `/eve/v1/info`. That path is covered by the unit tests in `status-line.test.ts` - * and the server-backed evals — not here, where the host is unreachable. - * - * Setup flows are injected fakes; the identity probe runs for real against a - * temp dir with no `.vercel/project.json`. Needs no agent server, no `vercel` - * CLI, and no credentials. - */ -const UNREACHABLE_HOST = "http://127.0.0.1:49215"; -process.env.EVE_TUI_UNICODE = "1"; - -void (async () => { - await runPendingDeployCycle(); - await runUnlinkedShowsNoVercelSegment(); -})().catch((error: unknown) => { - console.error(theme.danger("\n[tui] tui-status-line smoke test failed:"), error); - process.exitCode = 1; -}); - -async function runPendingDeployCycle(): Promise { - const appRoot = await makeAppRoot(); - const client = new Client({ host: UNREACHABLE_HOST }); - const screen = new MockScreen({ columns: 100, rows: 40 }); - const input = new MockUserInput(); - const runner = new EveTUIRunner({ - session: client.session(), - client, - screen, - userInput: input, - name: "TUI status line", - appRoot, - serverUrl: UNREACHABLE_HOST, - promptCommandHandler: createPromptCommandHandler({ - target: { kind: "local", serverUrl: UNREACHABLE_HOST, workspaceRoot: appRoot }, - flows: { - runChannelsFlow: async () => ({ kind: "done", addedChannels: ["slack"] }), - runDeployFlow: async () => ({ kind: "deployed" }), - }, - }), - }); - const runPromise = runner.run(); - - try { - await screen.waitForIdlePrompt(5_000); - await screen.waitForText(` :${new URL(UNREACHABLE_HOST).port} `, 5_000); - console.log(theme.muted("[tui-status-line] local loopback badge rendered")); - - input.type("/channels"); - input.enter(); - await screen.waitForText("Channels added: slack", 5_000); - await screen.waitForText("deploy pending", 5_000); - console.log(theme.muted("[tui-status-line] /channels marked the deploy pending")); - - input.type("/new"); - input.enter(); - await screen.waitForIdlePrompt(5_000); - if (!screen.snapshot().includes("deploy pending")) { - throw new Error(`/new dropped the pending-deploy flag:\n${screen.snapshot()}`); - } - console.log(theme.muted("[tui-status-line] pending flag survived /new")); - - input.type("/deploy"); - input.enter(); - await screen.waitForText("Deployed.", 5_000); - await waitForGone(screen, "deploy pending", 5_000); - console.log(theme.muted("[tui-status-line] /deploy cleared the pending flag")); - - input.type("/exit"); - input.enter(); - await withTimeout(runPromise, 5_000, "/exit did not terminate the runner"); - } catch (error) { - input.ctrlC(); - await runPromise.catch(() => {}); - throw error; - } finally { - await rm(appRoot, { recursive: true, force: true }); - } -} - -async function runUnlinkedShowsNoVercelSegment(): Promise { - const appRoot = await makeAppRoot(); - const client = new Client({ host: UNREACHABLE_HOST }); - const screen = new MockScreen({ columns: 100, rows: 40 }); - const input = new MockUserInput(); - // No detectProjectIdentity injection: the real probe reads the temp dir's - // missing `.vercel/project.json` and resolves unlinked without shelling out. - const runner = new EveTUIRunner({ - session: client.session(), - client, - screen, - userInput: input, - name: "TUI status line unlinked", - appRoot, - serverUrl: UNREACHABLE_HOST, - promptCommandHandler: createPromptCommandHandler({ - target: { kind: "local", serverUrl: UNREACHABLE_HOST, workspaceRoot: appRoot }, - }), - }); - const runPromise = runner.run(); - - try { - await screen.waitForIdlePrompt(5_000); - // Allow the unlinked probe to land and repaint before judging the footer. - await new Promise((resolve) => setTimeout(resolve, 300)); - - const lines = screen.snapshot().split("\n"); - const promptRow = lines.findLastIndex((line) => line.includes("›")); - const footer = lines.slice(promptRow + 1).join("\n"); - if (footer.includes("▲")) { - throw new Error(`unlinked footer rendered a Vercel segment:\n${screen.snapshot()}`); - } - console.log(theme.muted("[tui-status-line] unlinked session shows no Vercel segment")); - - input.type("/exit"); - input.enter(); - await withTimeout(runPromise, 5_000, "unlinked /exit did not terminate the runner"); - } catch (error) { - input.ctrlC(); - await runPromise.catch(() => {}); - throw error; - } finally { - await rm(appRoot, { recursive: true, force: true }); - } -} - -async function makeAppRoot(): Promise { - const appRoot = await mkdtemp(join(tmpdir(), "eve-tui-status-")); - await mkdir(join(appRoot, "agent"), { recursive: true }); - await writeFile(join(appRoot, "agent/agent.ts"), "export default {};\n", "utf8"); - await writeFile( - join(appRoot, "package.json"), - `${JSON.stringify({ name: "tui-status-line", private: true }, null, 2)}\n`, - "utf8", - ); - return appRoot; -} - -async function waitForGone(screen: MockScreen, text: string, ms: number): Promise { - const deadline = Date.now() + ms; - while (screen.snapshot().includes(text)) { - if (Date.now() > deadline) { - throw new Error( - `Timed out waiting for screen text to clear: ${text}\n\n${screen.snapshot()}`, - ); - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } -} - -async function withTimeout(promise: Promise, ms: number, message: string): Promise { - let timer: ReturnType | undefined; - const timeout = new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error(message)), ms); - }); - try { - return await Promise.race([promise, timeout]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} From db44e7424686eaecdc61a7f3660f5604cfdefd7a Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:26:15 +0000 Subject: [PATCH 3/5] refactor(eve): move connection setup behind eve add Signed-off-by: owenkephart Co-Authored-By: owenkephart --- .changeset/remove-connect-command.md | 5 + docs/guides/dev-tui.md | 14 +- .../eve/src/cli/dev/tui/agent-header.test.ts | 1 - .../src/cli/dev/tui/prompt-command-handler.ts | 1 - .../src/cli/dev/tui/prompt-commands.test.ts | 2 - .../eve/src/cli/dev/tui/prompt-commands.ts | 16 +- packages/eve/src/cli/dev/tui/runner.test.ts | 92 ---- packages/eve/src/cli/dev/tui/runner.ts | 18 +- .../src/cli/dev/tui/setup-commands.test.ts | 45 +- .../eve/src/cli/dev/tui/setup-commands.ts | 49 +-- .../src/setup/boxes/add-connections.test.ts | 399 ------------------ .../eve/src/setup/boxes/add-connections.ts | 216 ---------- .../eve/src/setup/boxes/select-connections.ts | 309 -------------- .../eve/src/setup/flows/connections.test.ts | 219 ---------- packages/eve/src/setup/flows/connections.ts | 221 ---------- 15 files changed, 14 insertions(+), 1593 deletions(-) create mode 100644 .changeset/remove-connect-command.md delete mode 100644 packages/eve/src/setup/boxes/add-connections.test.ts delete mode 100644 packages/eve/src/setup/boxes/add-connections.ts delete mode 100644 packages/eve/src/setup/boxes/select-connections.ts delete mode 100644 packages/eve/src/setup/flows/connections.test.ts delete mode 100644 packages/eve/src/setup/flows/connections.ts diff --git a/.changeset/remove-connect-command.md b/.changeset/remove-connect-command.md new file mode 100644 index 000000000..a28eadcc0 --- /dev/null +++ b/.changeset/remove-connect-command.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Remove the `/connect` dev TUI command. Add connections through `eve add` or `/add`; official Connect-backed connection items now configure and patch their Vercel connector during registry setup. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index 491cdd7c9..fe170df29 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -24,19 +24,17 @@ The conversation streams straight into your terminal's normal scrollback, so you Each turn renders without boxes. A colored gutter glyph marks who is speaking, tool calls collapse to a one-line summary (`✓ get_weather city="SF" → 73°F`), and a subagent's work is indented beneath its `◆` header. When input is ready, the prompt stays bare until you type. A green circle-dot pulses while the agent is waiting to answer and disappears when reasoning or answer content begins. -A persistent line beneath the prompt or status shows the model — with its authored reasoning level and a `↯` marker when the Gateway priority tier is on (`xai/grok-4.5@xhigh ↯`) — the linked Vercel project, and a yellow `/deploy pending` marker once a channel added this session still needs `/deploy`. The Vercel segment stays hidden until the directory is linked. Local sessions lead with a gray `:port` badge. Remote sessions lead with a padded `↗ project (environment)` badge, or the host when Vercel cannot resolve the deployment. The badge is gray while checking or unavailable, yellow while authentication is required or failed, and blue when connected. Status segments separate with plain whitespace. Remote status lines omit AI Gateway endpoint state. +A persistent line beneath the prompt or status shows the model — with its authored reasoning level and a `↯` marker when the Gateway priority tier is on (`xai/grok-4.5@xhigh ↯`) — and the linked Vercel project. The Vercel segment stays hidden until the directory is linked. Local sessions lead with a gray `:port` badge. Remote sessions lead with a padded `↗ project (environment)` badge, or the host when Vercel cannot resolve the deployment. The badge is gray while checking or unavailable, yellow while authentication is required or failed, and blue when connected. Status segments separate with plain whitespace. Remote status lines omit AI Gateway endpoint state. Errors render compactly with docs links highlighted. A code bug escaping your agent's own code shows its stack trace dim beneath the error headline. Dev-server rebuilds condense into one status row that updates in place (`tui/setup-panel.ts changed · rebuilding…`, then `· rebuilt`); only the latest rebuild shows, and paths shrink to their last two components. ## Slash commands -Each command echoes as an invocation line, asks through a bordered panel that takes the input area's place (one question at a time, separate from the chat transcript), and finishes with a one-line `⎿` result. Loading states stay on the ephemeral status line instead of piling into the transcript; model, channel, connection, and the Vercel CLI commands (`/vc:install`, `/vc:login`) use the same green square pulse as the build phase, while `/deploy` keeps a spinner. A connection setup waiting for browser action changes that pulse and the word "browser" to yellow. Setup menus render the selected option with a filled arrow and an inverse label padded by one space on each side. Text prompts use a blinking block cursor over the character at the caret. The selected label is blue normally and yellow for warning rows. +Each command echoes as an invocation line, asks through a bordered panel that takes the input area's place (one question at a time, separate from the chat transcript), and finishes with a one-line `⎿` result. Loading states stay on the ephemeral status line instead of piling into the transcript; model, registry, and the Vercel CLI commands (`/vc:install`, `/vc:login`) use the same green square pulse as the build phase, while `/deploy` keeps a spinner. A setup waiting for browser action changes that pulse and the word "browser" to yellow. Setup menus render the selected option with a filled arrow and an inverse label padded by one space on each side. Text prompts use a blinking block cursor over the character at the caret. The selected label is blue normally and yellow for warning rows. | Command | Does | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/model` | Opens a configure menu that loops until Done (or Esc). See [Configure the model and provider](#configure-the-model-and-provider). | -| `/add` | Browses and installs integrations from the registry. | -| `/connect` | Shows the Vercel Connect MCP catalog and configures the server you pick. See [Add a connection](#add-a-connection). | | `/add` | Searches registry integrations or adds an item address entered directly. | | `/deploy` | Ships the agent to Vercel production, linking the directory first when it is unlinked. | | `/vc:install` | Installs the Vercel CLI. Available locally and on a remote session. | @@ -46,7 +44,7 @@ Each command echoes as an invocation line, asks through a bordered panel that ta | `/exit` | Quits the TUI. | | `/help` | Lists the commands available for the current local or remote session. | -`/model`, `/connect`, `/add`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. +`/model`, `/add`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. ### Configure the model and provider @@ -60,11 +58,7 @@ The provider row demands attention (a bold yellow "Configure model access" with `/add` first organizes integrations by what they add: a way to chat, tools and data, capabilities, or observability. Pick a category to open its searchable catalog from the official eve registry and the registry namespaces configured in `package.json`, or browse everything. Each integration shows its source beside its name, such as `Vercel` or a configured registry namespace. Select a result to review its source, packages, environment variables, and target files before adding it, or type an official item path, configured namespace address, or HTTP(S) URL directly into the search bar. After an add attempt finishes, the panel closes instead of returning to the catalog. -### Add a connection - -`/connect` shows a searchable list of MCP servers available through Vercel Connect. Already-authored connections remain checked. Logged-out users are directed to `/vc:login`. When the directory is not linked, selecting a server opens the same team and project flow used by `/model`, including creating a project or linking an existing one. - -For a selected server, eve first tries to attach the provider's canonical connector. If that fails, choose an existing connector from a searchable list or create one with a specific name. The fallback stays scoped to the team selected by the linked project. A connector created by the current attempt is removed if attachment or connection-file patching fails. Successful setup writes `agent/connections/.ts`, records the attached connector UID, installs the new dependency so the dev server can load it, then returns to the main prompt. +Connection registry items install their definition and then configure Vercel Connect through the same `/add` flow. If the directory is unlinked, setup first offers to create or link a project. Run `eve add connection/` for the equivalent CLI flow. ## Keyboard shortcuts diff --git a/packages/eve/src/cli/dev/tui/agent-header.test.ts b/packages/eve/src/cli/dev/tui/agent-header.test.ts index 3fb97f6cc..e41d55653 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.test.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import type { AgentInfoResult, AgentInfoToolEntry } from "#client/index.js"; import { AGENT_HEADER_TIPS, buildAgentHeader, pickAgentHeaderTip } from "./agent-header.js"; -import { stripAnsi } from "#cli/ui/terminal-text.js"; import { createTheme } from "./theme.js"; const FRAMEWORK_TOOL: AgentInfoToolEntry = { diff --git a/packages/eve/src/cli/dev/tui/prompt-command-handler.ts b/packages/eve/src/cli/dev/tui/prompt-command-handler.ts index f47e1dd75..d196ccbd8 100644 --- a/packages/eve/src/cli/dev/tui/prompt-command-handler.ts +++ b/packages/eve/src/cli/dev/tui/prompt-command-handler.ts @@ -116,7 +116,6 @@ export function createPromptCommandHandler( command: command.name, appRoot: target.workspaceRoot, renderer: flow, - disabledConnectionReasons: context.disabledConnectionReasons, withExclusiveTerminal: context.withExclusiveTerminal, }; if (context.initialModelStep !== undefined) { diff --git a/packages/eve/src/cli/dev/tui/prompt-commands.test.ts b/packages/eve/src/cli/dev/tui/prompt-commands.test.ts index 2edc789e6..a551d0c1c 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.test.ts @@ -93,7 +93,6 @@ describe("promptCommandsFor", () => { it("exposes project commands only for local sessions", () => { const names = promptCommandsFor("local").map((command) => command.name); expect(names).toContain("model"); - expect(names).toContain("connect"); expect(names).toContain("add"); expect(names).toContain("deploy"); expect(names).toContain("vc:install"); @@ -107,7 +106,6 @@ describe("promptCommandsFor", () => { expect(names).toContain("vc:login"); expect(names).not.toContain("vc:auth"); expect(names).not.toContain("model"); - expect(names).not.toContain("connect"); expect(names).not.toContain("add"); expect(names).not.toContain("deploy"); }); diff --git a/packages/eve/src/cli/dev/tui/prompt-commands.ts b/packages/eve/src/cli/dev/tui/prompt-commands.ts index 2e8de3e50..53b778128 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.ts @@ -1,10 +1,4 @@ -export type PromptCommandExtensionName = - | "model" - | "connect" - | "add" - | "deploy" - | "vc:install" - | "vc:login"; +export type PromptCommandExtensionName = "model" | "add" | "deploy" | "vc:install" | "vc:login"; type PromptCommandTarget = "local" | "remote"; @@ -97,14 +91,6 @@ const PROMPT_COMMAND_DEFINITIONS = [ build: (argument) => ({ type: "loglevel", argument }), targets: ["local", "remote"], }, - { - name: "connect", - aliases: [], - description: "Add an MCP server through Vercel Connect", - takesArgument: false, - build: () => ({ type: "extension", name: "connect", argument: "" }), - targets: ["local"], - }, { name: "add", aliases: [], diff --git a/packages/eve/src/cli/dev/tui/runner.test.ts b/packages/eve/src/cli/dev/tui/runner.test.ts index a20195bb9..a9e18debf 100644 --- a/packages/eve/src/cli/dev/tui/runner.test.ts +++ b/packages/eve/src/cli/dev/tui/runner.test.ts @@ -2293,98 +2293,6 @@ describe("EveTUIRunner Vercel status line", () => { expect(info).toHaveBeenCalledTimes(2); }); - it("forces a runtime rebuild after /connect adds a connection", async () => { - const client = stubClient(); - vi.spyOn(client, "info").mockResolvedValue(AGENT_INFO); - const requests: URL[] = []; - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]) => { - const url = new URL( - typeof input === "string" ? input : input instanceof URL ? input : input.url, - ); - requests.push(url); - return Response.json({ revision: url.searchParams.get("force") === "1" ? "next" : "base" }); - }), - ); - const prompts: Array = ["/connect", undefined]; - const renderer = fakeRenderer({ readPrompt: vi.fn(async () => prompts.shift()) }); - const connectOutcome = { - message: "Connections added: linear.", - effect: { kind: "connection-added" }, - } satisfies PromptCommandOutcome; - - const runner = new EveTUIRunner({ - session: stubSession(), - client, - renderer, - serverUrl: "http://localhost:3000", - name: "Weather Agent", - promptCommandHandler: { handle: async () => connectOutcome }, - }); - await runner.run(); - - expect( - requests.some( - (url) => - url.pathname === "/eve/v1/dev/runtime-artifacts/rebuild" && - url.searchParams.get("force") === "1", - ), - ).toBe(true); - }); - - it("forces a runtime rebuild through the /connect command path", async () => { - const client = stubClient(); - vi.spyOn(client, "info").mockResolvedValue(AGENT_INFO); - const requests: URL[] = []; - vi.stubGlobal( - "fetch", - vi.fn(async (input: Parameters[0]) => { - const url = new URL( - typeof input === "string" ? input : input instanceof URL ? input : input.url, - ); - requests.push(url); - return Response.json({ revision: url.searchParams.get("force") === "1" ? "next" : "base" }); - }), - ); - const runConnectionsFlow = vi.fn(async () => ({ - kind: "done" as const, - addedConnections: ["linear"], - })); - const prompts: Array = ["/connect", undefined]; - const renderer = fakeRenderer({ - readPrompt: vi.fn(async () => prompts.shift()), - setupFlow: idleSetupFlow(), - }); - - const runner = new EveTUIRunner({ - session: stubSession(), - client, - renderer, - serverUrl: "http://localhost:3000", - name: "Weather Agent", - appRoot: "/tmp/weather-agent", - promptCommandHandler: createPromptCommandHandler({ - target: { - kind: "local", - serverUrl: "http://localhost:3000", - workspaceRoot: "/tmp/weather-agent", - }, - flows: { runConnectionsFlow }, - }), - }); - await runner.run(); - - expect(runConnectionsFlow).toHaveBeenCalledTimes(1); - expect( - requests.some( - (url) => - url.pathname === "/eve/v1/dev/runtime-artifacts/rebuild" && - url.searchParams.get("force") === "1", - ), - ).toBe(true); - }); - it("never pushes Vercel status for a remote --url session", async () => { const setVercelStatus = vi.fn(); const renderer = fakeRenderer({ setVercelStatus }); diff --git a/packages/eve/src/cli/dev/tui/runner.ts b/packages/eve/src/cli/dev/tui/runner.ts index 4622d72ae..70281a0d1 100644 --- a/packages/eve/src/cli/dev/tui/runner.ts +++ b/packages/eve/src/cli/dev/tui/runner.ts @@ -373,7 +373,7 @@ export interface PromptCommandOutcome { /** Promotes an outcome to a top-level status. */ tone?: "success" | "error"; /** Post-command work after setup settles. */ - effect?: VercelStatusEffect | { kind: "connection-added" } | { kind: "model-access-changed" }; + effect?: VercelStatusEffect | { kind: "model-access-changed" }; } export interface PromptCommandHandler { @@ -1351,13 +1351,6 @@ export class EveTUIRunner { } async #applyCommandEffect(effect: PromptCommandOutcome["effect"]): Promise { - if (effect?.kind === "connection-added") { - this.#vercelStatus?.applyEffect({ kind: "refresh-identity" }); - this.#authHintStale = true; - await this.#refreshConnectionRuntime(); - await this.#refreshModelAccess(); - return; - } if (effect?.kind === "model-access-changed") { this.#vercelStatus?.applyEffect({ kind: "refresh-identity" }); this.#authHintStale = true; @@ -1371,15 +1364,6 @@ export class EveTUIRunner { void this.#refreshSetupAttention(this.#agentInfo); } - async #refreshConnectionRuntime(): Promise { - const runtimeArtifacts = this.#runtimeArtifacts; - if (runtimeArtifacts === undefined) return; - - await runtimeArtifacts.refreshAfterSourceChange({ - onRuntimeArtifactsChanged: () => this.#handleRuntimeArtifactsChanged(), - }); - } - async #executeExtensionCommand( command: Extract, title: string, diff --git a/packages/eve/src/cli/dev/tui/setup-commands.test.ts b/packages/eve/src/cli/dev/tui/setup-commands.test.ts index 54d18d6a1..fe912fb3b 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.test.ts @@ -54,10 +54,6 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { kind: "done", modelMessage: "Model changed to openai/gpt-5.5. Live on your next prompt.", })), - runConnectionsFlow: vi.fn(async () => ({ - kind: "done", - addedConnections: [], - })), runRegistryFlow: vi.fn(async () => ({ kind: "done", addedItems: [], @@ -71,7 +67,7 @@ function fakeFlows(overrides: Partial = {}): TuiSetupFlows { } function run(input: { - command: "vc:install" | "vc:login" | "model" | "connect" | "add" | "deploy"; + command: "vc:install" | "vc:login" | "model" | "add" | "deploy"; flows: TuiSetupFlows; renderer?: TuiSetupCommandRenderer; initialModelStep?: "provider"; @@ -116,7 +112,6 @@ describe("runTuiSetupCommand", () => { "vc:install": "pulse", "vc:login": "pulse", model: "pulse", - connect: "pulse", add: "pulse", deploy: "spinner", }); @@ -331,44 +326,6 @@ describe("runTuiSetupCommand", () => { }); }); - it.each([ - [ - "configured", - { kind: "done", addedConnections: ["linear", "notion"] }, - "Connections added: linear, notion.", - { kind: "connection-added" }, - ], - [ - "empty", - { kind: "done", addedConnections: [] }, - "No connections added.", - { kind: "model-access-changed" }, - ], - ["cancelled", { kind: "cancelled" }, "/connect dismissed.", { kind: "model-access-changed" }], - [ - "partially failed", - { kind: "failed", addedConnections: ["linear"], message: "install failed" }, - "Connection files changed, but /connect failed: install failed", - { kind: "connection-added" }, - ], - [ - "failed before a connection file was written", - { kind: "failed", addedConnections: [], message: "connector setup failed" }, - "/connect failed: connector setup failed", - { kind: "model-access-changed" }, - ], - ] as const)("reports %s connection flows", async (_case, result, message, effect) => { - const runConnectionsFlow = vi.fn(async () => result); - await expect( - run({ command: "connect", flows: fakeFlows({ runConnectionsFlow }) }), - ).resolves.toEqual({ - message, - preserveFlowDiagnostics: true, - effect, - }); - expect(runConnectionsFlow).toHaveBeenCalledWith(expect.objectContaining({ appRoot: APP_ROOT })); - }); - it.each([ [ "added", diff --git a/packages/eve/src/cli/dev/tui/setup-commands.ts b/packages/eve/src/cli/dev/tui/setup-commands.ts index 293e1f9f9..97c4faaba 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.ts @@ -1,5 +1,4 @@ import { HumanActionRequiredError } from "#setup/human-action.js"; -import { runConnectionsFlow } from "#setup/flows/connections.js"; import { runDeployFlow } from "#setup/flows/deploy.js"; import { runInstallVercelCliFlow, @@ -28,7 +27,6 @@ export const SETUP_FLOW_CONFIG = { "vc:install": { title: "Install the Vercel CLI", indicator: "pulse" }, "vc:login": { title: "Log in to Vercel", indicator: "pulse" }, model: { title: "Configure the agent model", indicator: "pulse" }, - connect: { title: "Agent connections", indicator: "pulse" }, add: { title: "Add to your agent", indicator: "pulse" }, deploy: { title: "Deploy to Vercel", indicator: "spinner" }, } satisfies Record; @@ -48,7 +46,6 @@ export interface TuiSetupCommandInput { renderer: TuiSetupCommandRenderer; /** Initial model-flow step authorized by the runner's boot evidence. */ initialModelStep?: "provider"; - disabledConnectionReasons?: Readonly>; /** Suspends development runtime artifacts while registry installation and setup mutate them. */ withExclusiveTerminal?(task: () => Promise): Promise; /** Test seam; defaults to the real TUI-native prompter over `renderer`. */ @@ -62,7 +59,6 @@ export interface TuiSetupFlows { runInstallVercelCliFlow: typeof runInstallVercelCliFlow; runLoginFlow: typeof runLoginFlow; runModelFlow: typeof runModelFlow; - runConnectionsFlow: typeof runConnectionsFlow; runRegistryFlow: typeof runRegistryFlow; runDeployFlow: typeof runDeployFlow; } @@ -74,7 +70,7 @@ export interface TuiSetupCommandResult { /** Keep warning/error lines after the bordered panel closes. */ preserveFlowDiagnostics: boolean; /** Status refresh required after the command settles. */ - effect?: VercelStatusEffect | { kind: "connection-added" } | { kind: "model-access-changed" }; + effect?: VercelStatusEffect | { kind: "model-access-changed" }; } /** @@ -122,7 +118,7 @@ function muteableRenderer( } /** - * Runs one TUI setup command (/model, /connect, /deploy) over the + * Runs one TUI setup command (/model, /add, /deploy) over the * shared setup flows, asking through the TUI's own bordered panel. Never throws: * every outcome — done, cancelled, failed — folds into the returned command * result. Ctrl-C or Esc on the working indicator (no question open) aborts the @@ -171,7 +167,6 @@ async function executeSetupCommand( runInstallVercelCliFlow, runLoginFlow, runModelFlow, - runConnectionsFlow, runRegistryFlow, runDeployFlow, ...input.flows, @@ -231,46 +226,6 @@ async function executeSetupCommand( } return outcome; } - case "connect": { - const result = await flows.runConnectionsFlow({ - appRoot, - prompter, - signal, - disabledConnectionReasons: input.disabledConnectionReasons, - }); - switch (result.kind) { - case "cancelled": - return { - message: "/connect dismissed.", - preserveFlowDiagnostics: true, - effect: { kind: "model-access-changed" }, - }; - case "failed": - return { - message: - result.addedConnections.length === 0 - ? `/connect failed: ${result.message}` - : `Connection files changed, but /connect failed: ${result.message}`, - preserveFlowDiagnostics: true, - effect: - result.addedConnections.length === 0 - ? { kind: "model-access-changed" } - : { kind: "connection-added" }, - }; - case "done": - return { - message: - result.addedConnections.length === 0 - ? "No connections added." - : `Connections added: ${result.addedConnections.join(", ")}.`, - preserveFlowDiagnostics: true, - effect: - result.addedConnections.length === 0 - ? { kind: "model-access-changed" } - : { kind: "connection-added" }, - }; - } - } case "add": { const result = await flows.runRegistryFlow({ appRoot, prompter, signal }); if (result.kind === "cancelled") { diff --git a/packages/eve/src/setup/boxes/add-connections.test.ts b/packages/eve/src/setup/boxes/add-connections.test.ts deleted file mode 100644 index 135e3596f..000000000 --- a/packages/eve/src/setup/boxes/add-connections.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; - -import { headlessAsker, interactiveAsker } from "../ask.js"; -import type { Prompter } from "../prompter.js"; -import { createDefaultSetupState, snapshotSetupState, type SetupState } from "../state.js"; -import type { OutputSink } from "../step.js"; -import { runHeadless, runInteractive } from "../runner.js"; -import { addConnections, type AddConnectionsDeps } from "./add-connections.js"; -import { - buildCatalogOptions, - selectConnections, - type SelectConnectionsOptions, -} from "./select-connections.js"; - -const silentSink: OutputSink = { write: () => {} }; -const snapshot = { snapshot: snapshotSetupState }; - -function createPrompter(): Prompter { - return createFakePrompter().prompter; -} - -/** - * Composes the selection and execution boxes the way the onboarding site does: - * selection resolves the picker and custom sub-questions into plans on the - * state, execution runs the effects. The ask channel pairs the headless base - * with the selection box's `headless` flag, or an interactive base over the - * test prompter. - */ -function makeBoxes( - options: Omit & { - prompter: Prompter; - headless?: boolean; - deps?: AddConnectionsDeps; - }, -): [ReturnType, ReturnType] { - const headless = options.headless ?? false; - return [ - selectConnections({ - presetConnections: options.presetConnections, - asker: headless ? headlessAsker() : interactiveAsker(options.prompter), - headless, - }), - addConnections({ - prompter: options.prompter, - deps: options.deps, - }), - ]; -} - -function createDeps() { - return { - ensureConnection: vi.fn(async (options) => ({ - slug: options.slug ?? options.entry.slug, - protocol: options.protocol, - action: "created", - filePath: `/tmp/project/agent/connections/${options.slug ?? options.entry.slug}.ts`, - filesWritten: [`/tmp/project/agent/connections/${options.slug ?? options.entry.slug}.ts`], - filesSkipped: [], - packageJsonUpdated: [], - envKeysAdded: [], - envKeysRequired: [], - })), - listAuthoredConnections: vi.fn(async () => []), - readProjectLink: vi.fn(async () => ({ - orgId: "org_1", - projectId: "prj_demo", - })), - setupConnectionConnector: vi.fn(async () => ({ - kind: "existing", - connectorUid: "oauth/connector-1", - })), - cleanupCreatedConnectionConnector: vi.fn< - AddConnectionsDeps["cleanupCreatedConnectionConnector"] - >(async () => {}), - }; -} - -function createdConnectorDeps() { - const deps = createDeps(); - deps.setupConnectionConnector.mockResolvedValueOnce({ - kind: "created", - connectorUid: "linear/new", - connectorId: "scl_new", - }); - return deps; -} - -function runCreatedConnector(deps: ReturnType) { - return runInteractive( - makeBoxes({ prompter: createPrompter(), presetConnections: ["linear"], deps }), - resolvedState(), - silentSink, - snapshot, - ); -} - -function resolvedState(): SetupState { - const state = createDefaultSetupState(); - state.projectPath = { kind: "resolved", inPlace: false, path: "/tmp/project" }; - state.vercelProject = { kind: "new", project: "project", team: "team" }; - state.project = { kind: "linked", projectId: "prj_demo" }; - return state; -} - -/** A flow that chose not to deploy to Vercel: no project planned or linked. */ -function noVercelState(): SetupState { - const state = createDefaultSetupState(); - state.projectPath = { kind: "resolved", inPlace: false, path: "/tmp/project" }; - return state; -} - -describe("selectConnections + addConnections boxes", () => { - test("scaffolds a preset slug headlessly and prints the connector command hint", async () => { - const deps = createDeps(); - const prompter = createPrompter(); - const boxes = makeBoxes({ prompter, presetConnections: ["linear"], headless: true, deps }); - - await runHeadless(boxes, resolvedState(), silentSink, snapshot); - - expect(deps.ensureConnection).toHaveBeenCalledWith({ - projectRoot: "/tmp/project", - slug: "linear", - protocol: "mcp", - entry: expect.objectContaining({ slug: "linear" }), - }); - // Headless runs surface the connector command instead of provisioning, - // since `vercel connect create` opens a browser. - expect(deps.setupConnectionConnector).not.toHaveBeenCalled(); - expect(prompter.log.info).toHaveBeenCalledWith( - "Run `vercel connect create mcp.linear.app --name linear`, then set the connector UID in agent/connections/linear.ts.", - ); - }); - - test("provisions the Connect connector for a preset slug interactively", async () => { - const deps = createDeps(); - const boxes = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["linear"], - deps, - }); - - await runInteractive(boxes, resolvedState(), silentSink, snapshot); - - expect(deps.setupConnectionConnector).toHaveBeenCalledWith( - expect.objectContaining({ - slug: "linear", - service: "mcp.linear.app", - projectRoot: "/tmp/project", - }), - ); - expect(deps.ensureConnection).toHaveBeenCalledWith( - expect.objectContaining({ - entry: expect.objectContaining({ - auth: expect.objectContaining({ connector: "oauth/connector-1" }), - }), - }), - ); - }); - - test("uses the picker selection when no preset is given", async () => { - const deps = createDeps(); - let presented: { value: string | number | boolean; label: string }[] = []; - const prompter = createFakePrompter({ - multiple: (opts) => { - presented = opts.options; - return ["notion"]; - }, - }).prompter; - const boxes = makeBoxes({ prompter, deps }); - - await runInteractive(boxes, resolvedState(), silentSink, snapshot); - - expect(deps.ensureConnection).toHaveBeenCalledWith( - expect.objectContaining({ slug: "notion", protocol: "mcp" }), - ); - expect(deps.setupConnectionConnector).toHaveBeenCalledWith( - expect.objectContaining({ slug: "notion", service: "mcp.notion.com" }), - ); - // The interactive picker offers only curated catalog entries. - expect(presented.length).toBeGreaterThan(0); - expect(presented.some((option) => option.value === "custom")).toBe(false); - }); - - test("marks blocked catalog rows disabled with the reason", () => { - const options = buildCatalogOptions({ linear: "already defined" }); - - const linear = options.find((option) => option.value === "linear"); - expect(linear?.disabled).toBe(true); - expect(linear?.disabledReason).toBe("already defined"); - const others = options.filter((option) => option.value !== "linear"); - expect(others.length).toBeGreaterThan(0); - expect(others.every((option) => option.disabled !== true)).toBe(true); - }); - - test("builds a custom MCP plan from interactive prompts, deriving the service host", async () => { - const deps = createDeps(); - const text = vi - .fn<(opts: { message: string }) => string>() - .mockReturnValueOnce("Internal tools") // description - .mockReturnValueOnce("https://mcp.mycorp.dev/sse"); // url - const prompter = createFakePrompter({ text }).prompter; - // A valid slug that is not in the catalog routes to the custom planner, the - // only production path to it: the picker offers catalog rows only, so the - // channel (which now enforces the offered option ids, unlike the old raw - // prompter.select) would refuse a "custom" sentinel the picker never showed. - // The slug comes from the preset, so only the description and URL are asked. - const boxes = makeBoxes({ prompter, presetConnections: ["mycorp"], deps }); - - await runInteractive(boxes, resolvedState(), silentSink, snapshot); - - expect(text.mock.calls.map(([opts]) => opts.message)).toEqual([ - "Description for mycorp", - "MCP server URL for mycorp", - ]); - expect(deps.ensureConnection).toHaveBeenCalledWith( - expect.objectContaining({ - slug: "mycorp", - protocol: "mcp", - entry: expect.objectContaining({ - slug: "mycorp", - mcp: { url: "https://mcp.mycorp.dev/sse" }, - auth: { kind: "connect", connector: "oauth/connector-1" }, - }), - }), - ); - // Custom MCP URLs retain the host fallback because only curated providers - // declare a verified Vercel Connect service identifier. - expect(deps.setupConnectionConnector).toHaveBeenCalledWith( - expect.objectContaining({ slug: "mycorp", service: "mcp.mycorp.dev" }), - ); - }); - - test("rejects an unknown preset slug headlessly without touching files", async () => { - const deps = createDeps(); - const boxes = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["mystery"], - headless: true, - deps, - }); - - await expect(runHeadless(boxes, resolvedState(), silentSink, snapshot)).rejects.toThrow( - /requires interactive input/, - ); - expect(deps.ensureConnection).not.toHaveBeenCalled(); - }); - - test("rejects a malformed preset slug with the catalog listing", async () => { - const deps = createDeps(); - const boxes = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["Not_A_Slug"], - headless: true, - deps, - }); - - await expect(runHeadless(boxes, resolvedState(), silentSink, snapshot)).rejects.toThrow( - /Unknown connection "Not_A_Slug"/, - ); - expect(deps.ensureConnection).not.toHaveBeenCalled(); - }); - - test("selection records fully-specified plans on the state without running any effect", async () => { - const deps = createDeps(); - const [selectBox] = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["linear"], - headless: true, - deps, - }); - - const next = await runHeadless([selectBox], resolvedState(), silentSink, snapshot); - - expect(next.connectionSelection).toEqual([ - { - slug: "linear", - protocol: "mcp", - entry: expect.objectContaining({ slug: "linear" }), - provision: { kind: "command-hint", service: "mcp.linear.app" }, - }, - ]); - expect(deps.ensureConnection).not.toHaveBeenCalled(); - expect(deps.setupConnectionConnector).not.toHaveBeenCalled(); - }); - - test("the execution box skips entirely when nothing was selected", () => { - const box = addConnections({ prompter: createPrompter(), deps: createDeps() }); - - expect(box.shouldRun?.(snapshotSetupState(resolvedState()))).toBe(false); - expect( - box.shouldRun?.( - snapshotSetupState({ - ...resolvedState(), - connectionSelection: [ - { - slug: "linear", - protocol: "mcp", - entry: { slug: "linear" } as never, - provision: { kind: "none" }, - }, - ], - }), - ), - ).toBe(true); - }); - - test("skips provisioning when the connection file already exists", async () => { - const deps = createDeps(); - deps.listAuthoredConnections.mockResolvedValueOnce(["linear"]); - deps.ensureConnection.mockResolvedValueOnce({ - slug: "linear", - protocol: "mcp", - action: "skipped", - filePath: "/tmp/project/agent/connections/linear.ts", - filesWritten: [], - filesSkipped: ["/tmp/project/agent/connections/linear.ts"], - packageJsonUpdated: [], - envKeysAdded: [], - envKeysRequired: [], - }); - const prompter = createPrompter(); - const boxes = makeBoxes({ prompter, presetConnections: ["linear"], deps }); - - await runInteractive(boxes, resolvedState(), silentSink, snapshot); - - expect(deps.setupConnectionConnector).not.toHaveBeenCalled(); - expect(prompter.log.warning).toHaveBeenCalledWith( - "Skipped linear (already exists; pass --force to overwrite).", - ); - }); - - test("removes a connector created before a scaffold failure", async () => { - const deps = createdConnectorDeps(); - deps.ensureConnection.mockRejectedValueOnce(new Error("write failed")); - - await expect(runCreatedConnector(deps)).rejects.toThrow("write failed"); - expect(deps.cleanupCreatedConnectionConnector).toHaveBeenCalledWith( - expect.objectContaining({ connectorId: "scl_new" }), - ); - }); - - test("reports both scaffold and connector cleanup failures", async () => { - const deps = createdConnectorDeps(); - deps.ensureConnection.mockRejectedValueOnce(new Error("write failed")); - deps.cleanupCreatedConnectionConnector.mockRejectedValueOnce( - new Error("remove scl_new manually"), - ); - await expect(runCreatedConnector(deps)).rejects.toThrow("write failed remove scl_new manually"); - }); - - test("is skipped in headless mode when no connections are requested", async () => { - const deps = createDeps(); - const boxes = makeBoxes({ prompter: createPrompter(), headless: true, deps }); - - const next = await runHeadless(boxes, resolvedState(), silentSink, snapshot); - - expect(deps.ensureConnection).not.toHaveBeenCalled(); - expect(next.project).toEqual({ kind: "linked", projectId: "prj_demo" }); - }); - - test("offers the full catalog before the deployment decision (no Vercel plan yet)", async () => { - // The deployment question now comes after the connections picker, so no - // row is disabled here; the provisioning box resolves Connect-backed - // selections to Vercel instead. - const deps = createDeps(); - let presented: { value: string | number | boolean; disabled?: boolean }[] = []; - const prompter = createFakePrompter({ - multiple: (opts) => { - presented = opts.options; - return []; - }, - }).prompter; - const boxes = makeBoxes({ prompter, deps }); - - await runInteractive(boxes, noVercelState(), silentSink, snapshot); - - expect(presented.length).toBeGreaterThan(0); - expect(presented.every((option) => option.disabled !== true)).toBe(true); - expect(deps.ensureConnection).not.toHaveBeenCalled(); - }); - - test("throws when reusing an unresolved project while deploying to Vercel", async () => { - const state = resolvedState(); - // project stays unresolved: the link box did not record a resolution. - state.project = { kind: "unresolved" }; - const deps = createDeps(); - const boxes = makeBoxes({ - prompter: createPrompter(), - presetConnections: ["linear"], - deps, - }); - - await expect(runInteractive(boxes, state, silentSink, snapshot)).rejects.toThrow( - /none was resolved/, - ); - }); -}); diff --git a/packages/eve/src/setup/boxes/add-connections.ts b/packages/eve/src/setup/boxes/add-connections.ts deleted file mode 100644 index ff360ef73..000000000 --- a/packages/eve/src/setup/boxes/add-connections.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { - ensureConnection, - listAuthoredConnections, - type ConnectionInput, - type ConnectionMutationResult, -} from "#setup/scaffold/index.js"; -import type { ChannelSetupLog } from "#setup/cli/index.js"; - -import { - cleanupCreatedConnectionConnector, - setupConnectionConnector, -} from "../connection-connector.js"; -import { canonicalConnectorNameForEntry } from "../scaffold/connections/catalog.js"; -import { - isProjectResolved, - mergeProjectResolution, - readProjectLink, - type ProjectResolution, -} from "../project-resolution.js"; -import type { Prompter } from "../prompter.js"; -import { hasVercelProject, requireProjectPath, type SetupState } from "../state.js"; -import type { SetupBox } from "../step.js"; -import { projectIdFromResolution } from "../vercel-project.js"; -import { CONNECT_REQUIRES_VERCEL } from "./select-connections.js"; - -/** Injected for tests; defaults to the real scaffold and Connect effects. */ -export interface AddConnectionsDeps { - ensureConnection: typeof ensureConnection; - listAuthoredConnections: typeof listAuthoredConnections; - readProjectLink: typeof readProjectLink; - setupConnectionConnector: typeof setupConnectionConnector; - cleanupCreatedConnectionConnector: typeof cleanupCreatedConnectionConnector; -} - -export interface AddConnectionsOptions { - /** Carries connector selection prompts and provisioning output. */ - prompter: Prompter; - signal?: AbortSignal; - deps?: AddConnectionsDeps; -} - -function logFollowUp(log: ChannelSetupLog, result: ConnectionMutationResult): void { - if (result.action === "skipped") { - log.warning(`Skipped ${result.slug} (already exists; pass --force to overwrite).`); - return; - } - log.success(`Added agent/connections/${result.slug}.ts`); - if (result.envKeysAdded.length > 0) { - log.info(`Set ${result.envKeysAdded.join(", ")} in .env.local`); - } else if (result.envKeysRequired.length > 0) { - log.info(`Set ${result.envKeysRequired.join(", ")} in your environment`); - } -} - -function withConnectorUid(entry: ConnectionInput, connectorUid: string): ConnectionInput { - if (entry.auth?.kind !== "connect") { - throw new Error(`Connection ${entry.slug} is not configured for Vercel Connect.`); - } - return { ...entry, auth: { ...entry.auth, connector: connectorUid } }; -} - -/** - * THE CONNECTIONS BOX: executes the {@link ConnectionPlan}s the - * select-connections box recorded during the interview. It scaffolds each file - * and resolves the concrete Connect connector against the linked project. - */ -export function addConnections( - options: AddConnectionsOptions, -): SetupBox { - const deps = options.deps ?? { - ensureConnection, - listAuthoredConnections, - readProjectLink, - setupConnectionConnector, - cleanupCreatedConnectionConnector, - }; - - return { - id: "add-connections", - - shouldRun(state) { - return state.connectionSelection.length > 0; - }, - - async gather(): Promise { - // No questions: the plans were resolved by the select-connections box. - return null; - }, - - async perform({ state }): Promise { - const log = options.prompter.log; - const projectRoot = requireProjectPath(state); - const noVercel = !hasVercelProject(state); - const project = state.project; - const authored = new Set(await deps.listAuthoredConnections(projectRoot)); - - for (const plan of state.connectionSelection) { - if (authored.has(plan.slug)) { - const result = await deps.ensureConnection({ - projectRoot, - slug: plan.slug, - protocol: plan.protocol, - entry: plan.entry, - }); - logFollowUp(log, result); - continue; - } - - let entry = plan.entry; - let createdConnectorId: string | undefined; - - switch (plan.provision.kind) { - case "connect": { - const canonicalConnectorName = canonicalConnectorNameForEntry(plan.entry); - if (canonicalConnectorName === undefined) { - throw new Error(`Connection ${plan.slug} has no canonical connector name.`); - } - const connector = await deps.setupConnectionConnector({ - log, - prompter: options.prompter, - projectRoot, - slug: plan.slug, - service: plan.provision.service, - canonicalConnectorName, - project: await resolveConnectionProject({ - noVercel, - project, - projectRoot, - readProjectLink: deps.readProjectLink, - }), - signal: options.signal, - }); - entry = withConnectorUid(entry, connector.connectorUid); - if (connector.kind === "created") createdConnectorId = connector.connectorId; - break; - } - case "command-hint": - log.info( - `Run \`vercel connect create ${plan.provision.service} --name ${plan.slug}\`, then set the connector UID in agent/connections/${plan.slug}.ts.`, - ); - break; - case "connect-manual": - log.warning( - `Could not determine a Connect service for ${plan.slug}. Create the connector manually and set its UID in agent/connections/${plan.slug}.ts.`, - ); - break; - case "none": - break; - } - - let result: ConnectionMutationResult; - try { - result = await deps.ensureConnection({ - projectRoot, - slug: plan.slug, - protocol: plan.protocol, - entry, - }); - } catch (error) { - if (createdConnectorId !== undefined) { - try { - await deps.cleanupCreatedConnectionConnector({ - log, - projectRoot, - connectorId: createdConnectorId, - }); - } catch (cleanupError) { - throw new AggregateError( - [error, cleanupError], - `${error instanceof Error ? error.message : String(error)} ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, - ); - } - } - throw error; - } - if (result.action === "skipped" && createdConnectorId !== undefined) { - await deps.cleanupCreatedConnectionConnector({ - log, - projectRoot, - connectorId: createdConnectorId, - }); - } - logFollowUp(log, result); - if (result.action !== "skipped") authored.add(result.slug); - } - return project; - }, - - apply(state, payload) { - return { ...state, project: mergeProjectResolution(state.project, payload) }; - }, - }; -} - -async function resolveConnectionProject(input: { - noVercel: boolean; - project: ProjectResolution; - projectRoot: string; - readProjectLink: typeof readProjectLink; -}) { - if (input.noVercel) { - throw new Error(CONNECT_REQUIRES_VERCEL); - } - if (!isProjectResolved(input.project)) { - throw new Error("Expected a linked Vercel project for Connect, but none was resolved."); - } - - const linkedProject = await input.readProjectLink(input.projectRoot); - if ( - linkedProject === undefined || - linkedProject.projectId !== projectIdFromResolution(input.project) - ) { - throw new Error("A linked Vercel project is required. Run `eve link`, then retry /connect."); - } - return linkedProject; -} diff --git a/packages/eve/src/setup/boxes/select-connections.ts b/packages/eve/src/setup/boxes/select-connections.ts deleted file mode 100644 index 62ec994ff..000000000 --- a/packages/eve/src/setup/boxes/select-connections.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { - catalogSlugs, - CONNECTION_CATALOG, - CUSTOM_CONNECTION_SLUG, - effectiveProtocols, - getCatalogEntry, - isValidConnectionSlug, - SUPPORTED_PROTOCOLS, - type ConnectionInput, - type ConnectionProtocol, - type CustomConnectionInput, -} from "#setup/scaffold/index.js"; -import { connectorServiceForEntry } from "#setup/scaffold/connections/catalog.js"; -import type { ConnectionSelectOption } from "#setup/cli/index.js"; - -import { select, text, type Asker, type MultiSelectOption } from "../ask.js"; -import type { ConnectionPlan, SetupState } from "../state.js"; -import type { SetupBox } from "../step.js"; - -export const CONNECT_REQUIRES_VERCEL = - "Authenticates through Vercel Connect, which needs a Vercel project. Re-run and choose to deploy to Vercel."; - -const PROTOCOL_LABELS: Record = { - mcp: "MCP", - openapi: "OpenAPI", -}; - -export interface SelectConnectionsOptions { - /** Resolves the picker and custom sub-questions; the composed stack decides how. */ - asker: Asker; - /** - * Headless mode: skips the picker entirely unless `presetConnections` were - * requested. Fixed at composition time (the same place the asker base is - * chosen), since `gather` cannot read the mode off the asker. - */ - headless?: boolean; - /** Skip the picker and plan exactly these catalog slugs. */ - presetConnections?: string[]; -} - -/** Exported for tests: the picker rows derived from the curated catalog. */ -export function buildCatalogOptions( - disabledReasons: Readonly>, -): ConnectionSelectOption[] { - const options: ConnectionSelectOption[] = CONNECTION_CATALOG.map((entry) => { - const reason = disabledReasons[entry.slug]; - return reason === undefined - ? { value: entry.slug, label: entry.label, hint: entry.hint } - : { - value: entry.slug, - label: entry.label, - hint: entry.hint, - disabled: true, - disabledReason: reason, - }; - }); - return options; -} - -function unknownSlugError(rawSlug: string): Error { - return new Error( - `Unknown connection "${rawSlug}". Known: ${catalogSlugs().join(", ")}, or pass a custom name with a definition.`, - ); -} - -function assertSupportedProtocols( - effective: readonly ConnectionProtocol[], - label: string, -): asserts effective is readonly [ConnectionProtocol, ...ConnectionProtocol[]] { - if (effective.length === 0) { - throw new Error( - `No supported protocol for "${label}". Supported: ${SUPPORTED_PROTOCOLS.join(", ")}.`, - ); - } -} - -async function resolveProtocolInteractive( - asker: Asker, - declared: readonly ConnectionProtocol[] | undefined, - label: string, -): Promise { - const effective = effectiveProtocols(declared); - assertSupportedProtocols(effective, label); - if (effective.length === 1) return effective[0]; - - return asker.ask( - select({ - key: `protocol:${label}`, - message: `Protocol for ${label}`, - options: effective.map((protocol) => ({ - id: protocol, - value: protocol, - label: PROTOCOL_LABELS[protocol], - })), - }), - ); -} - -function resolveProtocolHeadless( - declared: readonly ConnectionProtocol[] | undefined, - label: string, -): ConnectionProtocol { - const effective = effectiveProtocols(declared); - assertSupportedProtocols(effective, label); - if (effective.length > 1) { - throw new Error( - `Connection "${label}" supports multiple protocols (${effective.join(", ")}). Pass --protocol to choose one.`, - ); - } - return effective[0]; -} - -/** - * How the connector for a Connect-backed entry gets provisioned. Interactive - * runs provision through Connect (browser OAuth); headless runs print the - * `vercel connect create` command instead. An underivable service degrades to - * a manual instruction either way. - */ -function deriveProvision(entry: ConnectionInput, headless: boolean): ConnectionPlan["provision"] { - if (entry.auth?.kind !== "connect") return { kind: "none" }; - const service = connectorServiceForEntry({ mcp: entry.mcp, auth: entry.auth }); - if (service === undefined) return { kind: "connect-manual" }; - return headless ? { kind: "command-hint", service } : { kind: "connect", service }; -} - -async function promptCustomSlug(asker: Asker): Promise { - return asker.ask( - text({ - key: "connection-name", - message: "Connection name", - placeholder: "mycorp", - validate: (value) => { - const trimmed = value.trim(); - if (trimmed.length === 0) return "A name is required."; - if (!isValidConnectionSlug(trimmed)) { - return "Start with a lowercase letter; use only lowercase letters, digits, and hyphens (max 64 characters)."; - } - return null; - }, - }), - ); -} - -async function planCustomInteractive(asker: Asker, slug: string): Promise { - const protocol = await resolveProtocolInteractive(asker, undefined, slug); - const description = await asker.ask( - text({ - key: `description:${slug}`, - message: `Description for ${slug}`, - placeholder: "What this connection exposes", - }), - ); - - if (protocol === "mcp") { - const url = await asker.ask( - text({ - key: `mcp-url:${slug}`, - message: `MCP server URL for ${slug}`, - placeholder: "https://mcp.example.com/mcp", - validate: (value) => (value.trim().length === 0 ? "A URL is required." : null), - }), - ); - const entry: CustomConnectionInput = { - slug, - description, - protocols: ["mcp"], - mcp: { url: url.trim() }, - auth: { kind: "connect", connector: slug }, - }; - return { slug, protocol, entry, provision: deriveProvision(entry, false) }; - } - - const spec = await asker.ask( - text({ - key: `openapi-spec:${slug}`, - message: `OpenAPI spec URL for ${slug}`, - placeholder: "https://api.example.com/openapi.json", - validate: (value) => (value.trim().length === 0 ? "A spec URL is required." : null), - }), - ); - const baseUrl = await asker.ask( - text({ - key: `openapi-base-url:${slug}`, - message: `Base URL for ${slug} (optional)`, - placeholder: "https://api.example.com", - }), - ); - const openapi = - baseUrl.trim().length > 0 - ? { spec: spec.trim(), baseUrl: baseUrl.trim() } - : { spec: spec.trim() }; - const entry: CustomConnectionInput = { - slug, - description, - protocols: ["openapi"], - openapi, - auth: { kind: "connect", connector: slug }, - }; - return { slug, protocol, entry, provision: deriveProvision(entry, false) }; -} - -async function planSelectionInteractive(asker: Asker, rawSlug: string): Promise { - if (rawSlug === CUSTOM_CONNECTION_SLUG) { - const slug = (await promptCustomSlug(asker)).trim(); - return planCustomInteractive(asker, slug); - } - - const entry = getCatalogEntry(rawSlug); - if (entry !== undefined) { - const protocol = await resolveProtocolInteractive(asker, entry.protocols, rawSlug); - return { slug: rawSlug, protocol, entry, provision: deriveProvision(entry, false) }; - } - - if (!isValidConnectionSlug(rawSlug)) { - throw unknownSlugError(rawSlug); - } - return planCustomInteractive(asker, rawSlug); -} - -function planPresetHeadless(rawSlug: string): ConnectionPlan { - if (rawSlug === CUSTOM_CONNECTION_SLUG) { - throw new Error("Custom connection requires interactive input or a preset definition."); - } - - const entry = getCatalogEntry(rawSlug); - if (entry !== undefined) { - return { - slug: rawSlug, - protocol: resolveProtocolHeadless(entry.protocols, rawSlug), - entry, - provision: deriveProvision(entry, true), - }; - } - - if (!isValidConnectionSlug(rawSlug)) { - throw unknownSlugError(rawSlug); - } - throw new Error( - `Custom connection "${rawSlug}" requires interactive input or a preset definition.`, - ); -} - -/** - * THE CONNECTION SELECTION BOX: part of the interview phase, before any - * filesystem write. Offers the connection catalog (plus a custom MCP / OpenAPI - * escape hatch through preset slugs) and resolves every prompt into - * fully-specified {@link ConnectionPlan}s recorded on the state; the - * add-connections box executes them after the scaffold and link. The full - * catalog is selectable: the deployment decision comes later in the interview, - * and the provisioning box resolves to Vercel when a Connect-backed selection - * needs a project. - */ -export function selectConnections( - options: SelectConnectionsOptions, -): SetupBox { - return { - id: "select-connections", - - async gather(): Promise { - const headless = options.headless ?? false; - const presets = options.presetConnections ?? []; - - // Headless maps presets to plans without prompting (and refuses an - // ambiguous protocol with a flag hint), or skips entirely when no preset - // was requested. - if (headless) { - return presets.map((slug) => planPresetHeadless(slug)); - } - - // The preset short-circuits the picker but still flows through the - // interactive planners (a catalog preset can still need a protocol pick), - // so it stays a factory option rather than a withAnswers rung. - let selected: string[]; - if (presets.length > 0) { - selected = [...presets]; - } else { - const pickerOptions: MultiSelectOption[] = buildCatalogOptions({}).map( - (option) => ({ - id: String(option.value), - value: String(option.value), - label: option.label, - hint: option.hint, - disabled: option.disabled, - disabledReason: option.disabledReason, - }), - ); - selected = await options.asker.askMany({ - key: "connection", - message: "What should your agent connect to?", - options: pickerOptions, - }); - } - - const plans: ConnectionPlan[] = []; - for (const rawSlug of selected) { - plans.push(await planSelectionInteractive(options.asker, rawSlug)); - } - return plans; - }, - - async perform({ input }): Promise { - return input; - }, - - apply(state, payload) { - return { ...state, connectionSelection: payload }; - }, - }; -} diff --git a/packages/eve/src/setup/flows/connections.test.ts b/packages/eve/src/setup/flows/connections.test.ts deleted file mode 100644 index bf4bade3d..000000000 --- a/packages/eve/src/setup/flows/connections.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import type { AddConnectionsDeps } from "#setup/boxes/add-connections.js"; -import type { DeploymentInfo } from "#setup/project-resolution.js"; -import type { PrompterValue, SingleSelectOptions } from "#setup/prompter.js"; -import { WizardCancelledError } from "#setup/step.js"; - -import { - CONNECTIONS_PROMPT_MESSAGE, - runConnectionsFlow, - type ConnectionsFlowDeps, -} from "./connections.js"; - -const APP_ROOT = "/app/agent"; -const LINKED: DeploymentInfo = { state: "linked", projectId: "prj_1", orgId: "org_1" }; - -function scriptConnectionList(queue: Array) { - const requests: SingleSelectOptions[] = []; - return { - requests, - single(options: SingleSelectOptions): PrompterValue { - if (options.message !== CONNECTIONS_PROMPT_MESSAGE) { - throw new Error(`Unexpected select: ${options.message}`); - } - requests.push(options); - const next = queue.shift(); - if (next === undefined) throw new Error("Connection list exhausted its scripted picks."); - if (next === "cancel") throw new WizardCancelledError(); - return next; - }, - }; -} - -function addConnectionDeps(): AddConnectionsDeps { - return { - ensureConnection: vi.fn(async (options) => ({ - slug: options.slug ?? options.entry.slug, - protocol: options.protocol, - action: "created", - filePath: `${APP_ROOT}/agent/connections/${options.slug ?? options.entry.slug}.ts`, - filesWritten: [`${APP_ROOT}/agent/connections/${options.slug ?? options.entry.slug}.ts`], - filesSkipped: [], - packageJsonUpdated: [], - envKeysAdded: [], - envKeysRequired: [], - })), - setupConnectionConnector: vi.fn(async () => - Object.freeze({ kind: "existing", connectorUid: "mcp.linear.app/linear" }), - ), - listAuthoredConnections: vi.fn(async () => []), - readProjectLink: vi.fn(async () => ({ orgId: "org_1", projectId: "prj_1" })), - cleanupCreatedConnectionConnector: vi.fn(async () => {}), - }; -} - -function runConnectionFlow( - list: ReturnType, - deps: Partial = {}, - disabledConnectionReasons: Readonly> = {}, -) { - const defaults: ConnectionsFlowDeps = { - detectDeployment: vi.fn(async () => LINKED), - detectPackageManager: vi.fn(async () => Object.freeze({ kind: "pnpm", source: "default" })), - ensureConnectionDependencies: vi.fn(async () => []), - getVercelAuthStatus: vi.fn(() => Promise.resolve<"authenticated">("authenticated")), - listAuthoredConnections: vi.fn(async () => []), - runLinkFlow: vi.fn(async () => Object.freeze({ kind: "done" })), - runPackageManagerInstall: vi.fn(async () => true), - addConnections: addConnectionDeps(), - }; - return runConnectionsFlow({ - appRoot: APP_ROOT, - prompter: createFakePrompter({ single: list.single }).prompter, - disabledConnectionReasons, - deps: { ...defaults, ...deps }, - }); -} - -describe("runConnectionsFlow", () => { - it("returns a terminal success after adding a catalog connection", async () => { - const listAuthoredConnections = vi - .fn(async () => [] as string[]) - .mockResolvedValueOnce([]) - .mockResolvedValue(["linear"]); - const list = scriptConnectionList(["linear"]); - - await expect(runConnectionFlow(list, { listAuthoredConnections })).resolves.toEqual({ - kind: "done", - addedConnections: ["linear"], - }); - - expect(list.requests[0]).toMatchObject({ - hintLayout: "inline", - search: true, - placeholder: "type to search MCP servers", - }); - expect(list.requests[0]?.options.map((row) => row.value)).toEqual([ - "linear", - "notion", - "datadog", - "honeycomb", - "done", - ]); - expect(list.requests).toHaveLength(1); - }); - - it("defaults to Done when every catalog connection is already authored", async () => { - const list = scriptConnectionList(["done"]); - await runConnectionFlow(list, { - listAuthoredConnections: vi.fn(async () => ["linear", "notion", "datadog", "honeycomb"]), - }); - - expect(list.requests[0]?.initialValue).toBe("done"); - }); - - it("blocks logged-out rows", async () => { - const loggedOutList = scriptConnectionList(["cancel"]); - await expect( - runConnectionFlow(loggedOutList, { - detectDeployment: vi.fn(() => Promise.resolve({ state: "unlinked" })), - getVercelAuthStatus: vi.fn(async (): Promise<"logged-out"> => "logged-out"), - }), - ).resolves.toEqual({ kind: "cancelled" }); - expect(loggedOutList.requests[0]?.options.find((row) => row.value === "linear")).toMatchObject({ - disabled: true, - disabledReason: "Log in to Vercel first, see /vc:login", - }); - }); - - it("disables a connection whose MCP endpoint is unreachable", async () => { - const list = scriptConnectionList(["cancel"]); - await expect( - runConnectionFlow( - list, - {}, - { - notion: "https://mcp.notion.com/mcp is not reachable (HTTP 404).", - }, - ), - ).resolves.toEqual({ kind: "cancelled" }); - - expect(list.requests[0]?.options.find((row) => row.value === "notion")).toMatchObject({ - disabled: true, - disabledReasonTone: "warning", - disabledReason: "https://mcp.notion.com/mcp is not reachable (HTTP 404).", - }); - }); - - it("runs the shared create-or-link flow before configuring an unlinked project", async () => { - const detectDeployment = vi - .fn() - .mockResolvedValueOnce({ state: "unlinked" }) - .mockResolvedValueOnce(LINKED); - const runLinkFlow = vi.fn(async () => ({ kind: "done" })); - const listAuthoredConnections = vi - .fn(async () => [] as string[]) - .mockResolvedValueOnce([]) - .mockResolvedValue(["linear"]); - const list = scriptConnectionList(["linear"]); - await expect( - runConnectionFlow(list, { - detectDeployment, - listAuthoredConnections, - runLinkFlow, - }), - ).resolves.toEqual({ kind: "done", addedConnections: ["linear"] }); - - const linkInput = runLinkFlow.mock.calls[0]?.[0]; - expect(linkInput?.projectSelection).toBe("create-or-link"); - expect(linkInput?.teamSelectMessage?.("Acme")).toBe( - "You need to link to a project to use Vercel Connect.\n\nSelect your team", - ); - }); - - it("returns a terminal cancellation when project linking is cancelled", async () => { - const runLinkFlow = vi.fn(async () => Object.freeze({ kind: "cancelled" })); - const list = scriptConnectionList(["linear"]); - - await expect( - runConnectionFlow(list, { - detectDeployment: vi.fn(() => Promise.resolve({ state: "unlinked" })), - runLinkFlow, - }), - ).resolves.toEqual({ kind: "cancelled" }); - expect(list.requests).toHaveLength(1); - }); - - it("returns a terminal failure when connector setup fails", async () => { - const list = scriptConnectionList(["linear"]); - const addConnections = addConnectionDeps(); - vi.mocked(addConnections.setupConnectionConnector).mockRejectedValueOnce( - new Error("Could not create the mcp.linear.app connector."), - ); - - await expect(runConnectionFlow(list, { addConnections })).resolves.toEqual({ - kind: "failed", - addedConnections: [], - message: "Could not create the mcp.linear.app connector.", - }); - expect(list.requests).toHaveLength(1); - }); - - it("installs dependencies before provisioning a connector", async () => { - const list = scriptConnectionList(["linear"]); - const addConnections = addConnectionDeps(); - const runPackageManagerInstall = vi.fn(async () => false); - - await expect( - runConnectionFlow(list, { addConnections, runPackageManagerInstall }), - ).resolves.toEqual({ - kind: "failed", - addedConnections: [], - message: "Dependency installation failed. Run `pnpm install`.", - }); - - expect(addConnections.setupConnectionConnector).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/eve/src/setup/flows/connections.ts b/packages/eve/src/setup/flows/connections.ts deleted file mode 100644 index 70765329c..000000000 --- a/packages/eve/src/setup/flows/connections.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { - CONNECTION_CATALOG, - ensureConnectionDependencies, - listAuthoredConnections, -} from "#setup/scaffold/index.js"; -import { createPromptCommandOutput, withPhase } from "#setup/cli/index.js"; -import { detectPackageManager } from "#setup/package-manager.js"; -import { runPackageManagerInstall } from "#setup/primitives/pm/run.js"; -import { toErrorMessage } from "#shared/errors.js"; - -import { interactiveAsker } from "../ask.js"; -import { addConnections, type AddConnectionsDeps } from "../boxes/add-connections.js"; -import { selectConnections } from "../boxes/select-connections.js"; -import { - detectDeployment, - isProjectResolved, - projectResolutionFromDeployment, -} from "../project-resolution.js"; -import type { Prompter, SelectOption, SingleSelectOptions } from "../prompter.js"; -import { runInteractive, type AnySetupBox } from "../runner.js"; -import { snapshotSetupState, type SetupState } from "../state.js"; -import { WizardCancelledError } from "../step.js"; -import { - getVercelAuthStatus, - vercelAuthBlockerReason, - type VercelAuthStatus, -} from "../vercel-project.js"; -import { withSpinner } from "../with-spinner.js"; - -import { inProjectSetupState, prompterSink } from "./in-project.js"; -import { runLinkFlow } from "./link.js"; - -export const CONNECTIONS_PROMPT_MESSAGE = - "Select an MCP server to add to your agent through Vercel Connect"; - -const CONNECT_CONNECTIONS = CONNECTION_CATALOG.filter((entry) => entry.auth.kind === "connect"); - -export interface ConnectionsFlowDeps { - detectDeployment: typeof detectDeployment; - detectPackageManager: typeof detectPackageManager; - getVercelAuthStatus: typeof getVercelAuthStatus; - runLinkFlow: typeof runLinkFlow; - ensureConnectionDependencies: typeof ensureConnectionDependencies; - listAuthoredConnections: typeof listAuthoredConnections; - runPackageManagerInstall: typeof runPackageManagerInstall; - addConnections?: AddConnectionsDeps; -} - -export type ConnectionsFlowResult = - | { kind: "done"; addedConnections: readonly string[] } - | { kind: "cancelled" } - | { kind: "failed"; addedConnections: readonly string[]; message: string }; - -function connectionRows( - authored: ReadonlySet, - authStatus: VercelAuthStatus, - disabledConnectionReasons: Readonly>, -): SelectOption[] { - const blocker = vercelAuthBlockerReason(authStatus); - const rows: SelectOption[] = CONNECT_CONNECTIONS.map((entry) => { - const row = { value: entry.slug, label: entry.label }; - if (authored.has(entry.slug)) { - return { ...row, completed: true, focusHint: "Already added" }; - } - const disabledConnectionReason = disabledConnectionReasons[entry.slug]; - if (disabledConnectionReason !== undefined) { - return { - ...row, - disabled: true, - disabledReason: disabledConnectionReason, - disabledReasonTone: "warning", - }; - } - if (blocker !== undefined) { - return { - ...row, - disabled: true, - disabledReason: blocker, - disabledReasonTone: "warning", - }; - } - return { ...row, hint: entry.hint }; - }); - rows.push({ value: "done", label: "Done", trailingAction: true }); - return rows; -} - -async function pickConnection( - prompter: Prompter, - authored: ReadonlySet, - authStatus: VercelAuthStatus, - disabledConnectionReasons: Readonly>, -): Promise { - const options = connectionRows(authored, authStatus, disabledConnectionReasons); - const request: SingleSelectOptions = { - message: CONNECTIONS_PROMPT_MESSAGE, - options, - hintLayout: "inline", - search: true, - placeholder: "type to search MCP servers", - }; - if ( - !options.some( - (option) => option.value !== "done" && option.disabled !== true && option.completed !== true, - ) - ) { - request.initialValue = "done"; - } - try { - return await prompter.select(request); - } catch (error) { - if (error instanceof WizardCancelledError) return undefined; - throw error; - } -} - -/** Runs `/connect`, linking a project on first selection when needed. */ -export async function runConnectionsFlow(input: { - appRoot: string; - prompter: Prompter; - signal?: AbortSignal; - disabledConnectionReasons?: Readonly>; - deps?: Partial; -}): Promise { - const { appRoot, prompter, signal } = input; - const deps: ConnectionsFlowDeps = { - detectDeployment, - detectPackageManager, - ensureConnectionDependencies, - getVercelAuthStatus, - listAuthoredConnections, - runLinkFlow, - runPackageManagerInstall, - ...input.deps, - }; - const [deployment, initialAuthored, authStatus] = await withSpinner( - prompter, - "Checking the project…", - () => - Promise.all([ - deps.detectDeployment(appRoot, { signal }), - deps.listAuthoredConnections(appRoot), - deps.getVercelAuthStatus(appRoot, { signal }), - ]), - ); - signal?.throwIfAborted(); - - let state = inProjectSetupState(appRoot, projectResolutionFromDeployment(deployment)); - const authored = new Set(initialAuthored); - const selected = await pickConnection( - prompter, - authored, - authStatus, - input.disabledConnectionReasons ?? {}, - ); - if (selected === undefined) return { kind: "cancelled" }; - if (selected === "done" || authored.has(selected)) { - return { kind: "done", addedConnections: [] }; - } - - try { - if (!isProjectResolved(state.project)) { - const link = await deps.runLinkFlow({ - appRoot, - prompter, - signal, - projectSelection: "create-or-link", - teamSelectMessage: () => - "You need to link to a project to use Vercel Connect.\n\nSelect your team", - }); - if (link.kind === "cancelled") return { kind: "cancelled" }; - - const deploymentAfterLink = await withSpinner(prompter, "Checking the project…", () => - deps.detectDeployment(appRoot, { signal }), - ); - const project = projectResolutionFromDeployment(deploymentAfterLink); - if (!isProjectResolved(project)) throw new Error("Project link was not found after linking."); - state = { ...state, project }; - } - - const packageManager = await deps.detectPackageManager(appRoot); - await deps.ensureConnectionDependencies({ projectRoot: appRoot }); - const installed = await withPhase( - prompter.log, - `Installing connection dependencies (${packageManager.kind} install)...`, - () => - deps.runPackageManagerInstall(packageManager.kind, appRoot, { - onOutput: createPromptCommandOutput(prompter.log), - signal, - }), - ); - if (!installed) { - throw new Error(`Dependency installation failed. Run \`${packageManager.kind} install\`.`); - } - - const boxes: AnySetupBox[] = [ - selectConnections({ asker: interactiveAsker(prompter), presetConnections: [selected] }), - addConnections({ prompter, signal, deps: deps.addConnections }), - ]; - - const result = await runInteractive(boxes, state, prompterSink(prompter), { - snapshot: snapshotSetupState, - signal, - }); - if (result.kind !== "done") return { kind: "cancelled" }; - - const updatedAuthored = new Set(await deps.listAuthoredConnections(appRoot)); - if (!updatedAuthored.has(selected)) { - throw new Error(`Connection "${selected}" was not added.`); - } - return { kind: "done", addedConnections: [selected] }; - } catch (error) { - if (error instanceof WizardCancelledError) return { kind: "cancelled" }; - const updatedAuthored = new Set(await deps.listAuthoredConnections(appRoot)); - return { - kind: "failed", - addedConnections: updatedAuthored.has(selected) ? [selected] : [], - message: toErrorMessage(error), - }; - } -} From cabab8cddaabcfd5ff659694590b9a0205c37dca Mon Sep 17 00:00:00 2001 From: owenkephart Date: Wed, 29 Jul 2026 21:09:13 +0000 Subject: [PATCH 4/5] refactor(eve): share interactive Vercel linking Signed-off-by: owenkephart --- .../cli/commands/integration-setup.test.ts | 5 +- .../setup/integrations/channels/index.test.ts | 3 +- .../setup/integrations/channels/setup.test.ts | 24 +++--- .../src/setup/integrations/channels/setup.ts | 86 ++++--------------- packages/eve/src/setup/vercel-project.test.ts | 42 +++++++++ packages/eve/src/setup/vercel-project.ts | 31 +++++++ 6 files changed, 104 insertions(+), 87 deletions(-) diff --git a/packages/eve/src/cli/commands/integration-setup.test.ts b/packages/eve/src/cli/commands/integration-setup.test.ts index 82b4a0e77..9ba0c9684 100644 --- a/packages/eve/src/cli/commands/integration-setup.test.ts +++ b/packages/eve/src/cli/commands/integration-setup.test.ts @@ -36,10 +36,7 @@ function addChannelsDeps(): AddChannelsDeps { source: "default", })), runPackageManagerInstall: vi.fn(async () => true), - runVercel: vi.fn(async () => true), - detectDeployment: vi.fn(async () => ({ - state: "unlinked", - })), + ensureVercelProject: vi.fn(async () => ({ orgId: "team-id", projectId: "project-id" })), }; } diff --git a/packages/eve/src/setup/integrations/channels/index.test.ts b/packages/eve/src/setup/integrations/channels/index.test.ts index 5e84be3cf..aea89318e 100644 --- a/packages/eve/src/setup/integrations/channels/index.test.ts +++ b/packages/eve/src/setup/integrations/channels/index.test.ts @@ -61,8 +61,7 @@ describe("channel setup integrations", () => { source: "default", })), runPackageManagerInstall: vi.fn(), - runVercel: vi.fn(), - detectDeployment: vi.fn(), + ensureVercelProject: vi.fn(), }, }); diff --git a/packages/eve/src/setup/integrations/channels/setup.test.ts b/packages/eve/src/setup/integrations/channels/setup.test.ts index f5634f0b8..99a3acce5 100644 --- a/packages/eve/src/setup/integrations/channels/setup.test.ts +++ b/packages/eve/src/setup/integrations/channels/setup.test.ts @@ -91,9 +91,8 @@ function createDeps() { source: "default", })), runPackageManagerInstall: vi.fn(async () => true), - runVercel: vi.fn(async () => true), - detectDeployment: vi.fn(async () => ({ - state: "linked", + ensureVercelProject: vi.fn(async () => ({ + orgId: "team_demo", projectId: "prj_demo", })), }; @@ -161,7 +160,7 @@ describe("addChannels box", () => { ); expect(deps.provisionSlackbot).not.toHaveBeenCalled(); - expect(deps.runVercel).not.toHaveBeenCalled(); + expect(deps.ensureVercelProject).not.toHaveBeenCalled(); expect(deps.runPackageManagerInstall).not.toHaveBeenCalled(); expect(deps.ensureChannel).toHaveBeenCalledWith({ projectRoot: "/tmp/project", @@ -358,7 +357,6 @@ describe("addChannels box", () => { state.project = { kind: "unresolved" }; state.vercelProject = { kind: "none" }; const prompter = createPrompter(); - prompter.withInheritedStdio = vi.fn((task) => task()); const box = makeBox({ prompter, presetCreateSlackbot: true, @@ -368,14 +366,14 @@ describe("addChannels box", () => { const result = await runInteractive([box], state, silentSink, snapshot); - // The engine's exact fallback: a bare interactive `vercel link` with NO - // onOutput, then a fresh deployment detection. - expect(prompter.withInheritedStdio).toHaveBeenCalledOnce(); - expect(deps.runVercel).toHaveBeenCalledWith(["link"], { cwd: "/tmp/project" }); - expect(deps.runVercel.mock.invocationCallOrder[0]).toBeLessThan( + expect(deps.ensureVercelProject).toHaveBeenCalledWith({ + appRoot: "/tmp/project", + prompter, + signal: undefined, + }); + expect(deps.ensureVercelProject.mock.invocationCallOrder[0]).toBeLessThan( deps.provisionSlackbot.mock.invocationCallOrder[0]!, ); - expect(deps.detectDeployment).toHaveBeenCalledWith("/tmp/project", { signal: undefined }); expect(result.kind).toBe("done"); if (result.kind === "done") { expect(result.state.project).toEqual({ kind: "linked", projectId: "prj_demo" }); @@ -385,7 +383,7 @@ describe("addChannels box", () => { it("fails the link fallback with the engine's copy when `vercel link` fails", async () => { const deps = createDeps(); - deps.runVercel.mockResolvedValue(false); + deps.ensureVercelProject.mockRejectedValue(new Error("Vercel project linking failed.")); const state = resolvedState(["slack"]); state.project = { kind: "unresolved" }; state.vercelProject = { kind: "none" }; @@ -397,7 +395,7 @@ describe("addChannels box", () => { }); await expect(runInteractive([box], state, silentSink, snapshot)).rejects.toThrow( - "Vercel project linking failed. Slackbot creation did not start.", + "Vercel project linking failed.", ); expect(deps.provisionSlackbot).not.toHaveBeenCalled(); }); diff --git a/packages/eve/src/setup/integrations/channels/setup.ts b/packages/eve/src/setup/integrations/channels/setup.ts index 9bf1ae882..ea7c516ee 100644 --- a/packages/eve/src/setup/integrations/channels/setup.ts +++ b/packages/eve/src/setup/integrations/channels/setup.ts @@ -12,13 +12,9 @@ import { detectPackageManager, type PackageManagerKind } from "#setup/package-ma import { formatNodeEngineOverrideWarning } from "#setup/node-engine.js"; import { runPackageManagerInstall } from "#setup/primitives/pm/run.js"; import { ensureVercelProject } from "#setup/flows/ensure-vercel-project.js"; -import { runVercel } from "#setup/primitives/run-vercel.js"; - import { - detectDeployment, isProjectResolved, mergeProjectResolution, - projectResolutionFromDeployment, type ProjectResolution, } from "../../project-resolution.js"; import type { Asker } from "../../ask.js"; @@ -140,10 +136,7 @@ export interface AddChannelsDeps { reconcileSlackUid: typeof reconcileSlackUid; detectPackageManager: typeof detectPackageManager; runPackageManagerInstall: typeof runPackageManagerInstall; - /** Parent-rendered project flow; legacy test seams may omit it. */ - ensureVercelProject?: typeof ensureVercelProject; - runVercel: typeof runVercel; - detectDeployment: typeof detectDeployment; + ensureVercelProject: typeof ensureVercelProject; } export interface AddChannelsOptions { @@ -180,9 +173,8 @@ export interface AddChannelsOptions { configureVercelServices?: boolean; /** * Opt-in fallback when Slack is chosen interactively but `state.project` is - * unresolved: run the interactive bare `vercel link` before provisioning the - * slackbot. The Slack integration sets this so Vercel Connect setup can link - * an unlinked project before provisioning. + * unresolved: link the project before provisioning the slackbot. The Slack + * integration sets this so Vercel Connect setup can link an unlinked project. */ ensureLinkedProject?: "interactive-vercel-link"; /** @@ -273,8 +265,6 @@ export function addChannels( detectPackageManager, runPackageManagerInstall, ensureVercelProject, - runVercel, - detectDeployment, }; async function scaffoldSlackChannel( @@ -310,55 +300,6 @@ export function addChannels( return wroteExactConnectorUid; } - /** - * The {@link AddChannelsOptions.ensureLinkedProject} fallback: link the - * directory interactively, then re-detect the on-disk resolution. The copy - * and command shape are the dissolved engine's, byte for byte. - */ - async function linkProjectForSlackbot( - log: ChannelSetupLog, - projectPath: string, - current: ProjectResolution, - headless: boolean, - signal?: AbortSignal, - ): Promise { - if (headless) { - throw new HumanActionRequiredError({ - kind: "vercel-link", - command: "vercel link", - reason: "Slackbot creation needs this directory linked to a Vercel project.", - }); - } - log.message("Linking this directory to a Vercel project..."); - let project: ProjectResolution; - if (deps.ensureVercelProject !== undefined) { - const linked = await deps.ensureVercelProject({ - appRoot: projectPath, - prompter: options.prompter, - signal, - }); - project = mergeProjectResolution(current, { kind: "linked", projectId: linked.projectId }); - } else if (deps.runVercel !== runVercel) { - const link = () => deps.runVercel(["link"], { cwd: projectPath, signal }); - const linked = await (options.prompter.withInheritedStdio?.(link) ?? link()); - if (!linked) - throw new Error("Vercel project linking failed. Slackbot creation did not start."); - const deployment = await deps.detectDeployment(projectPath, { signal }); - project = mergeProjectResolution(current, projectResolutionFromDeployment(deployment)); - } else { - const linked = await ensureVercelProject({ - appRoot: projectPath, - prompter: options.prompter, - signal, - }); - project = mergeProjectResolution(current, { kind: "linked", projectId: linked.projectId }); - } - if (!isProjectResolved(project)) { - throw new Error("Vercel project linking failed. Slackbot creation did not start."); - } - return project; - } - async function addWebChannelToPayload( log: ChannelSetupLog, state: Readonly, @@ -517,13 +458,22 @@ export function addChannels( if (!isProjectResolved(payload.project)) { // Only reachable with the ensureLinkedProject seam; without it the gate // above already required a resolved project. - payload.project = await linkProjectForSlackbot( - log, - projectPath, - payload.project, - input.headless, + if (input.headless) { + throw new HumanActionRequiredError({ + kind: "vercel-link", + command: "vercel link", + reason: "Slackbot creation needs this directory linked to a Vercel project.", + }); + } + const linked = await deps.ensureVercelProject({ + appRoot: projectPath, + prompter: options.prompter, signal, - ); + }); + payload.project = mergeProjectResolution(payload.project, { + kind: "linked", + projectId: linked.projectId, + }); } const slackbot = await provisionSlackbotWithControls(log, projectPath, slug, signal); diff --git a/packages/eve/src/setup/vercel-project.test.ts b/packages/eve/src/setup/vercel-project.test.ts index 2319627e3..75981b71a 100644 --- a/packages/eve/src/setup/vercel-project.test.ts +++ b/packages/eve/src/setup/vercel-project.test.ts @@ -9,6 +9,7 @@ import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import { readProjectLink } from "./project-resolution.js"; import { assertNewProjectNameAvailable, + ensureLinkedVercelProject, getVercelAuthStatus, linkProject, pickNewProjectName, @@ -570,6 +571,47 @@ describe("resolveProjectByNameOrId", () => { }); }); +describe("ensureLinkedVercelProject", () => { + it("returns the existing project link without invoking the CLI", async () => { + mockedReadProjectLink.mockResolvedValue({ orgId: "team_a", projectId: "prj_a" }); + const { prompter } = createFakePrompter(); + + await expect( + ensureLinkedVercelProject({ projectRoot: "/tmp/eve-agent", prompter }), + ).resolves.toEqual({ orgId: "team_a", projectId: "prj_a" }); + + expect(mockedRunVercel).not.toHaveBeenCalled(); + }); + + it("links interactively and reads the resulting project link", async () => { + mockedReadProjectLink + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ orgId: "team_a", projectId: "prj_a" }); + const { prompter } = createFakePrompter(); + prompter.withInheritedStdio = vi.fn((task) => task()); + + await expect( + ensureLinkedVercelProject({ projectRoot: "/tmp/eve-agent", prompter }), + ).resolves.toEqual({ orgId: "team_a", projectId: "prj_a" }); + + expect(prompter.withInheritedStdio).toHaveBeenCalledOnce(); + expect(mockedRunVercel).toHaveBeenCalledWith(["link"], { + cwd: "/tmp/eve-agent", + signal: undefined, + }); + }); + + it("fails when the interactive link does not complete", async () => { + mockedReadProjectLink.mockResolvedValue(undefined); + mockedRunVercel.mockResolvedValue(false); + const { prompter } = createFakePrompter(); + + await expect( + ensureLinkedVercelProject({ projectRoot: "/tmp/eve-agent", prompter }), + ).rejects.toThrow("Vercel project linking failed."); + }); +}); + describe("linkProject", () => { it("links a resolved existing project through `vercel link`", async () => { const { prompter } = createFakePrompter(); diff --git a/packages/eve/src/setup/vercel-project.ts b/packages/eve/src/setup/vercel-project.ts index c075d8441..178512920 100644 --- a/packages/eve/src/setup/vercel-project.ts +++ b/packages/eve/src/setup/vercel-project.ts @@ -56,6 +56,37 @@ export interface PickTeamOptions extends VercelProjectOperationOptions { export interface LinkProjectOperationOptions extends CreatedProjectFrameworkOptions {} +/** Effects used to ensure an interactive Vercel project link. */ +export interface EnsureLinkedVercelProjectDeps { + readProjectLink: typeof readProjectLink; + runVercel: typeof runVercel; +} + +/** + * Returns the existing Vercel project link or creates one through the Vercel + * CLI's interactive flow. The CLI owns team and project selection. + */ +export async function ensureLinkedVercelProject(input: { + projectRoot: string; + prompter: Prompter; + signal?: AbortSignal; + deps?: EnsureLinkedVercelProjectDeps; +}): Promise>>> { + const deps = input.deps ?? { readProjectLink, runVercel }; + const existing = await deps.readProjectLink(input.projectRoot); + if (existing !== undefined) return existing; + + const link = () => deps.runVercel(["link"], { cwd: input.projectRoot, signal: input.signal }); + const linked = await (input.prompter.withInheritedStdio?.(link) ?? link()); + if (!linked) { + input.signal?.throwIfAborted(); + throw new Error("Vercel project linking failed."); + } + const project = await deps.readProjectLink(input.projectRoot); + if (project === undefined) throw new Error("Vercel project linking failed."); + return project; +} + export function unresolvedProject(): ProjectResolution { return { kind: "unresolved" }; } From 5179a8ca8a4cf9585b718bd4b7cb02e8808431a8 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Wed, 29 Jul 2026 21:41:51 +0000 Subject: [PATCH 5/5] fix(eve): prompt for channel credentials Signed-off-by: owenkephart --- .../eve/src/cli/dev/tui/agent-header.test.ts | 1 + .../src/cli/dev/tui/terminal-renderer.test.ts | 22 +++++++++++++++++++ .../eve/src/cli/dev/tui/terminal-renderer.ts | 7 ++++++ .../integrations/channels/environment.test.ts | 4 ++-- .../integrations/channels/environment.ts | 6 ++--- .../src/setup/integrations/channels/slack.ts | 6 ++++- .../tui-client/tui-packed-install-model.ts | 4 ++++ 7 files changed, 44 insertions(+), 6 deletions(-) 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 e41d55653..aedc41819 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.test.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentInfoResult, AgentInfoToolEntry } from "#client/index.js"; +import { stripAnsi } from "#cli/ui/terminal-text.js"; import { AGENT_HEADER_TIPS, buildAgentHeader, pickAgentHeaderTip } from "./agent-header.js"; import { createTheme } from "./theme.js"; 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 c37df078a..4bbfdb418 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -3035,6 +3035,28 @@ describe("TerminalRenderer (inline scrollback)", () => { expect(screen.snapshot()).toContain("tui/setup-panel.ts changed · rebuilt"); }); + it("updates the rebuild status before the watcher writes a newline", () => { + const screen = new MockScreen({ columns: 100, rows: 30 }); + const input = new MockUserInput(); + const renderer = new TerminalRenderer({ + input, + output: screen, + captureForeignOutput: true, + logs: "all", + unicode: true, + }); + renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" }); + + process.stdout.write( + formatChangeDetectedLogLine("/app", [{ event: "change", path: "/app/package.json" }]), + ); + + const snapshot = screen.snapshot(); + expect(snapshot).toContain("package.json changed · rebuilding…"); + expect(snapshot).not.toContain("[eve:dev] change detected"); + renderer.shutdown(); + }); + it("flips the status row to reloading on a structural change", () => { const screen = new MockScreen({ columns: 100, rows: 30 }); const input = new MockUserInput(); diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 78b14e6b1..1ba275e21 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -4121,6 +4121,13 @@ export class TerminalRenderer implements AgentTUIRenderer { #handleForeignOutput(source: "stdout" | "stderr", text: string): void { const combined = (source === "stdout" ? this.#stdoutLogBuffer : this.#stderrLogBuffer) + text; + if (source === "stdout" && parseDevRebuildLogLine(combined.trimEnd()) !== undefined) { + this.#stdoutLogBuffer = ""; + this.#diagnostics?.append({ source, detail: combined.trimEnd() }); + this.#handleCapturedStdout(combined.trimEnd()); + this.#paint(); + return; + } const lastNewline = combined.lastIndexOf("\n"); const remainder = lastNewline === -1 ? combined : combined.slice(lastNewline + 1); diff --git a/packages/eve/src/setup/integrations/channels/environment.test.ts b/packages/eve/src/setup/integrations/channels/environment.test.ts index 58a0f5912..77ea05bbd 100644 --- a/packages/eve/src/setup/integrations/channels/environment.test.ts +++ b/packages/eve/src/setup/integrations/channels/environment.test.ts @@ -13,11 +13,11 @@ describe("channel setup environment", () => { ); }); - it("reports the portable fallback when logged out", () => { + it("reports the credential choice when logged out", () => { const environment = channelSetupEnvironment("logged-out", { kind: "unresolved" }); expect(environment).toEqual({ vercel: { kind: "unavailable", reason: "logged-out" } }); expect(describeChannelSetupEnvironment(environment)).toBe( - "No authenticated Vercel account found; using portable channel setup.", + "No authenticated Vercel account found; choose Vercel Connect or portable credentials.", ); }); }); diff --git a/packages/eve/src/setup/integrations/channels/environment.ts b/packages/eve/src/setup/integrations/channels/environment.ts index 536a044cc..3f887302c 100644 --- a/packages/eve/src/setup/integrations/channels/environment.ts +++ b/packages/eve/src/setup/integrations/channels/environment.ts @@ -22,11 +22,11 @@ export function describeChannelSetupEnvironment(environment: ChannelSetupEnviron } switch (environment.vercel.reason) { case "logged-out": - return "No authenticated Vercel account found; using portable channel setup."; + return "No authenticated Vercel account found; choose Vercel Connect or portable credentials."; case "cli-missing": - return "Vercel CLI not found; using portable channel setup."; + return "Vercel CLI not found; choose Vercel Connect or portable credentials."; case "unavailable": - return "Could not verify the Vercel account; using portable channel setup."; + return "Could not verify the Vercel account; choose Vercel Connect or portable credentials."; } } diff --git a/packages/eve/src/setup/integrations/channels/slack.ts b/packages/eve/src/setup/integrations/channels/slack.ts index ae3f33c9a..564d73036 100644 --- a/packages/eve/src/setup/integrations/channels/slack.ts +++ b/packages/eve/src/setup/integrations/channels/slack.ts @@ -5,7 +5,6 @@ import { WizardCancelledError } from "../../step.js"; async function choosePortableCredentials( context: Parameters[0], ): Promise<"vercel-connect" | "environment" | "cancelled"> { - if (context.environment.vercel.kind === "available") return "vercel-connect"; if (context.presetPortableCredentials !== undefined) { return context.presetPortableCredentials ? "environment" : "vercel-connect"; } @@ -41,6 +40,11 @@ export const SLACK_CHANNEL_SETUP: ChannelSetupIntegration = { async setup(context) { const credentials = await choosePortableCredentials(context); if (credentials === "cancelled") return { kind: "cancelled" }; + if (credentials === "vercel-connect" && context.environment.vercel.kind === "unavailable") { + throw new Error( + "Vercel Connect requires an authenticated Vercel CLI. Run `vercel login`, then retry Slack setup.", + ); + } const result = await runChannelSetup( context, diff --git a/packages/eve/test/tui-client/tui-packed-install-model.ts b/packages/eve/test/tui-client/tui-packed-install-model.ts index 3a9ef906f..c0917d0a6 100644 --- a/packages/eve/test/tui-client/tui-packed-install-model.ts +++ b/packages/eve/test/tui-client/tui-packed-install-model.ts @@ -147,6 +147,10 @@ void (async () => { // Fresh-model onboarding now follows the picker with the registry hub. // Dismiss it too before asserting that the runner returns to chat. await screen.waitForText("Add to your agent", 5_000); + // The title paints before registry loading necessarily yields to the + // category picker. Wait for a real option so Escape belongs to the + // question rather than the setup flow's between-questions interrupt trap. + await screen.waitForText("Chat channels", 5_000); input.send("\x1b"); await screen.waitForText("/add dismissed.", 5_000); await screen.waitForIdlePrompt(5_000);