From 483f67db6b227b1fade9cb362da88836248078a2 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:50:25 +0000 Subject: [PATCH 1/4] feat(eve): add guided Photon setup Signed-off-by: owenkephart Co-Authored-By: owenkephart --- .changeset/bright-messages-connect.md | 6 + apps/docs/lib/integrations/data.ts | 69 +--- apps/docs/lib/integrations/discovery.test.ts | 2 +- apps/docs/public/r/channel/photon.json | 17 + apps/docs/registry.json | 24 +- .../docs/registry/channels/chat-sdk-photon.ts | 27 -- .../docs/scripts/validate-channel-registry.ts | 22 +- docs/channels/overview.mdx | 4 + docs/guides/dev-tui.md | 19 + packages/eve-catalog/src/index.ts | 10 +- .../cli/commands/integration-setup.test.ts | 93 +++-- .../eve/src/cli/commands/integration-setup.ts | 44 ++- .../commands/register-integration-commands.ts | 5 +- packages/eve/src/cli/dev/tui/agent-header.ts | 3 + .../src/cli/dev/tui/prompt-commands.test.ts | 8 + .../eve/src/cli/dev/tui/prompt-commands.ts | 12 + .../eve/src/cli/dev/tui/setup-commands.ts | 69 ++++ packages/eve/src/cli/run.ts | 6 + .../eve/src/setup/boxes/deploy-project.ts | 12 + packages/eve/src/setup/flows/in-project.ts | 4 + .../integrations/channels/environment.ts | 12 +- .../eve/src/setup/integrations/channels/ui.ts | 8 +- packages/eve/src/setup/photon-connect.test.ts | 109 ++++++ packages/eve/src/setup/photon-connect.ts | 173 +++++++++ .../eve/src/setup/photon-management.test.ts | 143 ++++++++ packages/eve/src/setup/photon-management.ts | 299 ++++++++++++++++ .../eve/src/setup/photon-setup-environment.ts | 41 +++ .../eve/src/setup/photon-setup-integration.ts | 34 ++ .../src/setup/photon-setup-integrations.ts | 9 + packages/eve/src/setup/photon-setup-ui.ts | 29 ++ packages/eve/src/setup/photon-setup.test.ts | 82 +++++ packages/eve/src/setup/photon-setup.ts | 328 ++++++++++++++++++ .../src/setup/primitives/run-vercel.test.ts | 48 +++ .../eve/src/setup/primitives/run-vercel.ts | 47 ++- packages/eve/src/setup/prompter.ts | 1 + .../src/setup/scaffold/channels-catalog.ts | 4 + pnpm-lock.yaml | 48 ++- pnpm-workspace.yaml | 2 +- 38 files changed, 1658 insertions(+), 215 deletions(-) create mode 100644 .changeset/bright-messages-connect.md create mode 100644 apps/docs/public/r/channel/photon.json delete mode 100644 apps/docs/registry/channels/chat-sdk-photon.ts create mode 100644 packages/eve/src/setup/photon-connect.test.ts create mode 100644 packages/eve/src/setup/photon-connect.ts create mode 100644 packages/eve/src/setup/photon-management.test.ts create mode 100644 packages/eve/src/setup/photon-management.ts create mode 100644 packages/eve/src/setup/photon-setup-environment.ts create mode 100644 packages/eve/src/setup/photon-setup-integration.ts create mode 100644 packages/eve/src/setup/photon-setup-integrations.ts create mode 100644 packages/eve/src/setup/photon-setup-ui.ts create mode 100644 packages/eve/src/setup/photon-setup.test.ts create mode 100644 packages/eve/src/setup/photon-setup.ts diff --git a/.changeset/bright-messages-connect.md b/.changeset/bright-messages-connect.md new file mode 100644 index 000000000..173001495 --- /dev/null +++ b/.changeset/bright-messages-connect.md @@ -0,0 +1,6 @@ +--- +"eve": patch +"@vercel/eve-catalog": patch +--- + +Add guided Photon setup through `eve add channel/photon`, including project creation, phone registration, Vercel Connect or portable credentials, and channel scaffolding. diff --git a/apps/docs/lib/integrations/data.ts b/apps/docs/lib/integrations/data.ts index 6bf6cc0ef..e8efda52e 100644 --- a/apps/docs/lib/integrations/data.ts +++ b/apps/docs/lib/integrations/data.ts @@ -125,10 +125,10 @@ const channelPresentations: Record = { logo: "slack", docsHref: "/docs/channels/slack", keywords: ["chat", "messaging", "bot", "webhook"], - install: `The eve CLI scaffolds the channel for you. \`eve channels add slack\` writes \`agent/channels/slack.ts\`, adds \`@vercel/connect\`, and runs the Connect setup flow: + install: `The eve CLI scaffolds the channel for you. \`eve add channel/slack\` writes \`agent/channels/slack.ts\`, adds \`@vercel/connect\`, and runs the Connect setup flow: \`\`\`bash -eve channels add slack +eve add channel/slack \`\`\` To wire it up by hand instead, install the framework and the Connect SDK. Slack channels use [Vercel Connect](https://vercel.com/docs/connect) for both the outbound bot token and inbound webhook verification: @@ -314,7 +314,7 @@ export default linearChannel({ install: `The eve CLI scaffolds the full Next.js web chat app alongside \`agent/channels/eve.ts\`: \`\`\`bash -eve channels add web +eve add channel/web \`\`\` To wire it up by hand instead, install the framework: @@ -842,64 +842,29 @@ export default channel; See the [Kapso adapter documentation](https://chat-sdk.dev/adapters/vendor-official/kapso) for supported events, capabilities, and credentials.`, configure: `Connect a WhatsApp number in Kapso, set \`KAPSO_API_KEY\`, \`KAPSO_PHONE_NUMBER_ID\`, and \`KAPSO_WEBHOOK_SECRET\`, then point the Kapso webhook at \`/eve/v1/kapso\`. Use this provider-managed option when you do not want to integrate directly with the WhatsApp Cloud API. See the [Chat SDK channel docs](/docs/channels/chat-sdk) for eve session dispatch, state, streaming, and human-in-the-loop behavior.`, }, - "chat-sdk-photon": { + photon: { logo: "photon", - docsHref: "/docs/channels/chat-sdk", - badge: "Provider official", - keywords: [ - "chat sdk", - "imessage", - "apple messages", - "sms", - "mms", - "rcs", - "photon", - "sendblue", - "linq", - "agentphone", - "dial", - ], - install: `Add this Chat SDK channel from eve's registry. This writes \`agent/channels/imessage.ts\` and installs Chat SDK and its adapter dependencies: + docsHref: "/docs/channels/photon", + badge: "First-party", + keywords: ["imessage", "apple messages", "photon", "sms", "phone"], + install: `Add Photon from eve's registry, then follow the guided project, phone, and deployment setup: \`\`\`bash -eve add channel/chat-sdk-photon +eve add channel/photon \`\`\``, - quickStart: `Create \`agent/channels/imessage.ts\`: + quickStart: `Create \`agent/channels/photon.ts\`: \`\`\`ts -// agent/channels/imessage.ts -import { createiMessageAdapter } from "@photon-ai/chat-adapter-imessage"; -import { createMemoryState } from "@chat-adapter/state-memory"; -import type { Message, Thread } from "chat"; -import { chatSdkChannel } from "eve/channels/chat-sdk"; +import { connectPhotonCredentials } from "@vercel/connect/eve"; +import { photonChannel } from "eve/channels/photon"; -export const { bot, channel, send } = chatSdkChannel({ - userName: "My Agent", - adapters: { - imessage: createiMessageAdapter({ - local: false, - projectId: process.env.IMESSAGE_PROJECT_ID, - projectSecret: process.env.IMESSAGE_PROJECT_SECRET, - }), - }, - state: createMemoryState(), +export default photonChannel({ + credentials: connectPhotonCredentials(process.env.PHOTON_CONNECTOR_ID!), }); - -bot.onNewMention(async (thread: Thread, message: Message) => { - await thread.subscribe(); - await send(message.text, { thread }); -}); - -bot.onSubscribedMessage(async (thread: Thread, message: Message) => { - await send(message.text, { thread }); -}); - -export default channel; -\`\`\` - -See the [Photon adapter documentation](https://chat-sdk.dev/adapters/vendor-official/photon) for all supported events and credentials.`, - configure: `Set \`IMESSAGE_PROJECT_ID\` and \`IMESSAGE_PROJECT_SECRET\`, then point Photon’s signed webhook at \`/eve/v1/imessage\`. Photon supports cloud, self-hosted, and local macOS deployments. See the [Chat SDK channel docs](/docs/channels/chat-sdk) for eve session dispatch, state, streaming, and human-in-the-loop behavior.`, +\`\`\``, + configure: `The guided setup can create a dedicated Photon project or use existing credentials, register your phone, and choose Vercel Connect or portable environment credentials. Connect-backed setup creates a native Photon connector and routes verified triggers to \`/eve/v1/photon\`; portable setup registers a signed Photon webhook directly.`, }, + "chat-sdk-dial": { logo: "dial", docsHref: "/docs/channels/chat-sdk", diff --git a/apps/docs/lib/integrations/discovery.test.ts b/apps/docs/lib/integrations/discovery.test.ts index 67352b55d..b771c232e 100644 --- a/apps/docs/lib/integrations/discovery.test.ts +++ b/apps/docs/lib/integrations/discovery.test.ts @@ -29,7 +29,7 @@ describe("integration discovery", () => { const markdown = integrationMarkdown(slack!); expect(markdown).toContain("## Install"); expect(markdown).toContain("## Quick start"); - expect(markdown).toContain("eve channels add slack"); + expect(markdown).toContain("eve add channel/slack"); }); it("renders the Browserbase extension setup", () => { diff --git a/apps/docs/public/r/channel/photon.json b/apps/docs/public/r/channel/photon.json new file mode 100644 index 000000000..1eacd6cfb --- /dev/null +++ b/apps/docs/public/r/channel/photon.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "channel/photon", + "type": "registry:item", + "title": "Photon", + "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", + "dependencies": ["@vercel/connect@0.4.3"], + "meta": { + "eve": { + "setup": { + "command": "eve", + "args": ["integration", "setup", "photon"] + }, + "requires": ">=0.27.11" + } + } +} diff --git a/apps/docs/registry.json b/apps/docs/registry.json index dda92c36c..c8d9ba71c 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -1209,22 +1209,20 @@ ] }, { - "name": "channel/chat-sdk-photon", + "name": "channel/photon", "type": "registry:item", "title": "Photon", - "description": "Cloud, self-hosted, and local iMessage messaging through Photon.", - "dependencies": ["chat", "@photon-ai/chat-adapter-imessage", "@chat-adapter/state-memory"], - "envVars": { - "IMESSAGE_PROJECT_ID": "", - "IMESSAGE_PROJECT_SECRET": "" - }, - "files": [ - { - "path": "registry/channels/chat-sdk-photon.ts", - "type": "registry:file", - "target": "agent/channels/imessage.ts" + "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", + "dependencies": ["@vercel/connect@0.4.3"], + "meta": { + "eve": { + "setup": { + "command": "eve", + "args": ["integration", "setup", "photon"] + }, + "requires": ">=0.27.11" } - ] + } }, { "name": "channel/chat-sdk-dial", diff --git a/apps/docs/registry/channels/chat-sdk-photon.ts b/apps/docs/registry/channels/chat-sdk-photon.ts deleted file mode 100644 index 83e239a9e..000000000 --- a/apps/docs/registry/channels/chat-sdk-photon.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createiMessageAdapter } from "@photon-ai/chat-adapter-imessage"; -import { createMemoryState } from "@chat-adapter/state-memory"; -import type { Message, Thread } from "chat"; -import { chatSdkChannel } from "eve/channels/chat-sdk"; - -export const { bot, channel, send } = chatSdkChannel({ - userName: "My Agent", - adapters: { - imessage: createiMessageAdapter({ - local: false, - projectId: process.env.IMESSAGE_PROJECT_ID, - projectSecret: process.env.IMESSAGE_PROJECT_SECRET, - }), - }, - state: createMemoryState(), -}); - -bot.onNewMention(async (thread: Thread, message: Message) => { - await thread.subscribe(); - await send(message.text, { thread }); -}); - -bot.onSubscribedMessage(async (thread: Thread, message: Message) => { - await send(message.text, { thread }); -}); - -export default channel; diff --git a/apps/docs/scripts/validate-channel-registry.ts b/apps/docs/scripts/validate-channel-registry.ts index 11c65402c..57799050d 100644 --- a/apps/docs/scripts/validate-channel-registry.ts +++ b/apps/docs/scripts/validate-channel-registry.ts @@ -15,7 +15,6 @@ interface RegistryItem { meta?: { eve?: { setup?: { - command?: string; package?: string; bin?: string; args?: string[]; @@ -44,7 +43,6 @@ const adapterDependenciesByCatalogSlug: Readonly> = { "chat-sdk-liveblocks": "@liveblocks/chat-sdk-adapter", "chat-sdk-linq": "@linqapp/chat-sdk-adapter", "chat-sdk-kapso": "@kapso/chat-adapter", - "chat-sdk-photon": "@photon-ai/chat-adapter-imessage", "chat-sdk-dial": "@getdial/chat-sdk-adapter", "chat-sdk-agentphone": "@agentphone/chat-sdk-adapter", "chat-sdk-lark": "@larksuite/vercel-chat-adapter", @@ -65,7 +63,6 @@ const targetSlugsByCatalogSlug: Readonly> = { "chat-sdk-liveblocks": "liveblocks", "chat-sdk-linq": "linq", "chat-sdk-kapso": "kapso", - "chat-sdk-photon": "imessage", "chat-sdk-dial": "dial", "chat-sdk-agentphone": "agentphone", "chat-sdk-lark": "lark", @@ -89,28 +86,15 @@ if (JSON.stringify(actualSlugs) !== JSON.stringify(expectedSlugs)) { } for (const [index, item] of items.entries()) { - const setup = item.meta?.eve?.setup; - if ( - setup !== undefined && - (setup.command === undefined || - setup.package === undefined || - setup.bin === undefined || - setup.args === undefined) - ) { - throw new Error( - `Registry item "${item.name}" setup must declare command, package, bin, and args during the migration.`, - ); - } - const entry = galleryEntries[index]; if (entry === undefined) throw new Error(`Unexpected channel registry item "${item.name}".`); const registrySlug = expectedSlugs[index]; - if (entry.slug === "slack" || entry.slug === "eve") { + if (entry.slug === "slack" || entry.slug === "eve" || entry.slug === "photon") { + const setup = item.meta?.eve?.setup; const expectedArgs = ["integration", "setup", registrySlug]; if ( - setup?.command !== "eve" || - setup.package !== "eve" || + setup?.package !== "eve" || setup.bin !== "eve" || JSON.stringify(setup.args) !== JSON.stringify(expectedArgs) ) { diff --git a/docs/channels/overview.mdx b/docs/channels/overview.mdx index 892ed8ea7..82056cd7c 100644 --- a/docs/channels/overview.mdx +++ b/docs/channels/overview.mdx @@ -28,7 +28,11 @@ agent/ intake.ts ``` +<<<<<<< HEAD Install a built-in channel with `eve add channel/slack` or `eve add channel/web`. You can also author the file by hand. +======= +Install a channel from the registry with `eve add channel/photon`, `eve add channel/slack`, or `eve add channel/web`. You can also author the file by hand. +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) ## The eve HTTP channel (default) diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index fe170df29..fb26b9f24 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -13,7 +13,10 @@ On startup the TUI prints a brand line with your agent's name, plus a rotating t ```text eve weather-agent +<<<<<<< HEAD Use /add to install integrations from the registry. +======= +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) ``` 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 +38,11 @@ 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). | +<<<<<<< HEAD | `/add` | Searches registry integrations or adds an item address entered directly. | +======= +| `/connect` | Shows the Vercel Connect MCP catalog and configures the server you pick. See [Add a connection](#add-a-connection). | +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) | `/deploy` | Ships the agent to Vercel production, linking the directory first when it is unlinked. | | `/vc:install` | Installs the Vercel CLI. Available locally and on a remote session. | | `/vc:login` | Logs in to Vercel locally. On a remote session, resolves the deployment's project, refreshes its OIDC token, and confirms any required Trusted Sources rule. | @@ -44,7 +51,11 @@ 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. | +<<<<<<< HEAD `/model`, `/add`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. +======= +`/model`, `/connect`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) ### Configure the model and provider @@ -54,11 +65,19 @@ 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. +<<<<<<< HEAD ### 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. Connection registry items install their definition and then configure Vercel Connect through the same `/add` flow. If the directory is unlinked, setup first offers to create or link a project. Run `eve add connection/` for the equivalent CLI flow. +======= +### Add a connection + +`/connect` shows a searchable list of MCP servers available through Vercel Connect. Already-authored connections remain checked. Logged-out users are directed to `/vc:login`. When the directory is not linked, selecting a server opens the same team and project flow used by `/model`, including creating a project or linking an existing one. + +For a selected server, eve first tries to attach the provider's canonical connector. If that fails, choose an existing connector from a searchable list or create one with a specific name. The fallback stays scoped to the team selected by the linked project. A connector created by the current attempt is removed if attachment or connection-file patching fails. Successful setup writes `agent/connections/.ts`, records the attached connector UID, installs the new dependency so the dev server can load it, then returns to the main prompt. +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) ## Keyboard shortcuts diff --git a/packages/eve-catalog/src/index.ts b/packages/eve-catalog/src/index.ts index 88fa4adbe..4b4fe0ede 100644 --- a/packages/eve-catalog/src/index.ts +++ b/packages/eve-catalog/src/index.ts @@ -81,12 +81,6 @@ export function connectionProtocols(connection: ConnectionIdentity): ConnectionP * The canonical set of eve integrations. Order is display order. Each entry * carries only shared identity; the scaffolder and docs overlay their own * surface-specific data keyed by {@link IntegrationEntry.slug}. - * - * `surfaces.scaffoldable` reflects what eve's interactive setup flow can - * provision and scaffold today: Slack and Web Chat for channels, plus its - * curated connections. Registry installation is independent of this flag. - * The remaining channels are runtime modules configured by hand, so they - * appear in the gallery but not the setup picker. */ export const INTEGRATIONS: readonly IntegrationEntry[] = [ { @@ -223,10 +217,10 @@ export const INTEGRATIONS: readonly IntegrationEntry[] = [ surfaces: { scaffoldable: false, gallery: true }, }, { - slug: "chat-sdk-photon", + slug: "photon", name: "Photon", kind: "channel", - tagline: "Cloud, self-hosted, and local iMessage messaging through Photon.", + tagline: "iMessage through Photon, with guided project and phone setup.", surfaces: { scaffoldable: false, gallery: true }, }, { diff --git a/packages/eve/src/cli/commands/integration-setup.test.ts b/packages/eve/src/cli/commands/integration-setup.test.ts index 82b4a0e77..1373733b4 100644 --- a/packages/eve/src/cli/commands/integration-setup.test.ts +++ b/packages/eve/src/cli/commands/integration-setup.test.ts @@ -1,10 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import type { AddChannelsDeps } from "#setup/integrations/channels/setup.js"; -import { deriveSlackConnectorSlug } from "#setup/scaffold/index.js"; - -import { runIntegrationSetupCommand } from "./integration-setup.js"; +import { + parseIntegrationSetupInvocation, + runIntegrationSetupCommand, +} from "./integration-setup.js"; import type { RegistryCommandLogger } from "./registry.js"; const { isEveProject } = vi.hoisted(() => ({ isEveProject: vi.fn(async () => true) })); @@ -19,57 +18,53 @@ function logger(): RegistryCommandLogger & { errors: string[] } { return { errors, error: (message) => errors.push(message), log: () => {} }; } -function addChannelsDeps(): AddChannelsDeps { - return { - ensureChannel: vi.fn(async (options) => ({ - kind: "web", - action: "created", - filesWritten: [`${options.projectRoot}/app/page.tsx`], - filesSkipped: [], - packageJsonUpdated: [], - })), - deriveSlackConnectorSlug, - provisionSlackbot: vi.fn(), - 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 () => ({ - state: "unlinked", - })), - }; -} - afterEach(() => { process.exitCode = undefined; }); +describe("parseIntegrationSetupInvocation", () => { + it("parses trusted registry setup invocations", () => { + expect( + parseIntegrationSetupInvocation({ + command: "eve", + args: ["integration", "setup", "photon", "--yes"], + }), + ).toEqual({ kind: "channel", channel: "photon", yes: true }); + }); + + it("parses connection setup invocations", () => { + expect( + parseIntegrationSetupInvocation({ + command: "eve", + args: ["integration", "connect", "linear", "mcp.linear.app"], + }), + ).toEqual({ kind: "connection", slug: "linear", service: "mcp.linear.app" }); + }); + + it("rejects commands and flags outside the trusted grammar", () => { + expect( + parseIntegrationSetupInvocation({ + command: "sh", + args: ["integration", "setup", "photon"], + }), + ).toBeUndefined(); + expect( + parseIntegrationSetupInvocation({ + command: "eve", + args: ["integration", "setup", "photon", "--force"], + }), + ).toBeUndefined(); + }); +}); + describe("runIntegrationSetupCommand", () => { - it("runs registry-owned setup without mutating or installing dependencies", async () => { + it("rejects setup kinds outside Photon", async () => { const output = logger(); - const deps = addChannelsDeps(); - const fake = createFakePrompter(); - await runIntegrationSetupCommand( - output, - "/project", - "web", - {}, - { - createPrompter: () => fake.prompter, - detectDeployment: vi.fn(async () => ({ state: "unlinked" as const })), - getVercelAuthStatus: vi.fn(async () => "cli-missing" as const), - addChannelsDeps: deps, - }, - ); + await runIntegrationSetupCommand(output, "/project", "web"); - expect(deps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "web", skipDependencyMutation: true }), - ); - expect(deps.runPackageManagerInstall).not.toHaveBeenCalled(); - expect(output.errors).toEqual([]); + expect(output.errors).toEqual([ + 'Integration setup "web" is not available in this version of eve. Upgrade eve and try again.', + ]); }); }); diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index ac523553a..b9fdebd5f 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -8,6 +8,12 @@ import { channelSetupIntegration, createChannelSetupUi, } from "#setup/integrations/channels/index.js"; +import type { PhotonSetupDeps } from "#setup/photon-setup.js"; +import { + describePhotonSetupEnvironment, + photonSetupEnvironment, +} from "#setup/photon-setup-environment.js"; +import { createPhotonSetupUi, photonSetupIntegration } from "#setup/photon-setup-integrations.js"; import { detectDeployment, projectResolutionFromDeployment } from "#setup/project-resolution.js"; import { createPrompter, type Prompter } from "#setup/prompter.js"; import { isEveProject, type ChannelKind } from "#setup/scaffold/index.js"; @@ -19,6 +25,7 @@ import type { RegistryCommandLogger } from "./registry.js"; export interface IntegrationSetupOptions { yes?: boolean; + signal?: AbortSignal; } export interface IntegrationSetupDependencies { @@ -26,6 +33,7 @@ export interface IntegrationSetupDependencies { detectDeployment: typeof detectDeployment; getVercelAuthStatus: typeof getVercelAuthStatus; addChannelsDeps?: AddChannelsDeps; + photonDeps?: PhotonSetupDeps; } const defaultIntegrationSetupDependencies: IntegrationSetupDependencies = { @@ -33,7 +41,7 @@ const defaultIntegrationSetupDependencies: IntegrationSetupDependencies = { getVercelAuthStatus, }; -/** Runs a built-in integration setup after its registry payload is installed. */ +/** Runs built-in integration setup after its registry payload is installed. */ export async function runIntegrationSetupCommand( logger: RegistryCommandLogger, appRoot: string, @@ -48,21 +56,40 @@ export async function runIntegrationSetupCommand( } try { - if (kind !== "slack" && kind !== "web") { + if (kind !== "photon" && kind !== "slack" && kind !== "web") { throw new Error( `Integration setup "${kind}" is not available in this version of eve. Upgrade eve and try again.`, ); } - const channelKind: ChannelKind = kind; const prompter = dependencies.createPrompter?.() ?? createPrompter(); - 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), - dependencies.getVercelAuthStatus(appRoot), + dependencies.detectDeployment(appRoot, { signal: options.signal }), + dependencies.getVercelAuthStatus(appRoot, { signal: options.signal }), ]); const project = projectResolutionFromDeployment(deployment); + + if (kind === "photon") { + const integration = photonSetupIntegration(); + prompter.intro(`Set up ${integration.label}`); + prompter.log.message("Checking Vercel setup..."); + const environment = photonSetupEnvironment(authStatus, project); + prompter.log.info(describePhotonSetupEnvironment(environment)); + const result = await integration.setup({ + environment, + state: { agentName: "", project, projectPath: appRoot }, + ui: createPhotonSetupUi({ asker: interactiveAsker(prompter), prompter }), + photonDeps: dependencies.photonDeps, + signal: options.signal, + }); + if (result.kind === "cancelled") return; + prompter.outro("Integration set up."); + return; + } + + const channelKind: ChannelKind = kind; + const integration = channelSetupIntegration(channelKind); + prompter.intro(`Set up ${integration.label}`); + prompter.log.message("Checking Vercel setup..."); const environment = channelSetupEnvironment(authStatus, project); prompter.log.info(describeChannelSetupEnvironment(environment)); const result = await integration.setup({ @@ -78,6 +105,7 @@ export async function runIntegrationSetupCommand( presetPortableCredentials: options.yes ? true : undefined, skipDependencyMutation: true, deps: dependencies.addChannelsDeps, + signal: options.signal, }); if (result.kind === "cancelled" && process.env.EVE_SETUP === "1") process.exitCode = 130; prompter.outro(result.kind === "done" ? "Integration set up." : "No changes made."); diff --git a/packages/eve/src/cli/commands/register-integration-commands.ts b/packages/eve/src/cli/commands/register-integration-commands.ts index 5bd79409d..cc1717c7e 100644 --- a/packages/eve/src/cli/commands/register-integration-commands.ts +++ b/packages/eve/src/cli/commands/register-integration-commands.ts @@ -17,10 +17,9 @@ export function registerIntegrationCommands(input: { integration .command("setup ") - .option("-y, --yes") - .action(async (kind: string, options: { yes?: boolean }) => { + .action(async (kind: string) => { const { runIntegrationSetupCommand } = await import("./integration-setup.js"); - await runIntegrationSetupCommand(logger, appRoot, kind, { yes: options.yes }); + await runIntegrationSetupCommand(logger, appRoot, kind); }); integration diff --git a/packages/eve/src/cli/dev/tui/agent-header.ts b/packages/eve/src/cli/dev/tui/agent-header.ts index 98b068770..955a440fa 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.ts @@ -28,7 +28,10 @@ export interface AgentHeaderInput { * slash commands, so callers only attach a tip to local `eve dev` sessions. */ export const AGENT_HEADER_TIPS: readonly string[] = [ +<<<<<<< HEAD "Use /add to install integrations from the registry.", +======= +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) "Use /deploy to see your agent go live.", "Type /help to see every command.", ]; 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 a551d0c1c..e2c2ed7ca 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.test.ts @@ -93,7 +93,11 @@ describe("promptCommandsFor", () => { it("exposes project commands only for local sessions", () => { const names = promptCommandsFor("local").map((command) => command.name); expect(names).toContain("model"); +<<<<<<< HEAD expect(names).toContain("add"); +======= + expect(names).toContain("connect"); +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) expect(names).toContain("deploy"); expect(names).toContain("vc:install"); expect(names).toContain("vc:login"); @@ -106,7 +110,11 @@ describe("promptCommandsFor", () => { expect(names).toContain("vc:login"); expect(names).not.toContain("vc:auth"); expect(names).not.toContain("model"); +<<<<<<< HEAD expect(names).not.toContain("add"); +======= + expect(names).not.toContain("connect"); +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) 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 53b778128..17daaaa24 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.ts @@ -1,4 +1,8 @@ +<<<<<<< HEAD export type PromptCommandExtensionName = "model" | "add" | "deploy" | "vc:install" | "vc:login"; +======= +export type PromptCommandExtensionName = "model" | "connect" | "deploy" | "vc:install" | "vc:login"; +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) type PromptCommandTarget = "local" | "remote"; @@ -92,11 +96,19 @@ const PROMPT_COMMAND_DEFINITIONS = [ targets: ["local", "remote"], }, { +<<<<<<< HEAD name: "add", aliases: [], description: "Browse and add registry integrations", takesArgument: false, build: () => ({ type: "extension", name: "add", argument: "" }), +======= + name: "connect", + aliases: [], + description: "Add an MCP server through Vercel Connect", + takesArgument: false, + build: () => ({ type: "extension", name: "connect", argument: "" }), +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) targets: ["local"], }, { diff --git a/packages/eve/src/cli/dev/tui/setup-commands.ts b/packages/eve/src/cli/dev/tui/setup-commands.ts index 34a2344ab..ac9f557c8 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.ts @@ -1,4 +1,8 @@ import { HumanActionRequiredError } from "#setup/human-action.js"; +<<<<<<< HEAD +======= +import { runConnectionsFlow } from "#setup/flows/connections.js"; +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) import { runDeployFlow } from "#setup/flows/deploy.js"; import { runInstallVercelCliFlow, @@ -7,7 +11,10 @@ import { import { runLoginFlow, type LoginFlowResult } from "#setup/flows/login.js"; import { runModelFlow, type ModelProviderOutcome } from "#setup/flows/model.js"; import { runProviderFlow, type ProviderPicker } from "#setup/flows/provider.js"; +<<<<<<< HEAD import { runRegistryFlow } from "#setup/flows/registry.js"; +======= +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) import type { Prompter } from "#setup/prompter.js"; import { WizardCancelledError } from "#setup/step.js"; @@ -27,7 +34,11 @@ 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" }, +<<<<<<< HEAD add: { title: "Add to your agent", indicator: "pulse" }, +======= + connect: { title: "Agent connections", indicator: "pulse" }, +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) deploy: { title: "Deploy to Vercel", indicator: "spinner" }, } satisfies Record; @@ -59,7 +70,11 @@ export interface TuiSetupFlows { runInstallVercelCliFlow: typeof runInstallVercelCliFlow; runLoginFlow: typeof runLoginFlow; runModelFlow: typeof runModelFlow; +<<<<<<< HEAD runRegistryFlow: typeof runRegistryFlow; +======= + runConnectionsFlow: typeof runConnectionsFlow; +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) runDeployFlow: typeof runDeployFlow; } @@ -119,7 +134,11 @@ function muteableRenderer( } /** +<<<<<<< HEAD * Runs one TUI setup command (/model, /add, /deploy) over the +======= + * Runs one TUI setup command (/model, /connect, /deploy) over the +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) * 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 @@ -168,7 +187,11 @@ async function executeSetupCommand( runInstallVercelCliFlow, runLoginFlow, runModelFlow, +<<<<<<< HEAD runRegistryFlow, +======= + runConnectionsFlow, +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) runDeployFlow, ...input.flows, }; @@ -227,6 +250,7 @@ async function executeSetupCommand( } return outcome; } +<<<<<<< HEAD case "add": { const result = await flows.runRegistryFlow({ appRoot, prompter, signal }); if (result.kind === "cancelled") { @@ -238,6 +262,46 @@ async function executeSetupCommand( tone: "success", preserveFlowDiagnostics: true, }; +======= + case "connect": { + const result = await flows.runConnectionsFlow({ + appRoot, + prompter, + signal, + disabledConnectionReasons: input.disabledConnectionReasons, + }); + switch (result.kind) { + case "cancelled": + return { + message: "/connect dismissed.", + preserveFlowDiagnostics: true, + effect: { kind: "model-access-changed" }, + }; + case "failed": + return { + message: + result.addedConnections.length === 0 + ? `/connect failed: ${result.message}` + : `Connection files changed, but /connect failed: ${result.message}`, + preserveFlowDiagnostics: true, + effect: + result.addedConnections.length === 0 + ? { kind: "model-access-changed" } + : { kind: "connection-added" }, + }; + case "done": + return { + message: + result.addedConnections.length === 0 + ? "No connections added." + : `Connections added: ${result.addedConnections.join(", ")}.`, + preserveFlowDiagnostics: true, + effect: + result.addedConnections.length === 0 + ? { kind: "model-access-changed" } + : { kind: "connection-added" }, + }; +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) } return { message: "No registry items added.", preserveFlowDiagnostics: true }; } @@ -378,9 +442,14 @@ function vercelCliUpgradeFailureMessage(command: string, reason?: string): strin /** * Translates a Vercel {@link HumanActionRequiredError} into the in-TUI routing +<<<<<<< HEAD * message, or `undefined` for anything else. One translator so every path that * can surface a provisioning action, routing login, forbidden-scope, and CLI * recovery actions the same way rather than +======= + * message, or `undefined` for anything else. The command catch uses one + * translator for login, forbidden-scope, and CLI recovery actions rather than +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) * leaking the raw error text. */ function vercelActionOutcome(error: unknown, command: string): TuiSetupCommandResult | undefined { diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index bd16ec4ef..1b4432f1b 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -206,7 +206,13 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm program .command("channels") +<<<<<<< HEAD .description("Manage user-authored channels in the current project.") +======= + .description("Manage user-authored channels in the current project."); + + channels +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) .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 1b24f97c3..0da84ceed 100644 --- a/packages/eve/src/setup/boxes/deploy-project.ts +++ b/packages/eve/src/setup/boxes/deploy-project.ts @@ -36,7 +36,11 @@ export interface DeployProjectOptions { skip?: boolean; /** * Run even without a planned or detected project, linking interactively from +<<<<<<< HEAD * inside `perform` (the `eve add channel/slack` composition; onboarding always has +======= + * inside `perform` (the former channel setup composition; onboarding always has +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) * a plan or skips deploy with the channels). */ ensureLinkedProject?: "interactive-vercel-link"; @@ -75,14 +79,22 @@ 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 +<<<<<<< HEAD * `eve add channel/slack` composition), it falls back to the interactive bare +======= + * former channel setup composition), it falls back to the interactive bare +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) * `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 +<<<<<<< HEAD * every deploy surface — `eve add channel/slack`, the dev TUI `/deploy`, onboarding — +======= + * every deploy surface — the dev TUI `/deploy` and onboarding — +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) * gets the reconcile without composing a separate box. */ export function deployProject( diff --git a/packages/eve/src/setup/flows/in-project.ts b/packages/eve/src/setup/flows/in-project.ts index c47881643..6eab75701 100644 --- a/packages/eve/src/setup/flows/in-project.ts +++ b/packages/eve/src/setup/flows/in-project.ts @@ -12,7 +12,11 @@ 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 +<<<<<<< HEAD * `eve add channel/slack`. +======= + * `eve add`. +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) */ export function inProjectSetupState( appRoot: string, diff --git a/packages/eve/src/setup/integrations/channels/environment.ts b/packages/eve/src/setup/integrations/channels/environment.ts index 3f887302c..df4a474e4 100644 --- a/packages/eve/src/setup/integrations/channels/environment.ts +++ b/packages/eve/src/setup/integrations/channels/environment.ts @@ -1,15 +1,15 @@ 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 { +/** Read-only hosting facts available to Photon-owned setup hooks. */ +export interface PhotonSetupEnvironment { vercel: | { kind: "available"; project: ProjectResolution } | { kind: "unavailable"; reason: Exclude }; } /** Describes the result of the read-only Vercel capability probe. */ -export function describeChannelSetupEnvironment(environment: ChannelSetupEnvironment): string { +export function describePhotonSetupEnvironment(environment: PhotonSetupEnvironment): string { if (environment.vercel.kind === "available") { switch (environment.vercel.project.kind) { case "deployed": @@ -30,11 +30,11 @@ export function describeChannelSetupEnvironment(environment: ChannelSetupEnviron } } -/** Builds channel setup facts from the independent Vercel probes. */ -export function channelSetupEnvironment( +/** Builds Photon setup facts from the independent Vercel probes. */ +export function photonSetupEnvironment( authStatus: VercelAuthStatus, project: ProjectResolution, -): ChannelSetupEnvironment { +): PhotonSetupEnvironment { return authStatus === "authenticated" ? { vercel: { kind: "available", project } } : { vercel: { kind: "unavailable", reason: authStatus } }; diff --git a/packages/eve/src/setup/integrations/channels/ui.ts b/packages/eve/src/setup/integrations/channels/ui.ts index e44b9101e..c79582ef5 100644 --- a/packages/eve/src/setup/integrations/channels/ui.ts +++ b/packages/eve/src/setup/integrations/channels/ui.ts @@ -1,16 +1,16 @@ 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 { +/** UI capabilities available to a Photon-owned setup hook. */ +export interface PhotonSetupUi { readonly asker: Asker; readonly prompter: Prompter; confirm(input: { key: string; message: string; recommended?: boolean }): Promise; nextSteps(lines: readonly string[]): void; } -/** Adapts the shared setup asker and prompter to the channel hook UI. */ -export function createChannelSetupUi(input: { asker: Asker; prompter: Prompter }): ChannelSetupUi { +/** Adapts the shared setup asker and prompter to the Photon setup UI. */ +export function createPhotonSetupUi(input: { asker: Asker; prompter: Prompter }): PhotonSetupUi { return { ...input, async confirm(question) { diff --git a/packages/eve/src/setup/photon-connect.test.ts b/packages/eve/src/setup/photon-connect.test.ts new file mode 100644 index 000000000..17d7bcbc8 --- /dev/null +++ b/packages/eve/src/setup/photon-connect.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { ChannelSetupLog } from "./cli/index.js"; +import { parseCreatedPhotonConnector, provisionPhotonConnector } from "./photon-connect.js"; + +function log(): ChannelSetupLog { + return { + message: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + commandOutput: vi.fn(), + }; +} + +describe("Photon Connect provisioning", () => { + test("parses an app-scoped connector", () => { + expect( + parseCreatedPhotonConnector( + JSON.stringify({ + id: "scl_photon", + uid: "photon/imessage0", + supportedSubjectTypes: ["app"], + }), + ), + ).toEqual({ id: "scl_photon", uid: "photon/imessage0" }); + }); + + test("creates a native connector, detaches, then attaches the routed trigger", async () => { + const runVercelCaptureStdout = vi.fn().mockResolvedValueOnce({ + ok: true as const, + stdout: JSON.stringify({ + id: "scl_photon", + uid: "photon/imessage0", + supportedSubjectTypes: ["app"], + }), + }); + const runVercel = vi.fn(async () => true); + + await expect( + provisionPhotonConnector({ + credentials: { projectId: "project-id", projectSecret: "project-secret" }, + log: log(), + project: { orgId: "team_123", projectId: "prj_123" }, + projectRoot: "/tmp/imessage0", + slug: "imessage0", + deps: { runVercel, runVercelCaptureStdout }, + }), + ).resolves.toEqual({ id: "scl_photon", uid: "photon/imessage0" }); + + expect(runVercelCaptureStdout).toHaveBeenCalledWith( + [ + "connect", + "create", + "photon", + "--connector-type", + "photon", + "--data", + "@-", + "--name", + "imessage0", + "--triggers", + "-F", + "json", + "--scope", + "team_123", + ], + expect.objectContaining({ + cwd: "/tmp/imessage0", + nonInteractive: true, + stdin: JSON.stringify({ projectId: "project-id", projectSecret: "project-secret" }), + }), + ); + expect(runVercel).toHaveBeenNthCalledWith( + 1, + [ + "connect", + "detach", + "photon/imessage0", + "--project", + "prj_123", + "--yes", + "--scope", + "team_123", + ], + expect.objectContaining({ cwd: "/tmp/imessage0", nonInteractive: true }), + ); + expect(runVercel).toHaveBeenNthCalledWith( + 2, + [ + "connect", + "attach", + "photon/imessage0", + "--project", + "prj_123", + "--environment", + "production", + "--triggers", + "--trigger-path", + "/eve/v1/photon", + "--yes", + "--scope", + "team_123", + ], + expect.objectContaining({ cwd: "/tmp/imessage0", nonInteractive: true }), + ); + }); +}); diff --git a/packages/eve/src/setup/photon-connect.ts b/packages/eve/src/setup/photon-connect.ts new file mode 100644 index 000000000..deabb44e5 --- /dev/null +++ b/packages/eve/src/setup/photon-connect.ts @@ -0,0 +1,173 @@ +import type { ChannelSetupLog } from "#setup/cli/index.js"; +import { createPromptCommandOutput, withPhase } from "#setup/cli/index.js"; +import type { VercelProjectReference } from "#setup/project-resolution.js"; +import { + runVercel, + runVercelCaptureStdout, + type RunVercelCaptureResult, +} from "#setup/primitives/run-vercel.js"; + +export const PHOTON_CONNECT_SERVICE = "photon"; +export const PHOTON_CONNECTOR_TYPE = "photon"; +export const PHOTON_TRIGGER_PATH = "/eve/v1/photon"; + +/** Photon project credentials collected separately from their Connect storage encoding. */ +export interface PhotonProjectCredentials { + projectId: string; + projectSecret: string; +} + +/** Identity of a native Photon connector created through Vercel Connect. */ +export interface PhotonConnectorRef { + id: string; + uid: string; + /** Direct project URL used until Photon is available as a managed trigger connector. */ + webhookUrl?: string; +} + +/** Effects used to provision a Photon connector. */ +export interface ProvisionPhotonConnectorDeps { + runVercel: typeof runVercel; + runVercelCaptureStdout: typeof runVercelCaptureStdout; +} + +/** Input for provisioning one Photon connector and its eve webhook destination. */ +export interface ProvisionPhotonConnectorOptions { + credentials: PhotonProjectCredentials; + log: ChannelSetupLog; + project: VercelProjectReference; + projectRoot: string; + slug: string; + signal?: AbortSignal; + deps?: ProvisionPhotonConnectorDeps; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Parses `vercel connect create -F json` output for a Photon connector. */ +export function parseCreatedPhotonConnector(stdout: string): PhotonConnectorRef | undefined { + let value: unknown; + try { + value = JSON.parse(stdout); + } catch { + return undefined; + } + if (!isRecord(value) || typeof value["id"] !== "string" || typeof value["uid"] !== "string") { + return undefined; + } + const subjects = value["supportedSubjectTypes"]; + if (!Array.isArray(subjects) || !subjects.includes("app")) return undefined; + return { id: value["id"], uid: value["uid"] }; +} + +function createData(credentials: PhotonProjectCredentials): string { + return JSON.stringify({ + projectId: credentials.projectId.trim(), + projectSecret: credentials.projectSecret.trim(), + }); +} + +function requireCreatedConnector(result: RunVercelCaptureResult): PhotonConnectorRef { + if (!result.ok) throw new Error("Photon connector creation failed."); + const connector = parseCreatedPhotonConnector(result.stdout); + if (connector === undefined) { + throw new Error("Vercel returned an invalid Photon connector after creation."); + } + return connector; +} + +/** + * Creates a native Photon connector and attaches its verified webhook trigger + * to the linked project. Secrets travel over stdin, never argv. + */ +export async function provisionPhotonConnector( + options: ProvisionPhotonConnectorOptions, +): Promise { + const deps = options.deps ?? { runVercel, runVercelCaptureStdout }; + const onOutput = createPromptCommandOutput(options.log); + const result = await withPhase(options.log, "Creating Photon connector...", () => + deps.runVercelCaptureStdout( + [ + "connect", + "create", + PHOTON_CONNECT_SERVICE, + "--connector-type", + PHOTON_CONNECTOR_TYPE, + "--data", + "@-", + "--name", + options.slug, + "--triggers", + "-F", + "json", + "--scope", + options.project.orgId, + ], + { + cwd: options.projectRoot, + nonInteractive: true, + onOutput, + signal: options.signal, + stdin: createData(options.credentials), + }, + ), + ); + options.signal?.throwIfAborted(); + const connector = requireCreatedConnector(result); + + // Creation can auto-attach the cwd's linked project with a pathless default + // destination. Detach first so attach installs exactly one routed destination. + await deps.runVercel( + [ + "connect", + "detach", + connector.uid, + "--project", + options.project.projectId, + "--yes", + "--scope", + options.project.orgId, + ], + { + cwd: options.projectRoot, + nonInteractive: true, + onOutput, + signal: options.signal, + }, + ); + const attached = await withPhase(options.log, "Connecting Photon credentials...", () => + deps.runVercel( + [ + "connect", + "attach", + connector.uid, + "--project", + options.project.projectId, + "--environment", + "production", + "--triggers", + "--trigger-path", + PHOTON_TRIGGER_PATH, + "--yes", + "--scope", + options.project.orgId, + ], + { + cwd: options.projectRoot, + nonInteractive: true, + onOutput, + signal: options.signal, + }, + ), + ); + options.signal?.throwIfAborted(); + if (!attached) { + throw new Error( + `Photon connector was created, but its credentials could not be attached. Run \`vercel connect attach ${connector.uid} --environment production --yes\`.`, + ); + } + + return connector; +} diff --git a/packages/eve/src/setup/photon-management.test.ts b/packages/eve/src/setup/photon-management.test.ts new file mode 100644 index 000000000..fbabb2389 --- /dev/null +++ b/packages/eve/src/setup/photon-management.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + provisionPhotonProject, + registerPhotonWebhook, + usePhotonProject, + validatePhotonPhoneNumber, +} from "./photon-management.js"; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("Photon management provisioning", () => { + test("authorizes and creates a dedicated project with an operator", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + response({ + device_code: "device-code", + user_code: "ABCD-1234", + verification_uri_complete: "https://app.photon.codes/device?code=ABCD-1234", + expires_in: 600, + interval: 1, + }), + ) + .mockResolvedValueOnce(response({ access_token: "dashboard-token" })) + .mockResolvedValueOnce(response({ id: "project-id" })) + .mockResolvedValueOnce(response({ projectSecret: "project-secret" })) + .mockResolvedValueOnce( + response({ + succeed: true, + data: { user: { id: "user-id", assignedPhoneNumber: "+15550000000" } }, + }), + ); + const onAuthorization = vi.fn(); + + await expect( + provisionPhotonProject({ + projectName: "eve · demo", + phoneNumber: "+15551234567", + onAuthorization, + deps: { fetch, delay: vi.fn(async () => {}) }, + }), + ).resolves.toMatchObject({ + projectId: "project-id", + projectSecret: "project-secret", + assignedPhoneNumber: "+15550000000", + cleanup: expect.any(Function), + }); + + expect(onAuthorization).toHaveBeenCalledWith({ + userCode: "ABCD-1234", + verificationUrl: "https://app.photon.codes/device?code=ABCD-1234", + }); + const projectRequest = fetch.mock.calls[2]; + expect(JSON.parse(String(projectRequest?.[1]?.body))).toMatchObject({ + name: "eve · demo", + platforms: ["imessage"], + }); + const userRequest = fetch.mock.calls[4]; + expect(JSON.parse(String(userRequest?.[1]?.body))).toMatchObject({ + phoneNumber: "+15551234567", + }); + }); + + test("uses an existing project without dashboard authorization", async () => { + const fetch = vi.fn(async () => + response({ succeed: true, data: { user: { assignedPhoneNumber: "+15550000000" } } }), + ); + + await expect( + usePhotonProject({ + projectId: "project-id", + projectSecret: "project-secret", + phoneNumber: "+15551234567", + deps: { fetch, delay: vi.fn(async () => {}) }, + }), + ).resolves.toMatchObject({ + projectId: "project-id", + projectSecret: "project-secret", + assignedPhoneNumber: "+15550000000", + }); + expect(fetch).toHaveBeenCalledOnce(); + }); + + test("registers the Connect URL and returns the one-time secret", async () => { + const fetch = vi.fn(async () => + response({ + succeed: true, + data: { id: "webhook-id", signingSecret: "webhook-secret" }, + }), + ); + + await expect( + registerPhotonWebhook({ + projectId: "project-id", + projectSecret: "project-secret", + webhookUrl: "https://connect.vercel.com/trigger/scl_123", + deps: { fetch }, + }), + ).resolves.toBe("webhook-secret"); + + expect(fetch).toHaveBeenCalledWith( + "https://spectrum.photon.codes/projects/project-id/webhooks/", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + webhookUrl: "https://connect.vercel.com/trigger/scl_123", + }), + }), + ); + }); + + test.each([ + ["+15551234567", null], + ["+442079460123", null], + ["+1555123456", "exactly 10 digits"], + ["+155512345678", "exactly 10 digits"], + ["15551234567", "E.164"], + ["+123456", "E.164"], + ])("validates phone number %s", (phoneNumber, expected) => { + const result = validatePhotonPhoneNumber(phoneNumber); + if (expected === null) expect(result).toBeNull(); + else expect(result).toContain(expected); + }); + + test("rejects non-E.164 phone numbers before authorization", async () => { + const fetch = vi.fn(); + await expect( + provisionPhotonProject({ + projectName: "eve · demo", + phoneNumber: "555-1234", + onAuthorization: vi.fn(), + deps: { fetch, delay: vi.fn(async () => {}) }, + }), + ).rejects.toThrow("E.164"); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/setup/photon-management.ts b/packages/eve/src/setup/photon-management.ts new file mode 100644 index 000000000..509edbf58 --- /dev/null +++ b/packages/eve/src/setup/photon-management.ts @@ -0,0 +1,299 @@ +import { setTimeout as sleep } from "node:timers/promises"; + +const PHOTON_DASHBOARD_HOST = "https://app.photon.codes"; +const PHOTON_SPECTRUM_HOST = "https://spectrum.photon.codes"; +const PHOTON_DEVICE_CLIENT_ID = "photon-cli"; +const E164 = /^\+[1-9]\d{6,14}$/; + +/** Validates international syntax and the fixed-length North American numbering plan. */ +export function validatePhotonPhoneNumber(value: string): string | null { + const phoneNumber = value.trim(); + if (!E164.test(phoneNumber)) { + return "Use E.164 format: + followed by 7–15 digits, for example +15551234567"; + } + if (phoneNumber.startsWith("+1") && phoneNumber.length !== 12) { + return "US and Canadian numbers must be +1 followed by exactly 10 digits"; + } + return null; +} + +interface DeviceCodeResponse { + deviceCode: string; + userCode: string; + verificationUrl: string; + expiresIn: number; + interval: number; +} + +export interface PhotonManagedProject { + projectId: string; + projectSecret: string; + assignedPhoneNumber?: string; + /** Deletes the dedicated project when a later setup phase fails. */ + cleanup(): Promise; +} + +export interface PhotonDeviceAuthorization { + userCode: string; + verificationUrl: string; +} + +export interface ProvisionPhotonProjectOptions { + projectName: string; + phoneNumber: string; + onAuthorization(authorization: PhotonDeviceAuthorization): void; + signal?: AbortSignal; + deps?: PhotonManagementDeps; +} + +export interface UsePhotonProjectOptions { + projectId: string; + projectSecret: string; + phoneNumber: string; + deps?: PhotonManagementDeps; +} + +export interface PhotonManagementDeps { + fetch: typeof fetch; + delay(ms: number, signal?: AbortSignal): Promise; +} + +const defaultDeps: PhotonManagementDeps = { + fetch, + delay: (ms, signal) => sleep(ms, undefined, { signal }), +}; + +function record(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Photon returned an invalid response."); + } + return value as Record; +} + +async function json(response: Response, action: string): Promise> { + const body: unknown = await response.json().catch(() => ({})); + if (!response.ok) { + const value = record(body); + const detail = value["error"] ?? value["message"] ?? response.statusText; + throw new Error(`Photon ${action} failed: ${String(detail)}`); + } + return record(body); +} + +function bearer(token: string): Record { + return { authorization: `Bearer ${token}`, "content-type": "application/json" }; +} + +function basic(projectId: string, projectSecret: string): Record { + return { + authorization: `Basic ${Buffer.from(`${projectId}:${projectSecret}`).toString("base64")}`, + "content-type": "application/json", + }; +} + +async function requestDeviceCode(deps: PhotonManagementDeps): Promise { + const response = await deps.fetch(`${PHOTON_DASHBOARD_HOST}/api/auth/device/code`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ client_id: PHOTON_DEVICE_CLIENT_ID, scope: "openid profile email" }), + }); + const body = await json(response, "device login"); + const deviceCode = body["device_code"]; + const userCode = body["user_code"]; + const verificationUrl = body["verification_uri_complete"] ?? body["verification_uri"]; + if ( + typeof deviceCode !== "string" || + typeof userCode !== "string" || + typeof verificationUrl !== "string" + ) { + throw new Error("Photon returned an invalid device authorization response."); + } + return { + deviceCode, + userCode, + verificationUrl, + expiresIn: typeof body["expires_in"] === "number" ? body["expires_in"] : 1800, + interval: typeof body["interval"] === "number" ? body["interval"] : 5, + }; +} + +async function pollForToken( + code: DeviceCodeResponse, + deps: PhotonManagementDeps, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + code.expiresIn * 1000; + let intervalMs = code.interval * 1000; + while (Date.now() < deadline) { + await deps.delay(intervalMs, signal); + const response = await deps.fetch(`${PHOTON_DASHBOARD_HOST}/api/auth/device/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: code.deviceCode, + client_id: PHOTON_DEVICE_CLIENT_ID, + }), + signal, + }); + const body = record(await response.json().catch(() => ({}))); + if (response.ok) { + const token = body["access_token"] ?? body["accessToken"]; + if (typeof token === "string" && token.length > 0) return token; + throw new Error("Photon approved device login without returning an access token."); + } + const error = body["error"] ?? body["message"]; + if (error === "authorization_pending") continue; + if (error === "slow_down" || response.status === 429) { + intervalMs += response.status === 429 ? 10_000 : 5_000; + continue; + } + throw new Error(`Photon device login failed: ${String(error ?? response.statusText)}`); + } + throw new Error("Photon device login timed out."); +} + +async function createProject( + token: string, + name: string, + deps: PhotonManagementDeps, +): Promise { + const response = await deps.fetch(`${PHOTON_DASHBOARD_HOST}/api/projects`, { + method: "POST", + headers: bearer(token), + body: JSON.stringify({ + name, + location: "United States", + platforms: ["imessage"], + template: false, + observability: false, + }), + }); + const body = await json(response, "project creation"); + if (typeof body["id"] !== "string") throw new Error("Photon did not return a project ID."); + return body["id"]; +} + +async function regenerateSecret( + token: string, + projectId: string, + deps: PhotonManagementDeps, +): Promise { + const response = await deps.fetch( + `${PHOTON_DASHBOARD_HOST}/api/projects/${encodeURIComponent(projectId)}/regenerate-secret`, + { method: "POST", headers: bearer(token), body: "{}" }, + ); + const body = await json(response, "project credential provisioning"); + if (typeof body["projectSecret"] !== "string") { + throw new Error("Photon did not return the new project secret."); + } + return body["projectSecret"]; +} + +async function registerUser( + projectId: string, + projectSecret: string, + phoneNumber: string, + deps: PhotonManagementDeps, +): Promise { + const response = await deps.fetch( + `${PHOTON_SPECTRUM_HOST}/projects/${encodeURIComponent(projectId)}/users/`, + { + method: "POST", + headers: basic(projectId, projectSecret), + body: JSON.stringify({ type: "shared", phoneNumber }), + }, + ); + const body = await json(response, "phone registration"); + const data = record(body["data"] ?? body); + const user = record(data["user"] ?? data); + const assignedPhoneNumber = user["assignedPhoneNumber"] ?? user["phoneNumber"]; + return typeof assignedPhoneNumber === "string" ? assignedPhoneNumber : undefined; +} + +async function deleteProject( + token: string, + projectId: string, + deps: PhotonManagementDeps, +): Promise { + const response = await deps.fetch( + `${PHOTON_DASHBOARD_HOST}/api/projects/${encodeURIComponent(projectId)}`, + { method: "DELETE", headers: bearer(token) }, + ); + if (!response.ok && response.status !== 404) await json(response, "project cleanup"); +} + +/** Authorizes Photon and creates an isolated iMessage project for one eve agent. */ +export async function provisionPhotonProject( + options: ProvisionPhotonProjectOptions, +): Promise { + const phoneNumber = options.phoneNumber.trim(); + const validationError = validatePhotonPhoneNumber(phoneNumber); + if (validationError !== null) + throw new Error(`Photon phone number is invalid. ${validationError}.`); + const deps = options.deps ?? defaultDeps; + const code = await requestDeviceCode(deps); + options.onAuthorization({ userCode: code.userCode, verificationUrl: code.verificationUrl }); + const token = await pollForToken(code, deps, options.signal); + const projectId = await createProject(token, options.projectName, deps); + const cleanup = () => deleteProject(token, projectId, deps); + try { + const projectSecret = await regenerateSecret(token, projectId, deps); + const assignedPhoneNumber = await registerUser(projectId, projectSecret, phoneNumber, deps); + return assignedPhoneNumber === undefined + ? { projectId, projectSecret, cleanup } + : { projectId, projectSecret, assignedPhoneNumber, cleanup }; + } catch (error) { + await cleanup().catch(() => {}); + throw error; + } +} + +/** Validates existing Photon credentials and registers the agent's iMessage user. */ +export async function usePhotonProject( + options: UsePhotonProjectOptions, +): Promise { + const phoneNumber = options.phoneNumber.trim(); + const validationError = validatePhotonPhoneNumber(phoneNumber); + if (validationError !== null) + throw new Error(`Photon phone number is invalid. ${validationError}.`); + const projectId = options.projectId.trim(); + const projectSecret = options.projectSecret.trim(); + if (!projectId || !projectSecret) { + throw new Error("Photon project ID and project secret are required."); + } + const assignedPhoneNumber = await registerUser( + projectId, + projectSecret, + phoneNumber, + options.deps ?? defaultDeps, + ); + const cleanup = async () => {}; + return assignedPhoneNumber === undefined + ? { projectId, projectSecret, cleanup } + : { projectId, projectSecret, assignedPhoneNumber, cleanup }; +} + +/** Registers the webhook URL and returns its one-time signing secret. */ +export async function registerPhotonWebhook(input: { + projectId: string; + projectSecret: string; + webhookUrl: string; + deps?: Pick; +}): Promise { + const request = input.deps?.fetch ?? fetch; + const response = await request( + `${PHOTON_SPECTRUM_HOST}/projects/${encodeURIComponent(input.projectId)}/webhooks/`, + { + method: "POST", + headers: basic(input.projectId, input.projectSecret), + body: JSON.stringify({ webhookUrl: input.webhookUrl }), + }, + ); + const body = await json(response, "webhook registration"); + const registration = record(body["data"] ?? body); + if (typeof registration["signingSecret"] !== "string") { + throw new Error("Photon did not return the webhook signing secret."); + } + return registration["signingSecret"]; +} diff --git a/packages/eve/src/setup/photon-setup-environment.ts b/packages/eve/src/setup/photon-setup-environment.ts new file mode 100644 index 000000000..6fdb37eed --- /dev/null +++ b/packages/eve/src/setup/photon-setup-environment.ts @@ -0,0 +1,41 @@ +import type { ProjectResolution } from "../../project-resolution.js"; +import type { VercelAuthStatus } from "../../vercel-project.js"; + +/** Read-only hosting facts available to Photon-owned setup hooks. */ +export interface PhotonSetupEnvironment { + vercel: + | { kind: "available"; project: ProjectResolution } + | { kind: "unavailable"; reason: Exclude }; +} + +/** Describes the result of the read-only Vercel capability probe. */ +export function describePhotonSetupEnvironment(environment: PhotonSetupEnvironment): string { + if (environment.vercel.kind === "available") { + switch (environment.vercel.project.kind) { + case "deployed": + return `Found an authenticated Vercel account and deployed project (${environment.vercel.project.productionUrl}).`; + case "linked": + return "Found an authenticated Vercel account and linked project."; + case "unresolved": + return "Found an authenticated Vercel account; this directory is not linked to a project."; + } + } + switch (environment.vercel.reason) { + case "logged-out": + return "No authenticated Vercel account found; using portable Photon setup."; + case "cli-missing": + return "Vercel CLI not found; using portable Photon setup."; + case "unavailable": + return "Could not verify the Vercel account; using portable Photon setup."; + } +} + +/** Builds Photon setup facts from the independent Vercel probes. */ +export function photonSetupEnvironment( + authStatus: VercelAuthStatus, + project: ProjectResolution, +): PhotonSetupEnvironment { + return authStatus === "authenticated" + ? { vercel: { kind: "available", project } } + : { vercel: { kind: "unavailable", reason: authStatus } }; +} diff --git a/packages/eve/src/setup/photon-setup-integration.ts b/packages/eve/src/setup/photon-setup-integration.ts new file mode 100644 index 000000000..6897723e0 --- /dev/null +++ b/packages/eve/src/setup/photon-setup-integration.ts @@ -0,0 +1,34 @@ +import type { PhotonSetupEnvironment } from "./photon-setup-environment.js"; +import type { PhotonSetupUi } from "./photon-setup-ui.js"; +import type { ProjectResolution } from "./project-resolution.js"; + +/** State owned by a Photon setup invocation. */ +export interface PhotonSetupState { + readonly agentName: string; + readonly project: ProjectResolution; + readonly projectPath: string; +} + +/** Shared inputs available to the Photon setup implementation. */ +export interface PhotonSetupContext { + readonly environment: PhotonSetupEnvironment; + readonly state: Readonly; + readonly ui: PhotonSetupUi; + readonly signal?: AbortSignal; + readonly force?: boolean; + readonly headless?: boolean; + readonly photonDeps?: import("./photon-setup.js").PhotonSetupDeps; +} + +/** Structured outcome from the Photon setup implementation. */ +export type PhotonSetupResult = + | { readonly kind: "done"; readonly state: PhotonSetupState } + | { readonly kind: "cancelled" }; + +/** Guided Photon setup behavior. */ +export interface PhotonSetupIntegration { + readonly kind: "photon"; + readonly label: string; + readonly hint?: string; + setup(context: PhotonSetupContext): Promise; +} diff --git a/packages/eve/src/setup/photon-setup-integrations.ts b/packages/eve/src/setup/photon-setup-integrations.ts new file mode 100644 index 000000000..948c78b1a --- /dev/null +++ b/packages/eve/src/setup/photon-setup-integrations.ts @@ -0,0 +1,9 @@ +import { PHOTON_CHANNEL_SETUP } from "./photon-setup.js"; +import type { PhotonSetupIntegration } from "./photon-setup-integration.js"; + +/** Resolves the Photon setup integration. */ +export function photonSetupIntegration(): PhotonSetupIntegration { + return PHOTON_CHANNEL_SETUP; +} + +export { createPhotonSetupUi } from "./photon-setup-ui.js"; diff --git a/packages/eve/src/setup/photon-setup-ui.ts b/packages/eve/src/setup/photon-setup-ui.ts new file mode 100644 index 000000000..c79582ef5 --- /dev/null +++ b/packages/eve/src/setup/photon-setup-ui.ts @@ -0,0 +1,29 @@ +import { confirm, SkippedSignal, type Asker } from "../../ask.js"; +import type { Prompter } from "../../prompter.js"; + +/** UI capabilities available to a Photon-owned setup hook. */ +export interface PhotonSetupUi { + readonly asker: Asker; + readonly prompter: Prompter; + confirm(input: { key: string; message: string; recommended?: boolean }): Promise; + nextSteps(lines: readonly string[]): void; +} + +/** Adapts the shared setup asker and prompter to the Photon setup UI. */ +export function createPhotonSetupUi(input: { asker: Asker; prompter: Prompter }): PhotonSetupUi { + return { + ...input, + async confirm(question) { + try { + return await input.asker.ask(confirm(question)); + } catch (error) { + if (error instanceof SkippedSignal) return false; + throw error; + } + }, + nextSteps(lines) { + if (lines.length === 0) return; + input.prompter.note(lines.join("\n"), "Next steps", { tone: "success" }); + }, + }; +} diff --git a/packages/eve/src/setup/photon-setup.test.ts b/packages/eve/src/setup/photon-setup.test.ts new file mode 100644 index 000000000..e7792d26a --- /dev/null +++ b/packages/eve/src/setup/photon-setup.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; + +import type { Asker, Question } from "./ask.js"; +import { photonSetupEnvironment } from "./photon-setup-environment.js"; +import type { PhotonSetupDeps } from "./photon-setup.js"; +import { PHOTON_CHANNEL_SETUP } from "./photon-setup.js"; +import { createPhotonSetupUi } from "./photon-setup-ui.js"; + +function asker(answers: Record): Asker { + return { + ask: async (question: Question) => answers[question.key] as T, + askMany: async () => [], + }; +} + +function deps(): PhotonSetupDeps { + return { + appendEnv: vi.fn(async () => ({ written: [], skipped: [] })), + deriveConnectorSlug: vi.fn(async () => "agent" as never), + linkProject: vi.fn(), + openUrl: vi.fn(), + pickProject: vi.fn(), + pickTeam: vi.fn(), + provisionConnector: vi.fn(), + provisionProject: vi.fn(async () => ({ + projectId: "project-id", + projectSecret: "project-secret", + cleanup: vi.fn(async () => {}), + })), + readProjectLink: vi.fn(), + registerWebhook: vi.fn(async () => "webhook-secret"), + useProject: vi.fn(), + writeTextFile: vi.fn(async () => {}), + }; +} + +describe("Photon setup", () => { + it("owns portable provisioning and scaffolding", async () => { + const fake = createFakePrompter({ single: () => "portable" }); + const effects = deps(); + const state = { + agentName: "agent", + project: { kind: "unresolved" } as const, + projectPath: "/project", + }; + + const result = await PHOTON_CHANNEL_SETUP.setup({ + environment: photonSetupEnvironment("cli-missing", { kind: "unresolved" }), + state, + ui: createPhotonSetupUi({ + asker: asker({ + "photon-phone-number": "+15551234567", + "photon-webhook-base-url": "https://agent.example.com", + }), + prompter: fake.prompter, + }), + photonDeps: effects, + }); + + expect(result).toMatchObject({ kind: "done", state }); + expect(effects.provisionProject).toHaveBeenCalledWith( + expect.objectContaining({ phoneNumber: "+15551234567" }), + ); + expect(effects.registerWebhook).toHaveBeenCalledWith({ + projectId: "project-id", + projectSecret: "project-secret", + webhookUrl: "https://agent.example.com/eve/v1/photon", + }); + expect(effects.appendEnv).toHaveBeenCalledWith("/project/.env.local", { + IMESSAGE_PROJECT_ID: "project-id", + IMESSAGE_PROJECT_SECRET: "project-secret", + IMESSAGE_WEBHOOK_SECRET: "webhook-secret", + }); + expect(effects.writeTextFile).toHaveBeenCalledWith( + "/project/agent/channels/photon.ts", + expect.stringContaining('from "eve/channels/photon"'), + { force: undefined }, + ); + }); +}); diff --git a/packages/eve/src/setup/photon-setup.ts b/packages/eve/src/setup/photon-setup.ts new file mode 100644 index 000000000..83cc9dde8 --- /dev/null +++ b/packages/eve/src/setup/photon-setup.ts @@ -0,0 +1,328 @@ +import { join } from "node:path"; + +import { text } from "./ask.js"; +import { appendEnv } from "./append-env.js"; +import type { PhotonSetupIntegration } from "./photon-setup-integration.js"; +import { provisionPhotonConnector } from "./photon-connect.js"; +import { + provisionPhotonProject, + registerPhotonWebhook, + usePhotonProject, + validatePhotonPhoneNumber, + type PhotonManagedProject, +} from "./photon-management.js"; +import { readProjectLink } from "./project-resolution.js"; +import { openUrl } from "./primitives/open-url.js"; +import { deriveSlackConnectorSlug } from "./scaffold/index.js"; +import { writeTextFile } from "./scaffold/files.js"; +import { WizardCancelledError } from "./step.js"; +import { linkProject, pickProject, pickTeam } from "./vercel-project.js"; +import { createPromptCommandOutput } from "./cli/index.js"; + +interface PhotonSetupPlan { + credentials: "vercel-connect" | "environment"; + photonProject: "create" | { projectId: string; projectSecret: string }; + photonProjectName?: string; + webhookBaseUrl?: string; +} + +export interface PhotonSetupDeps { + appendEnv: typeof appendEnv; + deriveConnectorSlug: typeof deriveSlackConnectorSlug; + linkProject: typeof linkProject; + openUrl: typeof openUrl; + pickProject: typeof pickProject; + pickTeam: typeof pickTeam; + provisionConnector: typeof provisionPhotonConnector; + provisionProject: typeof provisionPhotonProject; + readProjectLink: typeof readProjectLink; + registerWebhook: typeof registerPhotonWebhook; + useProject: typeof usePhotonProject; + writeTextFile: typeof writeTextFile; +} + +const defaultDeps: PhotonSetupDeps = { + appendEnv, + deriveConnectorSlug: deriveSlackConnectorSlug, + linkProject, + openUrl, + pickProject, + pickTeam, + provisionConnector: provisionPhotonConnector, + provisionProject: provisionPhotonProject, + readProjectLink, + registerWebhook: registerPhotonWebhook, + useProject: usePhotonProject, + writeTextFile, +}; + +function connectTemplate(connectorUid: string): string { + return `import { connectPhotonCredentials } from "@vercel/connect/eve"; +import { photonChannel } from "eve/channels/photon"; + +export default photonChannel({ + credentials: connectPhotonCredentials(${JSON.stringify(connectorUid)}), +}); +`; +} + +const PORTABLE_TEMPLATE = `import { photonChannel } from "eve/channels/photon"; + +async function photonCredentials() { + const projectId = process.env.IMESSAGE_PROJECT_ID; + const projectSecret = process.env.IMESSAGE_PROJECT_SECRET; + if (!projectId || !projectSecret) throw new Error("Photon project credentials are required."); + return { projectId, projectSecret }; +} + +export default photonChannel({ + credentials: photonCredentials, + webhookSecret: process.env.IMESSAGE_WEBHOOK_SECRET, +}); +`; + +async function choosePhotonProject( + context: Parameters[0], +): Promise> { + const defaultName = `eve · ${context.state.agentName || "agent"}`; + const options = [ + { + value: "create" as const, + label: "Create a new Photon project", + hint: `Name: ${defaultName}`, + }, + { + value: "existing" as const, + label: "Use an existing Photon project", + hint: "Enter its project credentials", + }, + ]; + const editable = context.ui.prompter.selectEditable + ? await context.ui.prompter.selectEditable<"create" | "existing">({ + message: "Photon project", + options, + initialValue: "create", + editable: { + value: "create", + defaultValue: defaultName, + formatHint: (value) => `Name: ${value}`, + validate: (value) => + value.trim().length === 0 ? "Project name cannot be empty." : undefined, + }, + }) + : undefined; + const source = + editable?.value ?? + (await context.ui.prompter.select<"create" | "existing">({ + message: "Photon project", + options, + initialValue: "create", + })); + if (source === "existing") { + const projectId = await context.ui.asker.ask( + text({ key: "photon-project-id", message: "Photon project ID", required: true }), + ); + const projectSecret = await context.ui.asker.ask( + text({ + key: "photon-project-secret", + message: "Photon project secret", + required: true, + sensitive: true, + }), + ); + return { photonProject: { projectId: projectId.trim(), projectSecret: projectSecret.trim() } }; + } + + return { + photonProject: "create", + photonProjectName: editable?.kind === "edited" ? editable.text.trim() : defaultName, + }; +} + +async function chooseSetupPlan( + context: Parameters[0], +): Promise { + try { + const photon = await choosePhotonProject(context); + if (context.environment.vercel.kind === "available") { + return { credentials: "vercel-connect", ...photon }; + } + const destination = await context.ui.prompter.select<"vercel" | "portable">({ + message: "Where will this iMessage agent run?", + options: [ + { value: "vercel", label: "Vercel", hint: "Set up Vercel Connect and deploy" }, + { + value: "portable", + label: "Another host", + hint: "Use environment variables and your public URL", + }, + ], + }); + if (destination === "vercel") return { credentials: "vercel-connect", ...photon }; + + const webhookBaseUrl = await context.ui.asker.ask( + text({ + key: "photon-webhook-base-url", + message: "Public HTTPS URL for your deployed agent", + placeholder: "https://agent.example.com", + required: true, + validate(value) { + try { + const url = new URL(value.trim()); + return url.protocol === "https:" && url.pathname === "/" && !url.search && !url.hash + ? null + : "Enter an HTTPS origin without a path, for example https://agent.example.com"; + } catch { + return "Enter an HTTPS origin, for example https://agent.example.com"; + } + }, + }), + ); + return { + credentials: "environment", + webhookBaseUrl: webhookBaseUrl.trim().replace(/\/$/, ""), + ...photon, + }; + } catch (error) { + if (error instanceof WizardCancelledError) return "cancelled"; + throw error; + } +} + +async function resolvePhotonProject( + context: Parameters[0], + plan: PhotonSetupPlan, + phoneNumber: string, + deps: PhotonSetupDeps, +): Promise { + if (plan.photonProject !== "create") { + return deps.useProject({ ...plan.photonProject, phoneNumber }); + } + return deps.provisionProject({ + projectName: plan.photonProjectName ?? `eve · ${context.state.agentName || "agent"}`, + phoneNumber, + signal: context.signal, + onAuthorization(authorization) { + context.ui.prompter.log.message(`Authorize Photon: ${authorization.verificationUrl}`); + context.ui.prompter.log.message(`Photon code: ${authorization.userCode}`); + deps.openUrl(authorization.verificationUrl); + }, + }); +} + +async function ensureVercelProject( + context: Parameters[0], + projectRoot: string, + slug: string, + deps: PhotonSetupDeps, +) { + const existing = await deps.readProjectLink(projectRoot); + if (existing !== undefined) return existing; + const team = await deps.pickTeam(context.ui.prompter, projectRoot, undefined, { + signal: context.signal, + }); + const spec = await deps.pickProject(context.ui.prompter, projectRoot, team, { + allowCreateWhenEmpty: true, + suggestedName: slug, + signal: context.signal, + }); + await deps.linkProject( + context.ui.prompter, + projectRoot, + spec, + createPromptCommandOutput(context.ui.prompter.log), + { signal: context.signal }, + ); + const linked = await deps.readProjectLink(projectRoot); + if (linked === undefined) throw new Error("Vercel project linking failed. Photon setup stopped."); + return linked; +} + +async function setupPhoton( + context: Parameters[0], + plan: PhotonSetupPlan, + deps: PhotonSetupDeps, +): Promise<"created" | "cancelled"> { + const phoneNumber = await context.ui.asker.ask( + text({ + key: "photon-phone-number", + message: "Your iMessage phone number", + placeholder: "+15551234567", + required: true, + validate: validatePhotonPhoneNumber, + }), + ); + const projectRoot = context.state.projectPath; + const managedProject = await resolvePhotonProject(context, plan, phoneNumber, deps); + try { + const channelPath = join(projectRoot, "agent/channels/photon.ts"); + if (plan.credentials === "vercel-connect") { + const slug = await deps.deriveConnectorSlug(projectRoot, context.state.agentName); + const project = await ensureVercelProject(context, projectRoot, slug, deps); + const connector = await deps.provisionConnector({ + credentials: managedProject, + log: context.ui.prompter.log, + project, + projectRoot, + slug, + signal: context.signal, + }); + await deps.writeTextFile(channelPath, connectTemplate(connector.uid), { + force: context.force, + }); + } else { + const webhookUrl = `${plan.webhookBaseUrl}/eve/v1/photon`; + const webhookSecret = await deps.registerWebhook({ + projectId: managedProject.projectId, + projectSecret: managedProject.projectSecret, + webhookUrl, + }); + await deps.appendEnv(join(projectRoot, ".env.local"), { + IMESSAGE_PROJECT_ID: managedProject.projectId, + IMESSAGE_PROJECT_SECRET: managedProject.projectSecret, + IMESSAGE_WEBHOOK_SECRET: webhookSecret, + }); + await deps.writeTextFile(channelPath, PORTABLE_TEMPLATE, { force: context.force }); + context.ui.nextSteps([ + "Copy IMESSAGE_PROJECT_ID, IMESSAGE_PROJECT_SECRET, and IMESSAGE_WEBHOOK_SECRET from .env.local into your host's encrypted environment variables.", + "Deploy the agent at the public URL you provided. Photon is already configured to send signed webhooks to /eve/v1/photon.", + ]); + } + context.ui.prompter.log.success("Scaffolded channel: photon"); + if (managedProject.assignedPhoneNumber !== undefined) { + context.ui.prompter.note(managedProject.assignedPhoneNumber, "Text your agent", { + tone: "success", + }); + } + context.ui.prompter.note( + `https://app.photon.codes/dashboard/${managedProject.projectId}`, + "Photon project", + { tone: "success" }, + ); + return "created"; + } catch (error) { + await managedProject.cleanup().catch(() => {}); + throw error; + } +} + +/** Photon-managed project provisioning and channel scaffolding. */ +export const PHOTON_CHANNEL_SETUP: PhotonSetupIntegration = { + kind: "photon", + label: "Photon", + hint: "Messages through Photon", + async setup(context) { + try { + const plan = await chooseSetupPlan(context); + if (plan === "cancelled") return { kind: "cancelled" }; + await setupPhoton(context, plan, context.photonDeps ?? defaultDeps); + return { + kind: "done", + state: context.state, + }; + } catch (error) { + if (error instanceof WizardCancelledError) return { kind: "cancelled" }; + throw error; + } + }, +}; diff --git a/packages/eve/src/setup/primitives/run-vercel.test.ts b/packages/eve/src/setup/primitives/run-vercel.test.ts index c4fffce85..b95396d9d 100644 --- a/packages/eve/src/setup/primitives/run-vercel.test.ts +++ b/packages/eve/src/setup/primitives/run-vercel.test.ts @@ -22,6 +22,7 @@ const mockedSpawn = vi.mocked(spawn); * streams, close/error events, and a spyable `kill`. */ type ChildProcessDouble = ChildProcess & { + stdin: PassThrough; stdout: PassThrough; stderr: PassThrough; kill: ReturnType boolean>>; @@ -29,6 +30,7 @@ type ChildProcessDouble = ChildProcess & { function createChildProcess(): ChildProcessDouble { const child = new EventEmitter() as ChildProcessDouble; + child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.kill = vi.fn((_signal?: NodeJS.Signals | number) => true); @@ -144,6 +146,52 @@ describe("runVercel", () => { }); }); + test("writes supplied stdin without exposing it in argv", async () => { + const child = createChildProcess(); + mockSpawnReturn(child); + const inputChunks: Buffer[] = []; + child.stdin.on("data", (chunk: Buffer) => inputChunks.push(chunk)); + + const result = runVercelCaptureStdout( + ["connect", "create", "photon", "--data", "@-", "-F", "json"], + { + cwd: "/tmp/eve-agent", + nonInteractive: true, + stdin: '{"values":[{"value":"secret"}]}', + }, + ); + child.emit("close", 0); + + await expect(result).resolves.toEqual({ ok: true, stdout: "" }); + expectSpawnedVercel( + ["connect", "create", "photon", "--data", "@-", "-F", "json", "--non-interactive"], + { stdio: ["pipe", "pipe", "inherit"] }, + ); + expect(Buffer.concat(inputChunks).toString("utf8")).toBe('{"values":[{"value":"secret"}]}'); + }); + + test("settles as a failure when the child closes stdin before secrets are written", async () => { + const child = createChildProcess(); + mockSpawnReturn(child); + const onOutput = vi.fn(); + + const result = runVercelCaptureStdout(["connect", "create", "photon", "--data", "@-"], { + cwd: "/tmp/eve-agent", + nonInteractive: true, + stdin: '{"projectSecret":"secret"}', + onOutput, + }); + const error: NodeJS.ErrnoException = new Error("write EPIPE"); + error.code = "EPIPE"; + child.stdin.emit("error", error); + + await expect(result).resolves.toEqual({ ok: false, stdout: "" }); + expect(onOutput).toHaveBeenCalledWith({ + stream: "stderr", + text: "vercel connect create photon --data @- stdin failed: write EPIPE", + }); + }); + test("passes cancellation to the child and settles without a stale failure line", async () => { const child = createChildProcess(); mockSpawnReturn(child); diff --git a/packages/eve/src/setup/primitives/run-vercel.ts b/packages/eve/src/setup/primitives/run-vercel.ts index ef7c4b932..724db7fad 100644 --- a/packages/eve/src/setup/primitives/run-vercel.ts +++ b/packages/eve/src/setup/primitives/run-vercel.ts @@ -50,6 +50,8 @@ export interface RunVercelOptions { extraEnv?: Readonly>; /** Pass `--non-interactive` and close stdin so automation cannot stop on a prompt. */ nonInteractive?: boolean; + /** UTF-8 data written to stdin, used to keep connector secrets out of argv. */ + stdin?: string; /** Streams command output to a parent-owned renderer instead of writing outside it. */ onOutput?: ProcessOutputHandler; /** Aborts the Vercel CLI subprocess when its parent setup flow is interrupted. */ @@ -158,11 +160,26 @@ export function resolveVercelInvocation( : { command: localBinary, commandArgs: args }; } +function stdinMode(options: RunVercelOptions): "inherit" | "ignore" | "pipe" { + if (options.stdin !== undefined) return "pipe"; + return options.nonInteractive ? "ignore" : "inherit"; +} + +function writeStdin( + child: ChildProcess, + input: string | undefined, + onError: (error: NodeJS.ErrnoException) => void, +): void { + if (input === undefined || child.stdin === null) return; + child.stdin.once("error", onError); + child.stdin.end(input, "utf8"); +} + function stdioForRun( options: RunVercelOptions, -): ["inherit" | "ignore", "pipe", "pipe"] | "inherit" { - if (options.onOutput) { - return [options.nonInteractive ? "ignore" : "inherit", "pipe", "pipe"]; +): ["inherit" | "ignore" | "pipe", "pipe", "pipe"] | "inherit" { + if (options.onOutput || options.stdin !== undefined) { + return [stdinMode(options), "pipe", "pipe"]; } return options.nonInteractive ? ["ignore", "pipe", "pipe"] : "inherit"; } @@ -209,6 +226,10 @@ export async function runVercel(args: string[], options: RunVercelOptions): Prom reportFailure(timeoutMessage(args, options.timeoutMs ?? 0)); settle(false); }); + writeStdin(child, options.stdin, (error) => { + reportFailure(`vercel ${args.join(" ")} stdin failed: ${error.message}`); + settle(false); + }); child.on("error", (error: NodeJS.ErrnoException) => { if (isAbortError(error, options.signal)) { @@ -270,11 +291,7 @@ export async function runVercelCaptureStdout( const outputBuffer = options.onOutput && createProcessOutputBuffer(options.onOutput); const child = spawn(invocation.command, invocation.commandArgs, { cwd, - stdio: [ - options.nonInteractive ? "ignore" : "inherit", - "pipe", - options.onOutput ? "pipe" : "inherit", - ], + stdio: [stdinMode(options), "pipe", options.onOutput ? "pipe" : "inherit"], env: buildSpawnEnv(options.extraEnv ?? {}), shell: invocation.shell, signal: options.signal, @@ -309,6 +326,10 @@ export async function runVercelCaptureStdout( reportFailure(timeoutMessage(args, options.timeoutMs ?? 0)); settle(false); }); + writeStdin(child, options.stdin, (error) => { + reportFailure(`vercel ${args.join(" ")} stdin failed: ${error.message}`); + settle(false); + }); child.on("error", (error: NodeJS.ErrnoException) => { if (isAbortError(error, options.signal)) { @@ -392,7 +413,7 @@ export async function captureVercel( const outputBuffer = options.onOutput && createProcessOutputBuffer(options.onOutput); const child = spawn(invocation.command, invocation.commandArgs, { cwd, - stdio: ["ignore", "pipe", "pipe"], + stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], env: buildSpawnEnv(options.extraEnv ?? {}), shell: invocation.shell, signal: options.signal, @@ -429,6 +450,14 @@ export async function captureVercel( message: timeoutMessage(args, options.timeoutMs ?? 0), }); }); + writeStdin(child, options.stdin, (error) => { + fail({ + errno: error.code, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + message: `vercel ${args.join(" ")} stdin failed: ${error.message}`, + }); + }); child.on("error", (error: NodeJS.ErrnoException) => { if (isAbortError(error, options.signal)) { diff --git a/packages/eve/src/setup/prompter.ts b/packages/eve/src/setup/prompter.ts index af911b078..813a0b4f9 100644 --- a/packages/eve/src/setup/prompter.ts +++ b/packages/eve/src/setup/prompter.ts @@ -122,6 +122,7 @@ export interface SelectCommonOptions { * each hint on its own line below the label with a blank line between options — * for small action menus whose hints carry current values. "inline" keeps hints * on the label row, suppresses numeric shortcuts, and separates the trailing +<<<<<<< HEAD * completion action (e.g. the `/add` task list). */ hintLayout?: "stacked" | "inline"; diff --git a/packages/eve/src/setup/scaffold/channels-catalog.ts b/packages/eve/src/setup/scaffold/channels-catalog.ts index 65cf1e268..b8c079fa1 100644 --- a/packages/eve/src/setup/scaffold/channels-catalog.ts +++ b/packages/eve/src/setup/scaffold/channels-catalog.ts @@ -1,6 +1,10 @@ /** * Build-time catalog of channels used by programmatic setup and +<<<<<<< HEAD * `eve add channel/slack`. +======= + * `eve add`. +>>>>>>> d4584c90 (feat(eve): add guided Photon setup) * * 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/pnpm-lock.yaml b/pnpm-lock.yaml index 91c80a0fb..69cc2437a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ catalogs: specifier: 19.2.3 version: 19.2.3 '@vercel/connect': - specifier: 0.4.2 - version: 0.4.2 + specifier: 0.4.3 + version: 0.4.3 '@vercel/otel': specifier: 2.1.3 version: 2.1.3 @@ -239,7 +239,7 @@ importers: version: 0.2.0(ai@7.0.38(zod@4.4.3))(chat@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(react-native@0.86.0(@babel/core@7.29.0(supports-color@10.2.2))(@types/react@19.2.15)(bufferutil@4.1.0)(react@19.2.6)(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3) '@github-tools/eve-extension': specifier: ^0.1.0 - version: 0.1.0(@vercel/connect@0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + version: 0.1.0(@vercel/connect@0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) '@jetty/eve': specifier: ^0.3.0 version: 0.3.0(eve@packages+eve) @@ -293,7 +293,7 @@ importers: version: 0.2.4(ai@7.0.38(zod@4.4.3))(chat@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) '@vercel/connect': specifier: 'catalog:' - version: 0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + version: 0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) '@vercel/otel': specifier: 'catalog:' version: 2.1.3(@opentelemetry/api-logs@0.221.0)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2))(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)) @@ -381,7 +381,7 @@ importers: version: 0.41.2 '@vercel/connect': specifier: 'catalog:' - version: 0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + version: 0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) '@vercel/oidc': specifier: 3.8.0 version: 3.8.0 @@ -6889,12 +6889,15 @@ packages: '@vercel/cli-config@0.2.0': resolution: {integrity: sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ==} + '@vercel/cli-config@0.2.1': + resolution: {integrity: sha512-RhfyXmRLHdbnry8RJqHDc+5rGxMZ0bu+fpysZjtv3bE+BubpuwxTancHOKiH5zKQREsdwFVr3mOI2kOvxlOyxA==} + '@vercel/cli-exec@1.0.0': resolution: {integrity: sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug==} engines: {node: '>= 18'} - '@vercel/connect@0.4.2': - resolution: {integrity: sha512-3Lr2yFI2Z2NPR+T3DWOqrygBpxZUK6H+wCesijT2pV7VmuG8l5ZYSxk4tdzZwF4SWYJOXQFzcELtFLCGvBw5mg==} + '@vercel/connect@0.4.3': + resolution: {integrity: sha512-NDB5MhjvmeBXWAohLX7wKYnaI18pzvcqmPgSqdPHUcIdYNgHk8KlqjSkII92MgOb+zzP5t0J0yiQScwXKzkmow==} peerDependencies: '@ai-sdk/mcp': ^1 || ^2 '@auth/core': '>=0.37.0' @@ -7018,6 +7021,10 @@ packages: resolution: {integrity: sha512-r00laGW6Pv778RoR6M2NxX91ycSj+PBwVo+fOb9Bif+F0IyUKt25zrvBzfEzQpeAzbqOgPZyQibEWDdDFApd+A==} engines: {node: '>= 20'} + '@vercel/oidc@3.8.1': + resolution: {integrity: sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw==} + engines: {node: '>= 20'} + '@vercel/otel@2.1.3': resolution: {integrity: sha512-Ofvzs9qhftRD1YMLuPnhbXjQZG6IKrJ9AmEKmRHRGfoWlV89ed2gAOkvddkFiZDFZJm2rrFNdeZKRVxoCdnWiw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -16043,26 +16050,26 @@ snapshots: - react-native - supports-color - '@github-tools/eve-extension@0.1.0(@vercel/connect@0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)': + '@github-tools/eve-extension@0.1.0(@vercel/connect@0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)': dependencies: - '@github-tools/sdk': 1.8.2(@vercel/connect@0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)(zod@4.4.3) + '@github-tools/sdk': 1.8.2(@vercel/connect@0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)(zod@4.4.3) eve: link:packages/eve zod: 4.4.3 optionalDependencies: - '@vercel/connect': 0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + '@vercel/connect': 0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) transitivePeerDependencies: - '@ai-sdk/workflow' - '@workflow/ai' - ai - workflow - '@github-tools/sdk@1.8.2(@vercel/connect@0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)(zod@4.4.3)': + '@github-tools/sdk@1.8.2(@vercel/connect@0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)(zod@4.4.3)': dependencies: ai: 7.0.38(zod@4.4.3) octokit: 5.0.5 zod: 4.4.3 optionalDependencies: - '@vercel/connect': 0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + '@vercel/connect': 0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) eve: link:packages/eve '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(bufferutil@4.1.0)(supports-color@10.2.2)': @@ -17353,7 +17360,7 @@ snapshots: '@onkernel/eve-extension@0.1.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)': dependencies: - '@vercel/connect': 0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + '@vercel/connect': 0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) eve: link:packages/eve zod: 4.4.3 transitivePeerDependencies: @@ -20385,13 +20392,18 @@ snapshots: xdg-app-paths: 5.1.0 zod: 4.1.11 + '@vercel/cli-config@0.2.1': + dependencies: + xdg-app-paths: 5.1.0 + zod: 4.1.11 + '@vercel/cli-exec@1.0.0': dependencies: execa: 5.1.1 - '@vercel/connect@0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)': + '@vercel/connect@0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)': dependencies: - '@vercel/oidc': 3.8.0 + '@vercel/oidc': 3.8.1 optionalDependencies: '@ai-sdk/mcp': 2.0.16(zod@4.4.3) '@auth/core': 0.41.2 @@ -20677,6 +20689,12 @@ snapshots: '@vercel/cli-exec': 1.0.0 jose: 5.10.0 + '@vercel/oidc@3.8.1': + dependencies: + '@vercel/cli-config': 0.2.1 + '@vercel/cli-exec': 1.0.0 + jose: 5.10.0 + '@vercel/otel@2.1.3(@opentelemetry/api-logs@0.221.0)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2))(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e44b1dfe1..c21cc0551 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -36,7 +36,7 @@ catalog: "@types/node": "25.9.1" "@types/react": "19.2.15" "@types/react-dom": "19.2.3" - "@vercel/connect": "0.4.2" + "@vercel/connect": "0.4.3" "@vercel/otel": "2.1.3" "@vercel/sandbox": "2.8.0" ai: "^7.0.38" From 158571f080e39676097074f97806a9f3b451d812 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Wed, 29 Jul 2026 19:58:35 +0000 Subject: [PATCH 2/4] fix(eve): resolve Photon setup rebase conflicts Signed-off-by: owenkephart --- apps/docs/registry.json | 3 +- .../docs/scripts/validate-channel-registry.ts | 2 + docs/channels/overview.mdx | 4 - docs/channels/photon.mdx | 42 ++++++- docs/guides/dev-tui.md | 19 --- .../cli/commands/integration-setup.test.ts | 7 ++ .../commands/register-integration-commands.ts | 5 +- .../eve/src/cli/dev/tui/agent-header.test.ts | 1 + packages/eve/src/cli/dev/tui/agent-header.ts | 3 - .../src/cli/dev/tui/prompt-commands.test.ts | 8 -- .../eve/src/cli/dev/tui/prompt-commands.ts | 12 -- .../eve/src/cli/dev/tui/setup-commands.ts | 69 ----------- packages/eve/src/cli/run.ts | 6 - .../eve/src/setup/boxes/deploy-project.ts | 16 +-- packages/eve/src/setup/flows/in-project.ts | 4 - .../integrations/channels/environment.ts | 20 +++- .../eve/src/setup/integrations/channels/ui.ts | 8 +- .../eve/src/setup/photon-management.test.ts | 29 ----- packages/eve/src/setup/photon-management.ts | 24 ---- .../eve/src/setup/photon-setup-environment.ts | 4 +- packages/eve/src/setup/photon-setup-ui.ts | 4 +- packages/eve/src/setup/photon-setup.test.ts | 53 +++------ packages/eve/src/setup/photon-setup.ts | 112 +++--------------- .../src/setup/scaffold/channels-catalog.ts | 4 - 24 files changed, 110 insertions(+), 349 deletions(-) diff --git a/apps/docs/registry.json b/apps/docs/registry.json index c8d9ba71c..d0578852b 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -1217,7 +1217,8 @@ "meta": { "eve": { "setup": { - "command": "eve", + "package": "eve", + "bin": "eve", "args": ["integration", "setup", "photon"] }, "requires": ">=0.27.11" diff --git a/apps/docs/scripts/validate-channel-registry.ts b/apps/docs/scripts/validate-channel-registry.ts index 57799050d..acfeb78b1 100644 --- a/apps/docs/scripts/validate-channel-registry.ts +++ b/apps/docs/scripts/validate-channel-registry.ts @@ -43,6 +43,7 @@ const adapterDependenciesByCatalogSlug: Readonly> = { "chat-sdk-liveblocks": "@liveblocks/chat-sdk-adapter", "chat-sdk-linq": "@linqapp/chat-sdk-adapter", "chat-sdk-kapso": "@kapso/chat-adapter", + "chat-sdk-photon": "@photon-ai/chat-adapter-imessage", "chat-sdk-dial": "@getdial/chat-sdk-adapter", "chat-sdk-agentphone": "@agentphone/chat-sdk-adapter", "chat-sdk-lark": "@larksuite/vercel-chat-adapter", @@ -63,6 +64,7 @@ const targetSlugsByCatalogSlug: Readonly> = { "chat-sdk-liveblocks": "liveblocks", "chat-sdk-linq": "linq", "chat-sdk-kapso": "kapso", + "chat-sdk-photon": "imessage", "chat-sdk-dial": "dial", "chat-sdk-agentphone": "agentphone", "chat-sdk-lark": "lark", diff --git a/docs/channels/overview.mdx b/docs/channels/overview.mdx index 82056cd7c..cfab39c05 100644 --- a/docs/channels/overview.mdx +++ b/docs/channels/overview.mdx @@ -28,11 +28,7 @@ agent/ intake.ts ``` -<<<<<<< HEAD -Install a built-in channel with `eve add channel/slack` or `eve add channel/web`. You can also author the file by hand. -======= Install a channel from the registry with `eve add channel/photon`, `eve add channel/slack`, or `eve add channel/web`. You can also author the file by hand. ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) ## The eve HTTP channel (default) diff --git a/docs/channels/photon.mdx b/docs/channels/photon.mdx index 9dec08b18..5c92f3df1 100644 --- a/docs/channels/photon.mdx +++ b/docs/channels/photon.mdx @@ -4,8 +4,11 @@ description: Connect an eve agent to iMessage through Photon. --- Use `photonChannel` to receive and reply to iMessages through a Photon project. -With Vercel Connect, the channel resolves credentials lazily when the adapter -first initializes: + +For Vercel deployments, run `eve add channel/photon`. It creates or uses a +Photon project, registers your phone number, creates a Vercel Connect +connector, and configures Photon’s webhook through Connect. The channel resolves +credentials lazily when the adapter first initializes: ```ts title="agent/channels/photon.ts" import { connectPhotonCredentials } from "@vercel/connect/eve"; @@ -37,6 +40,35 @@ export default photonChannel({ ``` Set `route` to override the webhook path or `webhookVerifier` to use a different -trusted-forwarder verifier. For direct Photon webhooks, pass `webhookSecret` or -set `IMESSAGE_WEBHOOK_SECRET`; the signing secret takes precedence over the -default OIDC verifier. +trusted-forwarder verifier. + +## Other hosts + +For a host without Vercel Connect, configure Photon manually: + +1. Create a Photon iMessage project and register the phone number that will send + and receive messages. +2. Create a Photon webhook for your public `https://…/eve/v1/photon` URL and + save its signing secret. +3. Set `IMESSAGE_PROJECT_ID`, `IMESSAGE_PROJECT_SECRET`, and + `IMESSAGE_WEBHOOK_SECRET` in the host’s encrypted environment variables. + +Then configure the channel with lazy environment-backed credentials: + +```ts title="agent/channels/photon.ts" +import { photonChannel } from "eve/channels/photon"; + +export default photonChannel({ + async credentials() { + const projectId = process.env.IMESSAGE_PROJECT_ID; + const projectSecret = process.env.IMESSAGE_PROJECT_SECRET; + if (!projectId || !projectSecret) throw new Error("Photon project credentials are required."); + return { projectId, projectSecret }; + }, + webhookSecret: process.env.IMESSAGE_WEBHOOK_SECRET, +}); +``` + +For direct Photon webhooks, pass `webhookSecret` or set +`IMESSAGE_WEBHOOK_SECRET`; the signing secret takes precedence over the default +OIDC verifier. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index fb26b9f24..fe170df29 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -13,10 +13,7 @@ On startup the TUI prints a brand line with your agent's name, plus a rotating t ```text eve weather-agent -<<<<<<< HEAD Use /add to install integrations from the registry. -======= ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) ``` 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. @@ -38,11 +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). | -<<<<<<< HEAD | `/add` | Searches registry integrations or adds an item address entered directly. | -======= -| `/connect` | Shows the Vercel Connect MCP catalog and configures the server you pick. See [Add a connection](#add-a-connection). | ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) | `/deploy` | Ships the agent to Vercel production, linking the directory first when it is unlinked. | | `/vc:install` | Installs the Vercel CLI. Available locally and on a remote session. | | `/vc:login` | Logs in to Vercel locally. On a remote session, resolves the deployment's project, refreshes its OIDC token, and confirms any required Trusted Sources rule. | @@ -51,11 +44,7 @@ Each command echoes as an invocation line, asks through a bordered panel that ta | `/exit` | Quits the TUI. | | `/help` | Lists the commands available for the current local or remote session. | -<<<<<<< HEAD `/model`, `/add`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. -======= -`/model`, `/connect`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`. ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) ### Configure the model and provider @@ -65,19 +54,11 @@ 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. -<<<<<<< HEAD ### 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. Connection registry items install their definition and then configure Vercel Connect through the same `/add` flow. If the directory is unlinked, setup first offers to create or link a project. Run `eve add connection/` for the equivalent CLI flow. -======= -### Add a connection - -`/connect` shows a searchable list of MCP servers available through Vercel Connect. Already-authored connections remain checked. Logged-out users are directed to `/vc:login`. When the directory is not linked, selecting a server opens the same team and project flow used by `/model`, including creating a project or linking an existing one. - -For a selected server, eve first tries to attach the provider's canonical connector. If that fails, choose an existing connector from a searchable list or create one with a specific name. The fallback stays scoped to the team selected by the linked project. A connector created by the current attempt is removed if attachment or connection-file patching fails. Successful setup writes `agent/connections/.ts`, records the attached connector UID, installs the new dependency so the dev server can load it, then returns to the main prompt. ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) ## Keyboard shortcuts diff --git a/packages/eve/src/cli/commands/integration-setup.test.ts b/packages/eve/src/cli/commands/integration-setup.test.ts index 1373733b4..6c8a37b50 100644 --- a/packages/eve/src/cli/commands/integration-setup.test.ts +++ b/packages/eve/src/cli/commands/integration-setup.test.ts @@ -30,6 +30,7 @@ describe("parseIntegrationSetupInvocation", () => { args: ["integration", "setup", "photon", "--yes"], }), ).toEqual({ kind: "channel", channel: "photon", yes: true }); +<<<<<<< HEAD }); it("parses connection setup invocations", () => { @@ -39,14 +40,20 @@ describe("parseIntegrationSetupInvocation", () => { args: ["integration", "connect", "linear", "mcp.linear.app"], }), ).toEqual({ kind: "connection", slug: "linear", service: "mcp.linear.app" }); +======= +>>>>>>> 89831392 (fix(eve): resolve Photon setup rebase conflicts) }); it("rejects commands and flags outside the trusted grammar", () => { expect( +<<<<<<< HEAD parseIntegrationSetupInvocation({ command: "sh", args: ["integration", "setup", "photon"], }), +======= + parseIntegrationSetupInvocation({ command: "sh", args: ["integration", "setup", "photon"] }), +>>>>>>> 89831392 (fix(eve): resolve Photon setup rebase conflicts) ).toBeUndefined(); expect( parseIntegrationSetupInvocation({ diff --git a/packages/eve/src/cli/commands/register-integration-commands.ts b/packages/eve/src/cli/commands/register-integration-commands.ts index cc1717c7e..5bd79409d 100644 --- a/packages/eve/src/cli/commands/register-integration-commands.ts +++ b/packages/eve/src/cli/commands/register-integration-commands.ts @@ -17,9 +17,10 @@ export function registerIntegrationCommands(input: { integration .command("setup ") - .action(async (kind: string) => { + .option("-y, --yes") + .action(async (kind: string, options: { yes?: boolean }) => { const { runIntegrationSetupCommand } = await import("./integration-setup.js"); - await runIntegrationSetupCommand(logger, appRoot, kind); + await runIntegrationSetupCommand(logger, appRoot, kind, { yes: options.yes }); }); integration diff --git a/packages/eve/src/cli/dev/tui/agent-header.test.ts b/packages/eve/src/cli/dev/tui/agent-header.test.ts index e41d55653..aedc41819 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.test.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentInfoResult, AgentInfoToolEntry } from "#client/index.js"; +import { stripAnsi } from "#cli/ui/terminal-text.js"; import { AGENT_HEADER_TIPS, buildAgentHeader, pickAgentHeaderTip } from "./agent-header.js"; import { createTheme } from "./theme.js"; diff --git a/packages/eve/src/cli/dev/tui/agent-header.ts b/packages/eve/src/cli/dev/tui/agent-header.ts index 955a440fa..98b068770 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.ts @@ -28,10 +28,7 @@ export interface AgentHeaderInput { * slash commands, so callers only attach a tip to local `eve dev` sessions. */ export const AGENT_HEADER_TIPS: readonly string[] = [ -<<<<<<< HEAD "Use /add to install integrations from the registry.", -======= ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) "Use /deploy to see your agent go live.", "Type /help to see every command.", ]; 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 e2c2ed7ca..a551d0c1c 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.test.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.test.ts @@ -93,11 +93,7 @@ describe("promptCommandsFor", () => { it("exposes project commands only for local sessions", () => { const names = promptCommandsFor("local").map((command) => command.name); expect(names).toContain("model"); -<<<<<<< HEAD expect(names).toContain("add"); -======= - expect(names).toContain("connect"); ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) expect(names).toContain("deploy"); expect(names).toContain("vc:install"); expect(names).toContain("vc:login"); @@ -110,11 +106,7 @@ describe("promptCommandsFor", () => { expect(names).toContain("vc:login"); expect(names).not.toContain("vc:auth"); expect(names).not.toContain("model"); -<<<<<<< HEAD expect(names).not.toContain("add"); -======= - expect(names).not.toContain("connect"); ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) 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 17daaaa24..53b778128 100644 --- a/packages/eve/src/cli/dev/tui/prompt-commands.ts +++ b/packages/eve/src/cli/dev/tui/prompt-commands.ts @@ -1,8 +1,4 @@ -<<<<<<< HEAD export type PromptCommandExtensionName = "model" | "add" | "deploy" | "vc:install" | "vc:login"; -======= -export type PromptCommandExtensionName = "model" | "connect" | "deploy" | "vc:install" | "vc:login"; ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) type PromptCommandTarget = "local" | "remote"; @@ -96,19 +92,11 @@ const PROMPT_COMMAND_DEFINITIONS = [ targets: ["local", "remote"], }, { -<<<<<<< HEAD name: "add", aliases: [], description: "Browse and add registry integrations", takesArgument: false, build: () => ({ type: "extension", name: "add", argument: "" }), -======= - name: "connect", - aliases: [], - description: "Add an MCP server through Vercel Connect", - takesArgument: false, - build: () => ({ type: "extension", name: "connect", argument: "" }), ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) targets: ["local"], }, { diff --git a/packages/eve/src/cli/dev/tui/setup-commands.ts b/packages/eve/src/cli/dev/tui/setup-commands.ts index ac9f557c8..34a2344ab 100644 --- a/packages/eve/src/cli/dev/tui/setup-commands.ts +++ b/packages/eve/src/cli/dev/tui/setup-commands.ts @@ -1,8 +1,4 @@ import { HumanActionRequiredError } from "#setup/human-action.js"; -<<<<<<< HEAD -======= -import { runConnectionsFlow } from "#setup/flows/connections.js"; ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) import { runDeployFlow } from "#setup/flows/deploy.js"; import { runInstallVercelCliFlow, @@ -11,10 +7,7 @@ import { import { runLoginFlow, type LoginFlowResult } from "#setup/flows/login.js"; import { runModelFlow, type ModelProviderOutcome } from "#setup/flows/model.js"; import { runProviderFlow, type ProviderPicker } from "#setup/flows/provider.js"; -<<<<<<< HEAD import { runRegistryFlow } from "#setup/flows/registry.js"; -======= ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) import type { Prompter } from "#setup/prompter.js"; import { WizardCancelledError } from "#setup/step.js"; @@ -34,11 +27,7 @@ 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" }, -<<<<<<< HEAD add: { title: "Add to your agent", indicator: "pulse" }, -======= - connect: { title: "Agent connections", indicator: "pulse" }, ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) deploy: { title: "Deploy to Vercel", indicator: "spinner" }, } satisfies Record; @@ -70,11 +59,7 @@ export interface TuiSetupFlows { runInstallVercelCliFlow: typeof runInstallVercelCliFlow; runLoginFlow: typeof runLoginFlow; runModelFlow: typeof runModelFlow; -<<<<<<< HEAD runRegistryFlow: typeof runRegistryFlow; -======= - runConnectionsFlow: typeof runConnectionsFlow; ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) runDeployFlow: typeof runDeployFlow; } @@ -134,11 +119,7 @@ function muteableRenderer( } /** -<<<<<<< HEAD * Runs one TUI setup command (/model, /add, /deploy) over the -======= - * Runs one TUI setup command (/model, /connect, /deploy) over the ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) * 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 @@ -187,11 +168,7 @@ async function executeSetupCommand( runInstallVercelCliFlow, runLoginFlow, runModelFlow, -<<<<<<< HEAD runRegistryFlow, -======= - runConnectionsFlow, ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) runDeployFlow, ...input.flows, }; @@ -250,7 +227,6 @@ async function executeSetupCommand( } return outcome; } -<<<<<<< HEAD case "add": { const result = await flows.runRegistryFlow({ appRoot, prompter, signal }); if (result.kind === "cancelled") { @@ -262,46 +238,6 @@ async function executeSetupCommand( tone: "success", preserveFlowDiagnostics: true, }; -======= - case "connect": { - const result = await flows.runConnectionsFlow({ - appRoot, - prompter, - signal, - disabledConnectionReasons: input.disabledConnectionReasons, - }); - switch (result.kind) { - case "cancelled": - return { - message: "/connect dismissed.", - preserveFlowDiagnostics: true, - effect: { kind: "model-access-changed" }, - }; - case "failed": - return { - message: - result.addedConnections.length === 0 - ? `/connect failed: ${result.message}` - : `Connection files changed, but /connect failed: ${result.message}`, - preserveFlowDiagnostics: true, - effect: - result.addedConnections.length === 0 - ? { kind: "model-access-changed" } - : { kind: "connection-added" }, - }; - case "done": - return { - message: - result.addedConnections.length === 0 - ? "No connections added." - : `Connections added: ${result.addedConnections.join(", ")}.`, - preserveFlowDiagnostics: true, - effect: - result.addedConnections.length === 0 - ? { kind: "model-access-changed" } - : { kind: "connection-added" }, - }; ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) } return { message: "No registry items added.", preserveFlowDiagnostics: true }; } @@ -442,14 +378,9 @@ function vercelCliUpgradeFailureMessage(command: string, reason?: string): strin /** * Translates a Vercel {@link HumanActionRequiredError} into the in-TUI routing -<<<<<<< HEAD * message, or `undefined` for anything else. One translator so every path that * can surface a provisioning action, routing login, forbidden-scope, and CLI * recovery actions the same way rather than -======= - * message, or `undefined` for anything else. The command catch uses one - * translator for login, forbidden-scope, and CLI recovery actions rather than ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) * leaking the raw error text. */ function vercelActionOutcome(error: unknown, command: string): TuiSetupCommandResult | undefined { diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index 1b4432f1b..bd16ec4ef 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -206,13 +206,7 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm program .command("channels") -<<<<<<< HEAD .description("Manage user-authored channels in the current project.") -======= - .description("Manage user-authored channels in the current project."); - - channels ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) .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 0da84ceed..e46118287 100644 --- a/packages/eve/src/setup/boxes/deploy-project.ts +++ b/packages/eve/src/setup/boxes/deploy-project.ts @@ -36,11 +36,7 @@ export interface DeployProjectOptions { skip?: boolean; /** * Run even without a planned or detected project, linking interactively from -<<<<<<< HEAD * inside `perform` (the `eve add channel/slack` composition; onboarding always has -======= - * inside `perform` (the former channel setup composition; onboarding always has ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) * a plan or skips deploy with the channels). */ ensureLinkedProject?: "interactive-vercel-link"; @@ -79,22 +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 -<<<<<<< HEAD * `eve add channel/slack` composition), it falls back to the interactive bare -======= - * former channel setup composition), it falls back to the interactive bare ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) * `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 -<<<<<<< HEAD * every deploy surface — `eve add channel/slack`, the dev TUI `/deploy`, onboarding — -======= - * every deploy surface — the dev TUI `/deploy` and onboarding — ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) * gets the reconcile without composing a separate box. */ export function deployProject( @@ -155,7 +143,11 @@ 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 +<<<<<<< HEAD // only place this server-side setting is touched, so a local integration add +======= + // only place this server-side setting is touched, so a local `/channels` add +>>>>>>> be3b1c95 (fix(eve): resolve Photon setup rebase conflicts) // 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/flows/in-project.ts b/packages/eve/src/setup/flows/in-project.ts index 6eab75701..c47881643 100644 --- a/packages/eve/src/setup/flows/in-project.ts +++ b/packages/eve/src/setup/flows/in-project.ts @@ -12,11 +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 -<<<<<<< HEAD * `eve add channel/slack`. -======= - * `eve add`. ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) */ export function inProjectSetupState( appRoot: string, diff --git a/packages/eve/src/setup/integrations/channels/environment.ts b/packages/eve/src/setup/integrations/channels/environment.ts index df4a474e4..ef49d01ef 100644 --- a/packages/eve/src/setup/integrations/channels/environment.ts +++ b/packages/eve/src/setup/integrations/channels/environment.ts @@ -1,15 +1,15 @@ import type { ProjectResolution } from "../../project-resolution.js"; import type { VercelAuthStatus } from "../../vercel-project.js"; -/** Read-only hosting facts available to Photon-owned setup hooks. */ -export interface PhotonSetupEnvironment { +/** Read-only hosting facts available to channel-owned setup hooks. */ +export interface ChannelSetupEnvironment { vercel: | { kind: "available"; project: ProjectResolution } | { kind: "unavailable"; reason: Exclude }; } /** Describes the result of the read-only Vercel capability probe. */ -export function describePhotonSetupEnvironment(environment: PhotonSetupEnvironment): string { +export function describeChannelSetupEnvironment(environment: ChannelSetupEnvironment): string { if (environment.vercel.kind === "available") { switch (environment.vercel.project.kind) { case "deployed": @@ -22,19 +22,27 @@ export function describePhotonSetupEnvironment(environment: PhotonSetupEnvironme } switch (environment.vercel.reason) { case "logged-out": +<<<<<<< HEAD return "No authenticated Vercel account found; choose Vercel Connect or portable credentials."; case "cli-missing": return "Vercel CLI not found; choose Vercel Connect or portable credentials."; case "unavailable": return "Could not verify the Vercel account; choose Vercel Connect or portable credentials."; +======= + return "No authenticated Vercel account found; using portable channel setup."; + case "cli-missing": + return "Vercel CLI not found; using portable channel setup."; + case "unavailable": + return "Could not verify the Vercel account; using portable channel setup."; +>>>>>>> 98c81ace (fix(eve): resolve Photon setup rebase conflicts) } } -/** Builds Photon setup facts from the independent Vercel probes. */ -export function photonSetupEnvironment( +/** Builds channel setup facts from the independent Vercel probes. */ +export function channelSetupEnvironment( authStatus: VercelAuthStatus, project: ProjectResolution, -): PhotonSetupEnvironment { +): ChannelSetupEnvironment { return authStatus === "authenticated" ? { vercel: { kind: "available", project } } : { vercel: { kind: "unavailable", reason: authStatus } }; diff --git a/packages/eve/src/setup/integrations/channels/ui.ts b/packages/eve/src/setup/integrations/channels/ui.ts index c79582ef5..e44b9101e 100644 --- a/packages/eve/src/setup/integrations/channels/ui.ts +++ b/packages/eve/src/setup/integrations/channels/ui.ts @@ -1,16 +1,16 @@ import { confirm, SkippedSignal, type Asker } from "../../ask.js"; import type { Prompter } from "../../prompter.js"; -/** UI capabilities available to a Photon-owned setup hook. */ -export interface PhotonSetupUi { +/** UI capabilities available to a channel-owned setup hook. */ +export interface ChannelSetupUi { readonly asker: Asker; readonly prompter: Prompter; confirm(input: { key: string; message: string; recommended?: boolean }): Promise; nextSteps(lines: readonly string[]): void; } -/** Adapts the shared setup asker and prompter to the Photon setup UI. */ -export function createPhotonSetupUi(input: { asker: Asker; prompter: Prompter }): PhotonSetupUi { +/** Adapts the shared setup asker and prompter to the channel hook UI. */ +export function createChannelSetupUi(input: { asker: Asker; prompter: Prompter }): ChannelSetupUi { return { ...input, async confirm(question) { diff --git a/packages/eve/src/setup/photon-management.test.ts b/packages/eve/src/setup/photon-management.test.ts index fbabb2389..cfd76a391 100644 --- a/packages/eve/src/setup/photon-management.test.ts +++ b/packages/eve/src/setup/photon-management.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test, vi } from "vitest"; import { provisionPhotonProject, - registerPhotonWebhook, usePhotonProject, validatePhotonPhoneNumber, } from "./photon-management.js"; @@ -87,34 +86,6 @@ describe("Photon management provisioning", () => { expect(fetch).toHaveBeenCalledOnce(); }); - test("registers the Connect URL and returns the one-time secret", async () => { - const fetch = vi.fn(async () => - response({ - succeed: true, - data: { id: "webhook-id", signingSecret: "webhook-secret" }, - }), - ); - - await expect( - registerPhotonWebhook({ - projectId: "project-id", - projectSecret: "project-secret", - webhookUrl: "https://connect.vercel.com/trigger/scl_123", - deps: { fetch }, - }), - ).resolves.toBe("webhook-secret"); - - expect(fetch).toHaveBeenCalledWith( - "https://spectrum.photon.codes/projects/project-id/webhooks/", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - webhookUrl: "https://connect.vercel.com/trigger/scl_123", - }), - }), - ); - }); - test.each([ ["+15551234567", null], ["+442079460123", null], diff --git a/packages/eve/src/setup/photon-management.ts b/packages/eve/src/setup/photon-management.ts index 509edbf58..af03e5260 100644 --- a/packages/eve/src/setup/photon-management.ts +++ b/packages/eve/src/setup/photon-management.ts @@ -273,27 +273,3 @@ export async function usePhotonProject( ? { projectId, projectSecret, cleanup } : { projectId, projectSecret, assignedPhoneNumber, cleanup }; } - -/** Registers the webhook URL and returns its one-time signing secret. */ -export async function registerPhotonWebhook(input: { - projectId: string; - projectSecret: string; - webhookUrl: string; - deps?: Pick; -}): Promise { - const request = input.deps?.fetch ?? fetch; - const response = await request( - `${PHOTON_SPECTRUM_HOST}/projects/${encodeURIComponent(input.projectId)}/webhooks/`, - { - method: "POST", - headers: basic(input.projectId, input.projectSecret), - body: JSON.stringify({ webhookUrl: input.webhookUrl }), - }, - ); - const body = await json(response, "webhook registration"); - const registration = record(body["data"] ?? body); - if (typeof registration["signingSecret"] !== "string") { - throw new Error("Photon did not return the webhook signing secret."); - } - return registration["signingSecret"]; -} diff --git a/packages/eve/src/setup/photon-setup-environment.ts b/packages/eve/src/setup/photon-setup-environment.ts index 6fdb37eed..06db93f82 100644 --- a/packages/eve/src/setup/photon-setup-environment.ts +++ b/packages/eve/src/setup/photon-setup-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 Photon-owned setup hooks. */ export interface PhotonSetupEnvironment { diff --git a/packages/eve/src/setup/photon-setup-ui.ts b/packages/eve/src/setup/photon-setup-ui.ts index c79582ef5..7993bcfae 100644 --- a/packages/eve/src/setup/photon-setup-ui.ts +++ b/packages/eve/src/setup/photon-setup-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 Photon-owned setup hook. */ export interface PhotonSetupUi { diff --git a/packages/eve/src/setup/photon-setup.test.ts b/packages/eve/src/setup/photon-setup.test.ts index e7792d26a..5cc4c26d2 100644 --- a/packages/eve/src/setup/photon-setup.test.ts +++ b/packages/eve/src/setup/photon-setup.test.ts @@ -17,7 +17,6 @@ function asker(answers: Record): Asker { function deps(): PhotonSetupDeps { return { - appendEnv: vi.fn(async () => ({ written: [], skipped: [] })), deriveConnectorSlug: vi.fn(async () => "agent" as never), linkProject: vi.fn(), openUrl: vi.fn(), @@ -30,53 +29,29 @@ function deps(): PhotonSetupDeps { cleanup: vi.fn(async () => {}), })), readProjectLink: vi.fn(), - registerWebhook: vi.fn(async () => "webhook-secret"), useProject: vi.fn(), writeTextFile: vi.fn(async () => {}), }; } describe("Photon setup", () => { - it("owns portable provisioning and scaffolding", async () => { - const fake = createFakePrompter({ single: () => "portable" }); + it("directs hosts without Vercel Connect to manual Photon setup", async () => { + const fake = createFakePrompter(); const effects = deps(); - const state = { - agentName: "agent", - project: { kind: "unresolved" } as const, - projectPath: "/project", - }; - const result = await PHOTON_CHANNEL_SETUP.setup({ - environment: photonSetupEnvironment("cli-missing", { kind: "unresolved" }), - state, - ui: createPhotonSetupUi({ - asker: asker({ - "photon-phone-number": "+15551234567", - "photon-webhook-base-url": "https://agent.example.com", - }), - prompter: fake.prompter, + await expect( + PHOTON_CHANNEL_SETUP.setup({ + environment: photonSetupEnvironment("cli-missing", { kind: "unresolved" }), + state: { + agentName: "agent", + project: { kind: "unresolved" }, + projectPath: "/project", + }, + ui: createPhotonSetupUi({ asker: asker({}), prompter: fake.prompter }), + photonDeps: effects, }), - photonDeps: effects, - }); + ).rejects.toThrow("requires Vercel Connect"); - expect(result).toMatchObject({ kind: "done", state }); - expect(effects.provisionProject).toHaveBeenCalledWith( - expect.objectContaining({ phoneNumber: "+15551234567" }), - ); - expect(effects.registerWebhook).toHaveBeenCalledWith({ - projectId: "project-id", - projectSecret: "project-secret", - webhookUrl: "https://agent.example.com/eve/v1/photon", - }); - expect(effects.appendEnv).toHaveBeenCalledWith("/project/.env.local", { - IMESSAGE_PROJECT_ID: "project-id", - IMESSAGE_PROJECT_SECRET: "project-secret", - IMESSAGE_WEBHOOK_SECRET: "webhook-secret", - }); - expect(effects.writeTextFile).toHaveBeenCalledWith( - "/project/agent/channels/photon.ts", - expect.stringContaining('from "eve/channels/photon"'), - { force: undefined }, - ); + expect(effects.provisionProject).not.toHaveBeenCalled(); }); }); diff --git a/packages/eve/src/setup/photon-setup.ts b/packages/eve/src/setup/photon-setup.ts index 83cc9dde8..12746b24c 100644 --- a/packages/eve/src/setup/photon-setup.ts +++ b/packages/eve/src/setup/photon-setup.ts @@ -1,12 +1,10 @@ import { join } from "node:path"; import { text } from "./ask.js"; -import { appendEnv } from "./append-env.js"; import type { PhotonSetupIntegration } from "./photon-setup-integration.js"; import { provisionPhotonConnector } from "./photon-connect.js"; import { provisionPhotonProject, - registerPhotonWebhook, usePhotonProject, validatePhotonPhoneNumber, type PhotonManagedProject, @@ -20,14 +18,11 @@ import { linkProject, pickProject, pickTeam } from "./vercel-project.js"; import { createPromptCommandOutput } from "./cli/index.js"; interface PhotonSetupPlan { - credentials: "vercel-connect" | "environment"; photonProject: "create" | { projectId: string; projectSecret: string }; photonProjectName?: string; - webhookBaseUrl?: string; } export interface PhotonSetupDeps { - appendEnv: typeof appendEnv; deriveConnectorSlug: typeof deriveSlackConnectorSlug; linkProject: typeof linkProject; openUrl: typeof openUrl; @@ -36,13 +31,11 @@ export interface PhotonSetupDeps { provisionConnector: typeof provisionPhotonConnector; provisionProject: typeof provisionPhotonProject; readProjectLink: typeof readProjectLink; - registerWebhook: typeof registerPhotonWebhook; useProject: typeof usePhotonProject; writeTextFile: typeof writeTextFile; } const defaultDeps: PhotonSetupDeps = { - appendEnv, deriveConnectorSlug: deriveSlackConnectorSlug, linkProject, openUrl, @@ -51,7 +44,6 @@ const defaultDeps: PhotonSetupDeps = { provisionConnector: provisionPhotonConnector, provisionProject: provisionPhotonProject, readProjectLink, - registerWebhook: registerPhotonWebhook, useProject: usePhotonProject, writeTextFile, }; @@ -66,21 +58,6 @@ export default photonChannel({ `; } -const PORTABLE_TEMPLATE = `import { photonChannel } from "eve/channels/photon"; - -async function photonCredentials() { - const projectId = process.env.IMESSAGE_PROJECT_ID; - const projectSecret = process.env.IMESSAGE_PROJECT_SECRET; - if (!projectId || !projectSecret) throw new Error("Photon project credentials are required."); - return { projectId, projectSecret }; -} - -export default photonChannel({ - credentials: photonCredentials, - webhookSecret: process.env.IMESSAGE_WEBHOOK_SECRET, -}); -`; - async function choosePhotonProject( context: Parameters[0], ): Promise> { @@ -143,46 +120,12 @@ async function chooseSetupPlan( context: Parameters[0], ): Promise { try { - const photon = await choosePhotonProject(context); - if (context.environment.vercel.kind === "available") { - return { credentials: "vercel-connect", ...photon }; + if (context.environment.vercel.kind === "unavailable") { + throw new Error( + "Guided Photon setup requires Vercel Connect. Configure Photon manually for another host; see https://eve.dev/docs/channels/photon.", + ); } - const destination = await context.ui.prompter.select<"vercel" | "portable">({ - message: "Where will this iMessage agent run?", - options: [ - { value: "vercel", label: "Vercel", hint: "Set up Vercel Connect and deploy" }, - { - value: "portable", - label: "Another host", - hint: "Use environment variables and your public URL", - }, - ], - }); - if (destination === "vercel") return { credentials: "vercel-connect", ...photon }; - - const webhookBaseUrl = await context.ui.asker.ask( - text({ - key: "photon-webhook-base-url", - message: "Public HTTPS URL for your deployed agent", - placeholder: "https://agent.example.com", - required: true, - validate(value) { - try { - const url = new URL(value.trim()); - return url.protocol === "https:" && url.pathname === "/" && !url.search && !url.hash - ? null - : "Enter an HTTPS origin without a path, for example https://agent.example.com"; - } catch { - return "Enter an HTTPS origin, for example https://agent.example.com"; - } - }, - }), - ); - return { - credentials: "environment", - webhookBaseUrl: webhookBaseUrl.trim().replace(/\/$/, ""), - ...photon, - }; + return choosePhotonProject(context); } catch (error) { if (error instanceof WizardCancelledError) return "cancelled"; throw error; @@ -256,38 +199,19 @@ async function setupPhoton( const managedProject = await resolvePhotonProject(context, plan, phoneNumber, deps); try { const channelPath = join(projectRoot, "agent/channels/photon.ts"); - if (plan.credentials === "vercel-connect") { - const slug = await deps.deriveConnectorSlug(projectRoot, context.state.agentName); - const project = await ensureVercelProject(context, projectRoot, slug, deps); - const connector = await deps.provisionConnector({ - credentials: managedProject, - log: context.ui.prompter.log, - project, - projectRoot, - slug, - signal: context.signal, - }); - await deps.writeTextFile(channelPath, connectTemplate(connector.uid), { - force: context.force, - }); - } else { - const webhookUrl = `${plan.webhookBaseUrl}/eve/v1/photon`; - const webhookSecret = await deps.registerWebhook({ - projectId: managedProject.projectId, - projectSecret: managedProject.projectSecret, - webhookUrl, - }); - await deps.appendEnv(join(projectRoot, ".env.local"), { - IMESSAGE_PROJECT_ID: managedProject.projectId, - IMESSAGE_PROJECT_SECRET: managedProject.projectSecret, - IMESSAGE_WEBHOOK_SECRET: webhookSecret, - }); - await deps.writeTextFile(channelPath, PORTABLE_TEMPLATE, { force: context.force }); - context.ui.nextSteps([ - "Copy IMESSAGE_PROJECT_ID, IMESSAGE_PROJECT_SECRET, and IMESSAGE_WEBHOOK_SECRET from .env.local into your host's encrypted environment variables.", - "Deploy the agent at the public URL you provided. Photon is already configured to send signed webhooks to /eve/v1/photon.", - ]); - } + const slug = await deps.deriveConnectorSlug(projectRoot, context.state.agentName); + const project = await ensureVercelProject(context, projectRoot, slug, deps); + const connector = await deps.provisionConnector({ + credentials: managedProject, + log: context.ui.prompter.log, + project, + projectRoot, + slug, + signal: context.signal, + }); + await deps.writeTextFile(channelPath, connectTemplate(connector.uid), { + force: context.force, + }); context.ui.prompter.log.success("Scaffolded channel: photon"); if (managedProject.assignedPhoneNumber !== undefined) { context.ui.prompter.note(managedProject.assignedPhoneNumber, "Text your agent", { diff --git a/packages/eve/src/setup/scaffold/channels-catalog.ts b/packages/eve/src/setup/scaffold/channels-catalog.ts index b8c079fa1..65cf1e268 100644 --- a/packages/eve/src/setup/scaffold/channels-catalog.ts +++ b/packages/eve/src/setup/scaffold/channels-catalog.ts @@ -1,10 +1,6 @@ /** * Build-time catalog of channels used by programmatic setup and -<<<<<<< HEAD * `eve add channel/slack`. -======= - * `eve add`. ->>>>>>> d4584c90 (feat(eve): add guided Photon setup) * * 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 From a6d672d0e48bf6a7b319dbe76b6e0f491876f2b1 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Wed, 29 Jul 2026 20:16:40 +0000 Subject: [PATCH 3/4] fix(eve): remove Photon setup conflict markers Signed-off-by: owenkephart --- apps/docs/public/r/channel/photon.json | 4 +- apps/docs/registry.json | 3 +- .../docs/scripts/validate-channel-registry.ts | 20 ++- docs/channels/photon.mdx | 23 ++-- .../cli/commands/integration-setup.test.ts | 80 +++++------ .../eve/src/cli/commands/integration-setup.ts | 1 - .../eve/src/setup/boxes/deploy-project.ts | 4 - .../integrations/channels/environment.ts | 8 -- .../setup/integrations/channels/index.test.ts | 3 +- packages/eve/src/setup/photon-connect.ts | 7 +- .../eve/src/setup/photon-setup-environment.ts | 6 +- packages/eve/src/setup/photon-setup.test.ts | 45 ++++-- packages/eve/src/setup/photon-setup.ts | 129 ++++++++++-------- packages/eve/src/setup/prompter.ts | 1 - 14 files changed, 188 insertions(+), 146 deletions(-) diff --git a/apps/docs/public/r/channel/photon.json b/apps/docs/public/r/channel/photon.json index 1eacd6cfb..1f8bd9410 100644 --- a/apps/docs/public/r/channel/photon.json +++ b/apps/docs/public/r/channel/photon.json @@ -4,11 +4,13 @@ "type": "registry:item", "title": "Photon", "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", - "dependencies": ["@vercel/connect@0.4.3"], + "dependencies": ["@vercel/connect@0.5.0"], "meta": { "eve": { "setup": { "command": "eve", + "package": "eve", + "bin": "eve", "args": ["integration", "setup", "photon"] }, "requires": ">=0.27.11" diff --git a/apps/docs/registry.json b/apps/docs/registry.json index d0578852b..49907c134 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -1213,10 +1213,11 @@ "type": "registry:item", "title": "Photon", "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", - "dependencies": ["@vercel/connect@0.4.3"], + "dependencies": ["@vercel/connect@0.5.0"], "meta": { "eve": { "setup": { + "command": "eve", "package": "eve", "bin": "eve", "args": ["integration", "setup", "photon"] diff --git a/apps/docs/scripts/validate-channel-registry.ts b/apps/docs/scripts/validate-channel-registry.ts index acfeb78b1..cb96421f0 100644 --- a/apps/docs/scripts/validate-channel-registry.ts +++ b/apps/docs/scripts/validate-channel-registry.ts @@ -15,6 +15,7 @@ interface RegistryItem { meta?: { eve?: { setup?: { + command?: string; package?: string; bin?: string; args?: string[]; @@ -43,7 +44,6 @@ const adapterDependenciesByCatalogSlug: Readonly> = { "chat-sdk-liveblocks": "@liveblocks/chat-sdk-adapter", "chat-sdk-linq": "@linqapp/chat-sdk-adapter", "chat-sdk-kapso": "@kapso/chat-adapter", - "chat-sdk-photon": "@photon-ai/chat-adapter-imessage", "chat-sdk-dial": "@getdial/chat-sdk-adapter", "chat-sdk-agentphone": "@agentphone/chat-sdk-adapter", "chat-sdk-lark": "@larksuite/vercel-chat-adapter", @@ -64,7 +64,6 @@ const targetSlugsByCatalogSlug: Readonly> = { "chat-sdk-liveblocks": "liveblocks", "chat-sdk-linq": "linq", "chat-sdk-kapso": "kapso", - "chat-sdk-photon": "imessage", "chat-sdk-dial": "dial", "chat-sdk-agentphone": "agentphone", "chat-sdk-lark": "lark", @@ -88,15 +87,28 @@ if (JSON.stringify(actualSlugs) !== JSON.stringify(expectedSlugs)) { } for (const [index, item] of items.entries()) { + const setup = item.meta?.eve?.setup; + if ( + setup !== undefined && + (setup.command === undefined || + setup.package === undefined || + setup.bin === undefined || + setup.args === undefined) + ) { + throw new Error( + `Registry item "${item.name}" setup must declare command, package, bin, and args during the migration.`, + ); + } + const entry = galleryEntries[index]; if (entry === undefined) throw new Error(`Unexpected channel registry item "${item.name}".`); const registrySlug = expectedSlugs[index]; if (entry.slug === "slack" || entry.slug === "eve" || entry.slug === "photon") { - const setup = item.meta?.eve?.setup; const expectedArgs = ["integration", "setup", registrySlug]; if ( - setup?.package !== "eve" || + setup?.command !== "eve" || + setup.package !== "eve" || setup.bin !== "eve" || JSON.stringify(setup.args) !== JSON.stringify(expectedArgs) ) { diff --git a/docs/channels/photon.mdx b/docs/channels/photon.mdx index 5c92f3df1..6b8f97640 100644 --- a/docs/channels/photon.mdx +++ b/docs/channels/photon.mdx @@ -5,10 +5,11 @@ description: Connect an eve agent to iMessage through Photon. Use `photonChannel` to receive and reply to iMessages through a Photon project. -For Vercel deployments, run `eve add channel/photon`. It creates or uses a -Photon project, registers your phone number, creates a Vercel Connect -connector, and configures Photon’s webhook through Connect. The channel resolves -credentials lazily when the adapter first initializes: +Run `eve add channel/photon` to create or use a Photon project and register +your phone number. It then asks whether to configure Vercel Connect or portable +credentials. With Vercel Connect, eve creates the connector and configures +Photon’s webhook through Connect. The channel resolves credentials lazily when +the adapter first initializes: ```ts title="agent/channels/photon.ts" import { connectPhotonCredentials } from "@vercel/connect/eve"; @@ -44,16 +45,18 @@ trusted-forwarder verifier. ## Other hosts -For a host without Vercel Connect, configure Photon manually: +For a host without Vercel Connect, choose **Use portable credentials** during +`eve add channel/photon`. eve scaffolds the channel and writes +`IMESSAGE_PROJECT_ID` and `IMESSAGE_PROJECT_SECRET` to `.env.local`. -1. Create a Photon iMessage project and register the phone number that will send - and receive messages. -2. Create a Photon webhook for your public `https://…/eve/v1/photon` URL and - save its signing secret. +After deploying the agent: + +1. Create a Photon webhook for your public `https://…/eve/v1/photon` URL. +2. Copy its signing secret into `IMESSAGE_WEBHOOK_SECRET`. 3. Set `IMESSAGE_PROJECT_ID`, `IMESSAGE_PROJECT_SECRET`, and `IMESSAGE_WEBHOOK_SECRET` in the host’s encrypted environment variables. -Then configure the channel with lazy environment-backed credentials: +To configure the channel by hand, use lazy environment-backed credentials: ```ts title="agent/channels/photon.ts" import { photonChannel } from "eve/channels/photon"; diff --git a/packages/eve/src/cli/commands/integration-setup.test.ts b/packages/eve/src/cli/commands/integration-setup.test.ts index 6c8a37b50..bea43b95c 100644 --- a/packages/eve/src/cli/commands/integration-setup.test.ts +++ b/packages/eve/src/cli/commands/integration-setup.test.ts @@ -1,9 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - parseIntegrationSetupInvocation, - runIntegrationSetupCommand, -} from "./integration-setup.js"; +import type { AddChannelsDeps } from "#setup/integrations/channels/setup.js"; + +import { runIntegrationSetupCommand } from "./integration-setup.js"; import type { RegistryCommandLogger } from "./registry.js"; const { isEveProject } = vi.hoisted(() => ({ isEveProject: vi.fn(async () => true) })); @@ -22,56 +21,45 @@ afterEach(() => { process.exitCode = undefined; }); -describe("parseIntegrationSetupInvocation", () => { - it("parses trusted registry setup invocations", () => { - expect( - parseIntegrationSetupInvocation({ - command: "eve", - args: ["integration", "setup", "photon", "--yes"], - }), - ).toEqual({ kind: "channel", channel: "photon", yes: true }); -<<<<<<< HEAD - }); +describe("runIntegrationSetupCommand", () => { + it("does not let --yes select portable Slack credentials", async () => { + const output = logger(); + const ensureChannel = vi.fn(); - it("parses connection setup invocations", () => { - expect( - parseIntegrationSetupInvocation({ - command: "eve", - args: ["integration", "connect", "linear", "mcp.linear.app"], - }), - ).toEqual({ kind: "connection", slug: "linear", service: "mcp.linear.app" }); -======= ->>>>>>> 89831392 (fix(eve): resolve Photon setup rebase conflicts) - }); + await runIntegrationSetupCommand( + output, + "/project", + "slack", + { yes: true }, + { + detectDeployment: vi.fn(async () => ({ state: "unlinked" as const })), + getVercelAuthStatus: vi.fn(async () => "logged-out" as const), + createPrompter: () => { + throw new Error("Slack credential selection should remain interactive."); + }, + addChannelsDeps: { + ensureChannel, + deriveSlackConnectorSlug: vi.fn(), + provisionSlackbot: vi.fn(), + reconcileSlackUid: vi.fn(), + detectPackageManager: vi.fn(), + runPackageManagerInstall: vi.fn(), + ensureLinkedVercelProject: vi.fn(), + }, + }, + ); - it("rejects commands and flags outside the trusted grammar", () => { - expect( -<<<<<<< HEAD - parseIntegrationSetupInvocation({ - command: "sh", - args: ["integration", "setup", "photon"], - }), -======= - parseIntegrationSetupInvocation({ command: "sh", args: ["integration", "setup", "photon"] }), ->>>>>>> 89831392 (fix(eve): resolve Photon setup rebase conflicts) - ).toBeUndefined(); - expect( - parseIntegrationSetupInvocation({ - command: "eve", - args: ["integration", "setup", "photon", "--force"], - }), - ).toBeUndefined(); + expect(output.errors).toEqual(["Slack credential selection should remain interactive."]); + expect(ensureChannel).not.toHaveBeenCalled(); }); -}); -describe("runIntegrationSetupCommand", () => { - it("rejects setup kinds outside Photon", async () => { + it("rejects unsupported setup kinds", async () => { const output = logger(); - await runIntegrationSetupCommand(output, "/project", "web"); + await runIntegrationSetupCommand(output, "/project", "discord"); expect(output.errors).toEqual([ - 'Integration setup "web" is not available in this version of eve. Upgrade eve and try again.', + 'Integration setup "discord" is not available in this version of eve. Upgrade eve and try again.', ]); }); }); diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index b9fdebd5f..d53f80d4a 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -102,7 +102,6 @@ export async function runIntegrationSetupCommand( }, ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), presetCreateSlackbot: options.yes ? true : undefined, - presetPortableCredentials: options.yes ? true : undefined, skipDependencyMutation: true, deps: dependencies.addChannelsDeps, signal: options.signal, diff --git a/packages/eve/src/setup/boxes/deploy-project.ts b/packages/eve/src/setup/boxes/deploy-project.ts index e46118287..1b24f97c3 100644 --- a/packages/eve/src/setup/boxes/deploy-project.ts +++ b/packages/eve/src/setup/boxes/deploy-project.ts @@ -143,11 +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 -<<<<<<< HEAD // only place this server-side setting is touched, so a local integration add -======= - // only place this server-side setting is touched, so a local `/channels` add ->>>>>>> be3b1c95 (fix(eve): resolve Photon setup rebase conflicts) // 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/integrations/channels/environment.ts b/packages/eve/src/setup/integrations/channels/environment.ts index ef49d01ef..3f887302c 100644 --- a/packages/eve/src/setup/integrations/channels/environment.ts +++ b/packages/eve/src/setup/integrations/channels/environment.ts @@ -22,19 +22,11 @@ export function describeChannelSetupEnvironment(environment: ChannelSetupEnviron } switch (environment.vercel.reason) { case "logged-out": -<<<<<<< HEAD return "No authenticated Vercel account found; choose Vercel Connect or portable credentials."; case "cli-missing": return "Vercel CLI not found; choose Vercel Connect or portable credentials."; case "unavailable": return "Could not verify the Vercel account; choose Vercel Connect or portable credentials."; -======= - return "No authenticated Vercel account found; using portable channel setup."; - case "cli-missing": - return "Vercel CLI not found; using portable channel setup."; - case "unavailable": - return "Could not verify the Vercel account; using portable channel setup."; ->>>>>>> 98c81ace (fix(eve): resolve Photon setup rebase conflicts) } } diff --git a/packages/eve/src/setup/integrations/channels/index.test.ts b/packages/eve/src/setup/integrations/channels/index.test.ts index 5e84be3cf..e7357bf75 100644 --- a/packages/eve/src/setup/integrations/channels/index.test.ts +++ b/packages/eve/src/setup/integrations/channels/index.test.ts @@ -61,8 +61,7 @@ describe("channel setup integrations", () => { source: "default", })), runPackageManagerInstall: vi.fn(), - runVercel: vi.fn(), - detectDeployment: vi.fn(), + ensureLinkedVercelProject: vi.fn(), }, }); diff --git a/packages/eve/src/setup/photon-connect.ts b/packages/eve/src/setup/photon-connect.ts index deabb44e5..44dd5f798 100644 --- a/packages/eve/src/setup/photon-connect.ts +++ b/packages/eve/src/setup/photon-connect.ts @@ -70,7 +70,12 @@ function createData(credentials: PhotonProjectCredentials): string { } function requireCreatedConnector(result: RunVercelCaptureResult): PhotonConnectorRef { - if (!result.ok) throw new Error("Photon connector creation failed."); + if (!result.ok) { + const detail = result.stderr?.trim() || result.stdout.trim(); + throw new Error( + detail ? `Photon connector creation failed: ${detail}` : "Photon connector creation failed.", + ); + } const connector = parseCreatedPhotonConnector(result.stdout); if (connector === undefined) { throw new Error("Vercel returned an invalid Photon connector after creation."); diff --git a/packages/eve/src/setup/photon-setup-environment.ts b/packages/eve/src/setup/photon-setup-environment.ts index 06db93f82..58da491ed 100644 --- a/packages/eve/src/setup/photon-setup-environment.ts +++ b/packages/eve/src/setup/photon-setup-environment.ts @@ -22,11 +22,11 @@ export function describePhotonSetupEnvironment(environment: PhotonSetupEnvironme } switch (environment.vercel.reason) { case "logged-out": - return "No authenticated Vercel account found; using portable Photon setup."; + return "No authenticated Vercel account found; choose Vercel Connect or portable Photon credentials."; case "cli-missing": - return "Vercel CLI not found; using portable Photon setup."; + return "Vercel CLI not found; choose Vercel Connect or portable Photon credentials."; case "unavailable": - return "Could not verify the Vercel account; using portable Photon setup."; + return "Could not verify the Vercel account; choose Vercel Connect or portable Photon credentials."; } } diff --git a/packages/eve/src/setup/photon-setup.test.ts b/packages/eve/src/setup/photon-setup.test.ts index 5cc4c26d2..445f2744a 100644 --- a/packages/eve/src/setup/photon-setup.test.ts +++ b/packages/eve/src/setup/photon-setup.test.ts @@ -17,26 +17,24 @@ function asker(answers: Record): Asker { function deps(): PhotonSetupDeps { return { + appendEnv: vi.fn(async () => ({ written: [], skipped: [] })), deriveConnectorSlug: vi.fn(async () => "agent" as never), - linkProject: vi.fn(), + ensureLinkedVercelProject: vi.fn(async () => ({ orgId: "team-id", projectId: "project-id" })), openUrl: vi.fn(), - pickProject: vi.fn(), - pickTeam: vi.fn(), provisionConnector: vi.fn(), provisionProject: vi.fn(async () => ({ projectId: "project-id", projectSecret: "project-secret", cleanup: vi.fn(async () => {}), })), - readProjectLink: vi.fn(), useProject: vi.fn(), writeTextFile: vi.fn(async () => {}), }; } describe("Photon setup", () => { - it("directs hosts without Vercel Connect to manual Photon setup", async () => { - const fake = createFakePrompter(); + it("scaffolds portable credentials when Vercel Connect is unavailable", async () => { + const fake = createFakePrompter({ single: () => "portable" }); const effects = deps(); await expect( @@ -47,11 +45,40 @@ describe("Photon setup", () => { project: { kind: "unresolved" }, projectPath: "/project", }, - ui: createPhotonSetupUi({ asker: asker({}), prompter: fake.prompter }), + ui: createPhotonSetupUi({ + asker: asker({ "photon-phone-number": "+15551234567" }), + prompter: fake.prompter, + }), photonDeps: effects, }), - ).rejects.toThrow("requires Vercel Connect"); + ).resolves.toMatchObject({ kind: "done" }); + + expect(effects.appendEnv).toHaveBeenCalledWith("/project/.env.local", { + IMESSAGE_PROJECT_ID: "project-id", + IMESSAGE_PROJECT_SECRET: "project-secret", + }); + expect(effects.provisionConnector).not.toHaveBeenCalled(); + expect(effects.writeTextFile).toHaveBeenCalledWith( + "/project/agent/channels/photon.ts", + expect.stringContaining("IMESSAGE_WEBHOOK_SECRET"), + { force: undefined }, + ); + }); + + it("requires Vercel login when Connect is selected without an authenticated CLI", async () => { + const fake = createFakePrompter({ single: () => "vercel" }); - expect(effects.provisionProject).not.toHaveBeenCalled(); + await expect( + PHOTON_CHANNEL_SETUP.setup({ + environment: photonSetupEnvironment("cli-missing", { kind: "unresolved" }), + state: { + agentName: "agent", + project: { kind: "unresolved" }, + projectPath: "/project", + }, + ui: createPhotonSetupUi({ asker: asker({}), prompter: fake.prompter }), + photonDeps: deps(), + }), + ).rejects.toThrow("vercel login"); }); }); diff --git a/packages/eve/src/setup/photon-setup.ts b/packages/eve/src/setup/photon-setup.ts index 12746b24c..f3b62c364 100644 --- a/packages/eve/src/setup/photon-setup.ts +++ b/packages/eve/src/setup/photon-setup.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import { text } from "./ask.js"; +import { appendEnv } from "./append-env.js"; import type { PhotonSetupIntegration } from "./photon-setup-integration.js"; import { provisionPhotonConnector } from "./photon-connect.js"; import { @@ -9,45 +10,55 @@ import { validatePhotonPhoneNumber, type PhotonManagedProject, } from "./photon-management.js"; -import { readProjectLink } from "./project-resolution.js"; import { openUrl } from "./primitives/open-url.js"; import { deriveSlackConnectorSlug } from "./scaffold/index.js"; import { writeTextFile } from "./scaffold/files.js"; import { WizardCancelledError } from "./step.js"; -import { linkProject, pickProject, pickTeam } from "./vercel-project.js"; -import { createPromptCommandOutput } from "./cli/index.js"; +import { ensureLinkedVercelProject } from "./vercel-project.js"; interface PhotonSetupPlan { + credentials: "vercel-connect" | "environment"; photonProject: "create" | { projectId: string; projectSecret: string }; photonProjectName?: string; } export interface PhotonSetupDeps { + appendEnv: typeof appendEnv; deriveConnectorSlug: typeof deriveSlackConnectorSlug; - linkProject: typeof linkProject; + ensureLinkedVercelProject: typeof ensureLinkedVercelProject; openUrl: typeof openUrl; - pickProject: typeof pickProject; - pickTeam: typeof pickTeam; provisionConnector: typeof provisionPhotonConnector; provisionProject: typeof provisionPhotonProject; - readProjectLink: typeof readProjectLink; useProject: typeof usePhotonProject; writeTextFile: typeof writeTextFile; } const defaultDeps: PhotonSetupDeps = { + appendEnv, deriveConnectorSlug: deriveSlackConnectorSlug, - linkProject, + ensureLinkedVercelProject, openUrl, - pickProject, - pickTeam, provisionConnector: provisionPhotonConnector, provisionProject: provisionPhotonProject, - readProjectLink, useProject: usePhotonProject, writeTextFile, }; +const PORTABLE_TEMPLATE = `import { photonChannel } from "eve/channels/photon"; + +async function photonCredentials() { + const projectId = process.env.IMESSAGE_PROJECT_ID; + const projectSecret = process.env.IMESSAGE_PROJECT_SECRET; + if (!projectId || !projectSecret) throw new Error("Photon project credentials are required."); + return { projectId, projectSecret }; +} + +export default photonChannel({ + credentials: photonCredentials, + webhookSecret: process.env.IMESSAGE_WEBHOOK_SECRET, +}); +`; + function connectTemplate(connectorUid: string): string { return `import { connectPhotonCredentials } from "@vercel/connect/eve"; import { photonChannel } from "eve/channels/photon"; @@ -120,12 +131,32 @@ async function chooseSetupPlan( context: Parameters[0], ): Promise { try { - if (context.environment.vercel.kind === "unavailable") { + const destination = await context.ui.prompter.select<"vercel" | "portable">({ + message: "How would you like to configure Photon?", + options: [ + { + value: "vercel", + label: "Set up Vercel Connect", + hint: + context.environment.vercel.kind === "available" + ? "Link this project and configure Photon automatically" + : "Log in to Vercel and link this project", + }, + { + value: "portable", + label: "Use portable credentials", + hint: "Configure the Photon webhook manually after deployment", + }, + ], + initialValue: context.environment.vercel.kind === "available" ? "vercel" : "portable", + }); + if (destination === "vercel" && context.environment.vercel.kind === "unavailable") { throw new Error( - "Guided Photon setup requires Vercel Connect. Configure Photon manually for another host; see https://eve.dev/docs/channels/photon.", + "Vercel Connect requires an authenticated Vercel CLI. Run `vercel login`, then retry Photon setup.", ); } - return choosePhotonProject(context); + const photon = await choosePhotonProject(context); + return { credentials: destination === "vercel" ? "vercel-connect" : "environment", ...photon }; } catch (error) { if (error instanceof WizardCancelledError) return "cancelled"; throw error; @@ -153,34 +184,6 @@ async function resolvePhotonProject( }); } -async function ensureVercelProject( - context: Parameters[0], - projectRoot: string, - slug: string, - deps: PhotonSetupDeps, -) { - const existing = await deps.readProjectLink(projectRoot); - if (existing !== undefined) return existing; - const team = await deps.pickTeam(context.ui.prompter, projectRoot, undefined, { - signal: context.signal, - }); - const spec = await deps.pickProject(context.ui.prompter, projectRoot, team, { - allowCreateWhenEmpty: true, - suggestedName: slug, - signal: context.signal, - }); - await deps.linkProject( - context.ui.prompter, - projectRoot, - spec, - createPromptCommandOutput(context.ui.prompter.log), - { signal: context.signal }, - ); - const linked = await deps.readProjectLink(projectRoot); - if (linked === undefined) throw new Error("Vercel project linking failed. Photon setup stopped."); - return linked; -} - async function setupPhoton( context: Parameters[0], plan: PhotonSetupPlan, @@ -199,19 +202,35 @@ async function setupPhoton( const managedProject = await resolvePhotonProject(context, plan, phoneNumber, deps); try { const channelPath = join(projectRoot, "agent/channels/photon.ts"); - const slug = await deps.deriveConnectorSlug(projectRoot, context.state.agentName); - const project = await ensureVercelProject(context, projectRoot, slug, deps); - const connector = await deps.provisionConnector({ - credentials: managedProject, - log: context.ui.prompter.log, - project, - projectRoot, - slug, - signal: context.signal, - }); - await deps.writeTextFile(channelPath, connectTemplate(connector.uid), { - force: context.force, - }); + if (plan.credentials === "vercel-connect") { + const slug = await deps.deriveConnectorSlug(projectRoot, context.state.agentName); + const project = await deps.ensureLinkedVercelProject({ + projectRoot, + prompter: context.ui.prompter, + signal: context.signal, + }); + const connector = await deps.provisionConnector({ + credentials: managedProject, + log: context.ui.prompter.log, + project, + projectRoot, + slug, + signal: context.signal, + }); + await deps.writeTextFile(channelPath, connectTemplate(connector.uid), { + force: context.force, + }); + } else { + await deps.appendEnv(join(projectRoot, ".env.local"), { + IMESSAGE_PROJECT_ID: managedProject.projectId, + IMESSAGE_PROJECT_SECRET: managedProject.projectSecret, + }); + await deps.writeTextFile(channelPath, PORTABLE_TEMPLATE, { force: context.force }); + context.ui.nextSteps([ + "Deploy the agent, then create a Photon webhook pointing to https:///eve/v1/photon.", + "Copy the webhook signing secret into IMESSAGE_WEBHOOK_SECRET, alongside IMESSAGE_PROJECT_ID and IMESSAGE_PROJECT_SECRET, in your host's encrypted environment variables.", + ]); + } context.ui.prompter.log.success("Scaffolded channel: photon"); if (managedProject.assignedPhoneNumber !== undefined) { context.ui.prompter.note(managedProject.assignedPhoneNumber, "Text your agent", { diff --git a/packages/eve/src/setup/prompter.ts b/packages/eve/src/setup/prompter.ts index 813a0b4f9..af911b078 100644 --- a/packages/eve/src/setup/prompter.ts +++ b/packages/eve/src/setup/prompter.ts @@ -122,7 +122,6 @@ export interface SelectCommonOptions { * each hint on its own line below the label with a blank line between options — * for small action menus whose hints carry current values. "inline" keeps hints * on the label row, suppresses numeric shortcuts, and separates the trailing -<<<<<<< HEAD * completion action (e.g. the `/add` task list). */ hintLayout?: "stacked" | "inline"; From d4ef58338c4d96b8ba39774015c4f41407ec3ce9 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Wed, 29 Jul 2026 18:56:45 -0700 Subject: [PATCH 4/4] fix(eve): stabilize Photon inbound messages Signed-off-by: owenkephart --- .changeset/calm-photons-queue.md | 5 +++ .../channels/photon/inboundContent.test.ts | 40 +++++++++++++++++++ .../public/channels/photon/inboundContent.ts | 13 ++++++ .../public/channels/photon/photonChannel.ts | 7 +++- 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 .changeset/calm-photons-queue.md create mode 100644 packages/eve/src/public/channels/photon/inboundContent.test.ts create mode 100644 packages/eve/src/public/channels/photon/inboundContent.ts diff --git a/.changeset/calm-photons-queue.md b/.changeset/calm-photons-queue.md new file mode 100644 index 000000000..5e2fb9ef2 --- /dev/null +++ b/.changeset/calm-photons-queue.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Queue overlapping Photon messages instead of dropping them, and ignore inbound iMessage events that contain no model-visible content. diff --git a/packages/eve/src/public/channels/photon/inboundContent.test.ts b/packages/eve/src/public/channels/photon/inboundContent.test.ts new file mode 100644 index 000000000..c031c075e --- /dev/null +++ b/packages/eve/src/public/channels/photon/inboundContent.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { Message } from "#compiled/chat/index.js"; +import { photonInboundContent } from "#public/channels/photon/inboundContent.js"; + +function message(text: string, attachments: Message["attachments"] = []): Message { + return new Message({ + attachments, + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text, + threadId: "thread-id", + }); +} + +describe("photonInboundContent", () => { + it("returns plain text", () => { + expect(photonInboundContent(message("hello"))).toBe("hello"); + }); + + it("drops blank messages", () => { + expect(photonInboundContent(message(" \n"))).toBeUndefined(); + }); + + it("drops attachment-only messages when Photon provides no attachment URL", () => { + expect( + photonInboundContent( + message("", [ + { + mimeType: "image/jpeg", + name: "photo.jpg", + size: 10, + type: "image", + }, + ]), + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/eve/src/public/channels/photon/inboundContent.ts b/packages/eve/src/public/channels/photon/inboundContent.ts new file mode 100644 index 000000000..6f5dec1c6 --- /dev/null +++ b/packages/eve/src/public/channels/photon/inboundContent.ts @@ -0,0 +1,13 @@ +import type { UserContent } from "ai"; + +import type { Message } from "#compiled/chat/index.js"; +import { messageToUserContent } from "#public/channels/chat-sdk/index.js"; + +/** Returns model-visible Photon content, or `undefined` for non-message events. */ +export function photonInboundContent(message: Message): string | UserContent | undefined { + const content = messageToUserContent(message); + if (typeof content === "string") { + return content.trim().length > 0 ? content : undefined; + } + return content.length > 0 ? content : undefined; +} diff --git a/packages/eve/src/public/channels/photon/photonChannel.ts b/packages/eve/src/public/channels/photon/photonChannel.ts index 0e9db2e90..4374c01b5 100644 --- a/packages/eve/src/public/channels/photon/photonChannel.ts +++ b/packages/eve/src/public/channels/photon/photonChannel.ts @@ -2,7 +2,6 @@ import type { SessionAuthContext } from "#channel/types.js"; import { vercelOidc } from "#public/channels/auth.js"; import { chatSdkChannel, - messageToUserContent, type ChatSdkChannel, type ChatSdkChannelBridge, type ChatSdkChannelEvents, @@ -15,6 +14,7 @@ import { type iMessageCredentialProvider, type iMessageWebhookVerifier, } from "#compiled/@photon-ai/chat-adapter-imessage/index.js"; +import { photonInboundContent } from "#public/channels/photon/inboundContent.js"; /** Photon project credentials used by {@link photonChannel}. */ export type PhotonChannelCredentials = iMessageCredentialProvider; @@ -83,6 +83,7 @@ export function photonChannel(config: PhotonChannelConfig): PhotonChannel { }); const bridge = chatSdkChannel({ adapters: { imessage }, + concurrency: "queue", events: config.events, routes: { imessage: config.route ?? "/eve/v1/photon" }, state: createMemoryState(), @@ -115,11 +116,13 @@ async function dispatchMessage( const result = await onMessage({ thread }, message); if (result === null) return; await markReadBestEffort(bridge.bot.getAdapter("imessage"), thread, message); + const content = photonInboundContent(message); + if (content === undefined) return; if (subscribe) await thread.subscribe(); await bridge.send( { context: [...(result.context ?? [])], - message: messageToUserContent(message), + message: content, }, { auth: result.auth, thread }, );