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 083e575fe..a1cdb0430 100644 --- a/packages/eve/src/cli/commands/init.integration.test.ts +++ b/packages/eve/src/cli/commands/init.integration.test.ts @@ -931,7 +931,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 bc6af9520..d3af18b25 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 2aec09835..9cf6b8735 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -204,21 +204,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 c7409da0a..847212506 100644 --- a/packages/eve/src/setup/scaffold/create/project.ts +++ b/packages/eve/src/setup/scaffold/create/project.ts @@ -365,7 +365,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); - } -}