From dc135471a05ad3f1c7df9ba21d048c25da90e32b Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 09:23:49 -0700 Subject: [PATCH 01/12] 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 | 19 ++ apps/docs/registry.json | 26 +- .../docs/registry/channels/chat-sdk-photon.ts | 27 -- .../docs/scripts/validate-channel-registry.ts | 4 +- docs/channels/overview.mdx | 2 +- docs/channels/photon.mdx | 45 ++- packages/eve-catalog/src/index.ts | 10 +- packages/eve/src/setup/photon-connect.test.ts | 109 +++++++ packages/eve/src/setup/photon-connect.ts | 178 ++++++++++++ .../eve/src/setup/photon-management.test.ts | 114 ++++++++ packages/eve/src/setup/photon-management.ts | 275 ++++++++++++++++++ .../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 | 84 ++++++ packages/eve/src/setup/photon-setup.ts | 271 +++++++++++++++++ .../src/setup/primitives/run-vercel.test.ts | 48 +++ .../eve/src/setup/primitives/run-vercel.ts | 47 ++- pnpm-lock.yaml | 48 ++- pnpm-workspace.yaml | 2 +- 24 files changed, 1364 insertions(+), 135 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..1f8bd9410 --- /dev/null +++ b/apps/docs/public/r/channel/photon.json @@ -0,0 +1,19 @@ +{ + "$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.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 dda92c36c..49907c134 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -1209,22 +1209,22 @@ ] }, { - "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.5.0"], + "meta": { + "eve": { + "setup": { + "command": "eve", + "package": "eve", + "bin": "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..cb96421f0 100644 --- a/apps/docs/scripts/validate-channel-registry.ts +++ b/apps/docs/scripts/validate-channel-registry.ts @@ -44,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", @@ -65,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", @@ -106,7 +104,7 @@ for (const [index, item] of items.entries()) { 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 expectedArgs = ["integration", "setup", registrySlug]; if ( setup?.command !== "eve" || diff --git a/docs/channels/overview.mdx b/docs/channels/overview.mdx index 892ed8ea7..cfab39c05 100644 --- a/docs/channels/overview.mdx +++ b/docs/channels/overview.mdx @@ -28,7 +28,7 @@ agent/ intake.ts ``` -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. ## The eve HTTP channel (default) diff --git a/docs/channels/photon.mdx b/docs/channels/photon.mdx index 04645a156..2d8c1368a 100644 --- a/docs/channels/photon.mdx +++ b/docs/channels/photon.mdx @@ -4,8 +4,12 @@ description: Connect an eve agent to iMessage through Photon. --- Use `photonIMessageChannel` to receive and reply to iMessages through a Photon project. -With Vercel 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"; @@ -38,6 +42,37 @@ export default photonIMessageChannel({ ``` 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, 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`. + +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. + +To configure the channel by hand, use lazy environment-backed credentials: + +```ts title="agent/channels/photon.ts" +import { photonIMessageChannel } from "eve/channels/photon"; + +export default photonIMessageChannel({ + 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/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/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..2da8a8ea9 --- /dev/null +++ b/packages/eve/src/setup/photon-connect.ts @@ -0,0 +1,178 @@ +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) { + const detail = 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."); + } + 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..cfd76a391 --- /dev/null +++ b/packages/eve/src/setup/photon-management.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + provisionPhotonProject, + 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.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..af03e5260 --- /dev/null +++ b/packages/eve/src/setup/photon-management.ts @@ -0,0 +1,275 @@ +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 }; +} 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..58da491ed --- /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; choose Vercel Connect or portable Photon credentials."; + case "cli-missing": + return "Vercel CLI not found; choose Vercel Connect or portable Photon credentials."; + case "unavailable": + return "Could not verify the Vercel account; choose Vercel Connect or portable Photon credentials."; + } +} + +/** 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..7993bcfae --- /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..445f2744a --- /dev/null +++ b/packages/eve/src/setup/photon-setup.test.ts @@ -0,0 +1,84 @@ +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), + ensureLinkedVercelProject: vi.fn(async () => ({ orgId: "team-id", projectId: "project-id" })), + openUrl: vi.fn(), + provisionConnector: vi.fn(), + provisionProject: vi.fn(async () => ({ + projectId: "project-id", + projectSecret: "project-secret", + cleanup: vi.fn(async () => {}), + })), + useProject: vi.fn(), + writeTextFile: vi.fn(async () => {}), + }; +} + +describe("Photon setup", () => { + it("scaffolds portable credentials when Vercel Connect is unavailable", async () => { + const fake = createFakePrompter({ single: () => "portable" }); + const effects = deps(); + + await expect( + PHOTON_CHANNEL_SETUP.setup({ + environment: photonSetupEnvironment("cli-missing", { kind: "unresolved" }), + state: { + agentName: "agent", + project: { kind: "unresolved" }, + projectPath: "/project", + }, + ui: createPhotonSetupUi({ + asker: asker({ "photon-phone-number": "+15551234567" }), + prompter: fake.prompter, + }), + photonDeps: effects, + }), + ).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" }); + + 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 new file mode 100644 index 000000000..f3b62c364 --- /dev/null +++ b/packages/eve/src/setup/photon-setup.ts @@ -0,0 +1,271 @@ +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, + usePhotonProject, + validatePhotonPhoneNumber, + type PhotonManagedProject, +} from "./photon-management.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 { 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; + ensureLinkedVercelProject: typeof ensureLinkedVercelProject; + openUrl: typeof openUrl; + provisionConnector: typeof provisionPhotonConnector; + provisionProject: typeof provisionPhotonProject; + useProject: typeof usePhotonProject; + writeTextFile: typeof writeTextFile; +} + +const defaultDeps: PhotonSetupDeps = { + appendEnv, + deriveConnectorSlug: deriveSlackConnectorSlug, + ensureLinkedVercelProject, + openUrl, + provisionConnector: provisionPhotonConnector, + provisionProject: provisionPhotonProject, + 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"; + +export default photonChannel({ + credentials: connectPhotonCredentials(${JSON.stringify(connectorUid)}), +}); +`; +} + +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 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( + "Vercel Connect requires an authenticated Vercel CLI. Run `vercel login`, then retry Photon setup.", + ); + } + const photon = await choosePhotonProject(context); + return { credentials: destination === "vercel" ? "vercel-connect" : "environment", ...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 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 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", { + 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/pnpm-lock.yaml b/pnpm-lock.yaml index d5dfacd61..4f452d949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,8 +46,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 @@ -245,7 +245,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) @@ -299,7 +299,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)) @@ -387,7 +387,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 @@ -7080,12 +7080,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' @@ -7209,6 +7212,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} @@ -16427,26 +16434,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)': @@ -17739,7 +17746,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: @@ -20791,13 +20798,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 @@ -21083,6 +21095,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 094e16b51..516b1504f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -37,7 +37,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" "@workflow/world-postgres": "5.0.0-beta.29" From dd44ec540bc9bf668cd73d178477181f3f38a52c Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 14:12:11 -0700 Subject: [PATCH 02/12] fix(eve): stabilize Photon setup Signed-off-by: owenkephart --- .../eve/src/cli/commands/integration-setup.ts | 74 +++++++++++++++++++ packages/eve/src/cli/dev/tui/blocks.ts | 15 +++- .../src/cli/dev/tui/terminal-renderer.test.ts | 24 +++++- .../eve/src/cli/dev/tui/terminal-renderer.ts | 8 +- packages/eve/src/setup/photon-connect.test.ts | 22 ++++++ packages/eve/src/setup/photon-connect.ts | 6 +- .../eve/src/setup/photon-setup-integration.ts | 7 +- packages/eve/src/setup/photon-setup.test.ts | 25 ++++++- packages/eve/src/setup/photon-setup.ts | 52 +++++++------ 9 files changed, 199 insertions(+), 34 deletions(-) diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index d775dbb31..343ec5985 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -1,3 +1,22 @@ +<<<<<<< HEAD +======= +import { basename } from "node:path"; + +import { interactiveAsker } from "#setup/ask.js"; +import type { AddChannelsDeps } from "#setup/integrations/channel-scaffold.js"; +import { + channelSetupEnvironment, + describeChannelSetupEnvironment, +} from "#setup/integrations/shared/environment.js"; +import { channelSetupIntegration, createChannelSetupUi } from "#setup/integrations/registry.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"; +>>>>>>> 23729a51 (fix(eve): stabilize Photon setup) import { createPrompter, type Prompter } from "#setup/prompter.js"; import { createRegistrySetupClient } from "#setup/registry-setup-client.js"; import { @@ -38,6 +57,7 @@ export async function runIntegrationSetupCommand( const client = createRegistrySetupClient({ signal: options.signal }); try { const prompter = client?.prompter ?? dependencies.createPrompter?.() ?? createPrompter(); +<<<<<<< HEAD const result = await runIntegrationSetup( kind, { @@ -45,6 +65,60 @@ export async function runIntegrationSetupCommand( prompter, signal: client?.signal ?? options.signal, yes: options.yes, +======= + const signal = client?.signal ?? options.signal; + const [deployment, authStatus] = await Promise.all([ + dependencies.detectDeployment(appRoot, { signal }), + dependencies.getVercelAuthStatus(appRoot, { 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: basename(appRoot), project, projectPath: appRoot }, + ui: createPhotonSetupUi({ asker: interactiveAsker(prompter), prompter }), + photonDeps: dependencies.photonDeps, + signal, + }); + if (result.kind === "cancelled") { + client?.cancel(); + return; + } + prompter.outro("Integration set up."); + client?.complete([ + ...(result.assignedPhoneNumber === undefined + ? [] + : [ + { + label: "Text your agent", + value: result.assignedPhoneNumber, + kind: "phone" as const, + }, + ]), + { label: "Photon project", value: result.dashboardUrl, kind: "url" }, + ]); + 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({ + environment, + state: { + ...createDefaultSetupState(), + project, + projectPath: { kind: "resolved", inPlace: true, path: appRoot }, +>>>>>>> 23729a51 (fix(eve): stabilize Photon setup) }, dependencies.runnerDeps, ); diff --git a/packages/eve/src/cli/dev/tui/blocks.ts b/packages/eve/src/cli/dev/tui/blocks.ts index 3a699d584..839fed4d3 100644 --- a/packages/eve/src/cli/dev/tui/blocks.ts +++ b/packages/eve/src/cli/dev/tui/blocks.ts @@ -474,8 +474,21 @@ function renderFlow(block: Block, width: number, theme: Theme): string[] { * nests under the echoed command's text rather than its `│` marker. */ function renderResult(block: Block, width: number, theme: Theme): string[] { - const marker = theme.colors.dim(theme.glyph.elbow); const lines = wrap(block.body ?? "", width - 7); + if (block.status === "done") { + const elbow = theme.colors.dim(theme.glyph.elbow); + const success = theme.colors.green(theme.glyph.success); + const rule = theme.colors.dim(theme.glyph.rule); + const corner = theme.colors.dim(theme.glyph.corner); + if (lines.length === 0) return [` ${elbow} ${success}`]; + return lines.map((line, index) => { + if (index === 0) return ` ${elbow} ${success} ${line}`; + const marker = index === lines.length - 1 ? corner : rule; + return ` ${marker} ${line}`; + }); + } + + const marker = theme.colors.dim(theme.glyph.elbow); if (lines.length === 0) return [` ${marker}`]; // SGR 22 closes bold and dim together, so a result that bolds a span (the // /model reply's model name) would drop the rest of the line out of dim; diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index 4bbfdb418..f0799fa74 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -1448,14 +1448,30 @@ describe("TerminalRenderer (inline scrollback)", () => { expect(screen.snapshot()).toContain("\u23bf /model dismissed."); }); - it("promotes a successful command outcome to a top-level green check", () => { + it("hangs a successful command outcome from an elbow into a full-intensity rail", () => { const { screen, renderer } = makeRenderer(); - renderer.renderCommandResult("Registry items added: channel/discord.", "success"); + renderer.renderCommandResult( + "Registry items added: channel/photon.\n" + + "Text your agent: +15550000000\n" + + "Photon project: https://app.photon.codes/dashboard/project-id", + "success", + ); renderer.shutdown(); - expect(screen.snapshot()).toContain("✓ Registry items added: channel/discord."); - expect(screen.snapshot()).not.toContain("⎿"); + const snapshot = screen.snapshot(); + expect(snapshot).toContain("⎿ ✓ Registry items added: channel/photon."); + expect(snapshot).toContain("│ Text your agent: +15550000000"); + expect(snapshot).toContain("└ Photon project: https://app.photon.codes/dashboard/project-id"); expect(screen.rawOutput()).toContain("\u001b[32m✓\u001b[39m"); + expect(screen.rawOutput()).not.toContain("\u001b[2mText your agent"); + }); + + it("keeps a single-line successful command result under the elbow", () => { + const { screen, renderer } = makeRenderer(); + renderer.renderCommandResult("Registry items added: connection/linear.", "success"); + renderer.shutdown(); + + expect(screen.snapshot()).toContain("⎿ ✓ Registry items added: connection/linear."); }); it("marks a failed automatic command and keeps its multiline outcome in one result block", () => { diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 1ba275e21..d6a7a3465 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -1615,9 +1615,11 @@ export class TerminalRenderer implements AgentTUIRenderer { if (content.trim().length === 0) return; this.#start(); this.#pushBlock( - tone === undefined - ? { kind: "result", body: content, live: false } - : { kind: "flow", title: tone, body: content, live: false }, + tone === "success" + ? { kind: "result", body: content, live: false, status: "done" } + : tone === "error" + ? { kind: "flow", title: tone, body: content, live: false } + : { kind: "result", body: content, live: false }, ); this.#paint(); } diff --git a/packages/eve/src/setup/photon-connect.test.ts b/packages/eve/src/setup/photon-connect.test.ts index 17d7bcbc8..97d3c1c4f 100644 --- a/packages/eve/src/setup/photon-connect.test.ts +++ b/packages/eve/src/setup/photon-connect.test.ts @@ -27,6 +27,28 @@ describe("Photon Connect provisioning", () => { ).toEqual({ id: "scl_photon", uid: "photon/imessage0" }); }); + test("includes Vercel stderr when connector creation fails", async () => { + 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: vi.fn(), + runVercelCaptureStdout: vi.fn(async () => ({ + ok: false, + stdout: "", + stderr: 'Error: A connector named "imessage0" already exists.', + })), + }, + }), + ).rejects.toThrow( + 'Photon connector creation failed:\nError: A connector named "imessage0" already exists.', + ); + }); + test("creates a native connector, detaches, then attaches the routed trigger", async () => { const runVercelCaptureStdout = vi.fn().mockResolvedValueOnce({ ok: true as const, diff --git a/packages/eve/src/setup/photon-connect.ts b/packages/eve/src/setup/photon-connect.ts index 2da8a8ea9..6940b2488 100644 --- a/packages/eve/src/setup/photon-connect.ts +++ b/packages/eve/src/setup/photon-connect.ts @@ -71,9 +71,11 @@ function createData(credentials: PhotonProjectCredentials): string { function requireCreatedConnector(result: RunVercelCaptureResult): PhotonConnectorRef { if (!result.ok) { - const detail = result.stdout.trim(); + const detail = [result.stderr, result.stdout] + .map((output) => output?.trim()) + .find((output): output is string => output !== undefined && output.length > 0); throw new Error( - detail ? `Photon connector creation failed: ${detail}` : "Photon connector creation failed.", + detail ? `Photon connector creation failed:\n${detail}` : "Photon connector creation failed.", ); } const connector = parseCreatedPhotonConnector(result.stdout); diff --git a/packages/eve/src/setup/photon-setup-integration.ts b/packages/eve/src/setup/photon-setup-integration.ts index 6897723e0..cb78260ce 100644 --- a/packages/eve/src/setup/photon-setup-integration.ts +++ b/packages/eve/src/setup/photon-setup-integration.ts @@ -22,7 +22,12 @@ export interface PhotonSetupContext { /** Structured outcome from the Photon setup implementation. */ export type PhotonSetupResult = - | { readonly kind: "done"; readonly state: PhotonSetupState } + | { + readonly kind: "done"; + readonly state: PhotonSetupState; + readonly assignedPhoneNumber?: string; + readonly dashboardUrl: string; + } | { readonly kind: "cancelled" }; /** Guided Photon setup behavior. */ diff --git a/packages/eve/src/setup/photon-setup.test.ts b/packages/eve/src/setup/photon-setup.test.ts index 445f2744a..b080c8e98 100644 --- a/packages/eve/src/setup/photon-setup.test.ts +++ b/packages/eve/src/setup/photon-setup.test.ts @@ -19,7 +19,7 @@ function deps(): PhotonSetupDeps { return { appendEnv: vi.fn(async () => ({ written: [], skipped: [] })), deriveConnectorSlug: vi.fn(async () => "agent" as never), - ensureLinkedVercelProject: vi.fn(async () => ({ orgId: "team-id", projectId: "project-id" })), + ensureVercelProject: vi.fn(async () => ({ orgId: "team-id", projectId: "project-id" })), openUrl: vi.fn(), provisionConnector: vi.fn(), provisionProject: vi.fn(async () => ({ @@ -65,6 +65,29 @@ describe("Photon setup", () => { ); }); + it("uses the agent name in the default Photon project name", async () => { + const fake = createFakePrompter({ single: () => "portable" }); + const effects = deps(); + + await PHOTON_CHANNEL_SETUP.setup({ + environment: photonSetupEnvironment("cli-missing", { kind: "unresolved" }), + state: { + agentName: "weather-agent", + project: { kind: "unresolved" }, + projectPath: "/project", + }, + ui: createPhotonSetupUi({ + asker: asker({ "photon-phone-number": "+15551234567" }), + prompter: fake.prompter, + }), + photonDeps: effects, + }); + + expect(effects.provisionProject).toHaveBeenCalledWith( + expect.objectContaining({ projectName: "eve · weather-agent" }), + ); + }); + it("requires Vercel login when Connect is selected without an authenticated CLI", async () => { const fake = createFakePrompter({ single: () => "vercel" }); diff --git a/packages/eve/src/setup/photon-setup.ts b/packages/eve/src/setup/photon-setup.ts index f3b62c364..0f81df569 100644 --- a/packages/eve/src/setup/photon-setup.ts +++ b/packages/eve/src/setup/photon-setup.ts @@ -14,7 +14,7 @@ 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 { ensureLinkedVercelProject } from "./vercel-project.js"; +import { ensureVercelProject } from "./flows/ensure-vercel-project.js"; interface PhotonSetupPlan { credentials: "vercel-connect" | "environment"; @@ -25,7 +25,7 @@ interface PhotonSetupPlan { export interface PhotonSetupDeps { appendEnv: typeof appendEnv; deriveConnectorSlug: typeof deriveSlackConnectorSlug; - ensureLinkedVercelProject: typeof ensureLinkedVercelProject; + ensureVercelProject: typeof ensureVercelProject; openUrl: typeof openUrl; provisionConnector: typeof provisionPhotonConnector; provisionProject: typeof provisionPhotonProject; @@ -36,7 +36,7 @@ export interface PhotonSetupDeps { const defaultDeps: PhotonSetupDeps = { appendEnv, deriveConnectorSlug: deriveSlackConnectorSlug, - ensureLinkedVercelProject, + ensureVercelProject, openUrl, provisionConnector: provisionPhotonConnector, provisionProject: provisionPhotonProject, @@ -172,23 +172,31 @@ async function resolvePhotonProject( 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); - }, + const spinner = context.ui.prompter.log.spinner?.("Waiting for Photon approval…", { + kind: "external-action", + emphasis: "browser", }); + try { + return await 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); + }, + }); + } finally { + spinner?.stop(); + } } async function setupPhoton( context: Parameters[0], plan: PhotonSetupPlan, deps: PhotonSetupDeps, -): Promise<"created" | "cancelled"> { +): Promise<{ assignedPhoneNumber?: string; dashboardUrl: string }> { const phoneNumber = await context.ui.asker.ask( text({ key: "photon-phone-number", @@ -204,8 +212,8 @@ async function setupPhoton( 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 deps.ensureLinkedVercelProject({ - projectRoot, + const project = await deps.ensureVercelProject({ + appRoot: projectRoot, prompter: context.ui.prompter, signal: context.signal, }); @@ -237,12 +245,11 @@ async function setupPhoton( tone: "success", }); } - context.ui.prompter.note( - `https://app.photon.codes/dashboard/${managedProject.projectId}`, - "Photon project", - { tone: "success" }, - ); - return "created"; + const dashboardUrl = `https://app.photon.codes/dashboard/${managedProject.projectId}`; + context.ui.prompter.note(dashboardUrl, "Photon project", { tone: "success" }); + return managedProject.assignedPhoneNumber === undefined + ? { dashboardUrl } + : { assignedPhoneNumber: managedProject.assignedPhoneNumber, dashboardUrl }; } catch (error) { await managedProject.cleanup().catch(() => {}); throw error; @@ -258,10 +265,11 @@ export const PHOTON_CHANNEL_SETUP: PhotonSetupIntegration = { try { const plan = await chooseSetupPlan(context); if (plan === "cancelled") return { kind: "cancelled" }; - await setupPhoton(context, plan, context.photonDeps ?? defaultDeps); + const result = await setupPhoton(context, plan, context.photonDeps ?? defaultDeps); return { kind: "done", state: context.state, + ...result, }; } catch (error) { if (error instanceof WizardCancelledError) return { kind: "cancelled" }; From 1c3a988d3933a6c6348c6cb6a06720ccaac0a537 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 14:13:18 -0700 Subject: [PATCH 03/12] fix(eve): use the Photon iMessage channel API in setup Signed-off-by: owenkephart --- apps/docs/lib/integrations/data.ts | 6 +++--- packages/eve/src/setup/photon-setup.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/docs/lib/integrations/data.ts b/apps/docs/lib/integrations/data.ts index e8efda52e..da9e56c96 100644 --- a/apps/docs/lib/integrations/data.ts +++ b/apps/docs/lib/integrations/data.ts @@ -856,10 +856,10 @@ eve add channel/photon \`\`\`ts import { connectPhotonCredentials } from "@vercel/connect/eve"; -import { photonChannel } from "eve/channels/photon"; +import { photonIMessageChannel } from "eve/channels/photon"; -export default photonChannel({ - credentials: connectPhotonCredentials(process.env.PHOTON_CONNECTOR_ID!), +export default photonIMessageChannel({ + credentials: connectPhotonCredentials("photon/my-agent"), }); \`\`\``, 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.`, diff --git a/packages/eve/src/setup/photon-setup.ts b/packages/eve/src/setup/photon-setup.ts index 0f81df569..eee4c69a8 100644 --- a/packages/eve/src/setup/photon-setup.ts +++ b/packages/eve/src/setup/photon-setup.ts @@ -44,7 +44,7 @@ const defaultDeps: PhotonSetupDeps = { writeTextFile, }; -const PORTABLE_TEMPLATE = `import { photonChannel } from "eve/channels/photon"; +const PORTABLE_TEMPLATE = `import { photonIMessageChannel } from "eve/channels/photon"; async function photonCredentials() { const projectId = process.env.IMESSAGE_PROJECT_ID; @@ -53,7 +53,7 @@ async function photonCredentials() { return { projectId, projectSecret }; } -export default photonChannel({ +export default photonIMessageChannel({ credentials: photonCredentials, webhookSecret: process.env.IMESSAGE_WEBHOOK_SECRET, }); @@ -61,9 +61,9 @@ export default photonChannel({ function connectTemplate(connectorUid: string): string { return `import { connectPhotonCredentials } from "@vercel/connect/eve"; -import { photonChannel } from "eve/channels/photon"; +import { photonIMessageChannel } from "eve/channels/photon"; -export default photonChannel({ +export default photonIMessageChannel({ credentials: connectPhotonCredentials(${JSON.stringify(connectorUid)}), }); `; From ef1a31a54139c3fce8caff9dd7b9f9016f15f0a2 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 14:19:29 -0700 Subject: [PATCH 04/12] refactor(eve): share Connect trigger provisioning Signed-off-by: owenkephart --- .../src/setup/connect-provisioning.test.ts | 73 +++++++++++++++++ .../eve/src/setup/connect-provisioning.ts | 61 ++++++++++++++ packages/eve/src/setup/photon-connect.test.ts | 36 +++++++++ packages/eve/src/setup/photon-connect.ts | 79 ++++++------------- .../eve/src/setup/slack-connect-lifecycle.ts | 40 +++------- 5 files changed, 206 insertions(+), 83 deletions(-) create mode 100644 packages/eve/src/setup/connect-provisioning.test.ts create mode 100644 packages/eve/src/setup/connect-provisioning.ts diff --git a/packages/eve/src/setup/connect-provisioning.test.ts b/packages/eve/src/setup/connect-provisioning.test.ts new file mode 100644 index 000000000..380bd086b --- /dev/null +++ b/packages/eve/src/setup/connect-provisioning.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; + +import { replaceConnectTrigger } from "./connect-provisioning.js"; + +const onOutput = vi.fn(); + +function options(runVercel = vi.fn(async () => true)) { + return { + connectorUid: "photon/my-agent", + projectRoot: "/project", + triggerPath: "/eve/v1/photon", + onOutput, + deps: { runVercel }, + }; +} + +describe("replaceConnectTrigger", () => { + it("replaces a project-scoped production trigger destination", async () => { + const runVercel = vi.fn(async () => true); + + await expect( + replaceConnectTrigger({ + ...options(runVercel), + projectId: "prj_123", + orgId: "team_123", + environment: "production", + }), + ).resolves.toEqual({ state: "attached" }); + + expect(runVercel).toHaveBeenNthCalledWith( + 1, + [ + "connect", + "detach", + "photon/my-agent", + "--project", + "prj_123", + "--yes", + "--scope", + "team_123", + ], + expect.objectContaining({ cwd: "/project", nonInteractive: true }), + ); + expect(runVercel).toHaveBeenNthCalledWith( + 2, + [ + "connect", + "attach", + "photon/my-agent", + "--project", + "prj_123", + "--environment", + "production", + "--triggers", + "--trigger-path", + "/eve/v1/photon", + "--yes", + "--scope", + "team_123", + ], + expect.objectContaining({ cwd: "/project", nonInteractive: true }), + ); + }); + + it("does not attach when detaching fails", async () => { + const runVercel = vi.fn(async () => false); + + await expect(replaceConnectTrigger(options(runVercel))).resolves.toEqual({ + state: "detach-failed", + }); + expect(runVercel).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/eve/src/setup/connect-provisioning.ts b/packages/eve/src/setup/connect-provisioning.ts new file mode 100644 index 000000000..43ae28288 --- /dev/null +++ b/packages/eve/src/setup/connect-provisioning.ts @@ -0,0 +1,61 @@ +import type { ProcessOutputHandler } from "#setup/primitives/process-output.js"; +import { runVercel, type RunVercelOptions } from "#setup/primitives/run-vercel.js"; + +/** Maximum time allowed for a Vercel Connect mutation. */ +export const CONNECT_MUTATION_TIMEOUT_MS = 2 * 60_000; + +/** Outcome of replacing a connector's trigger destination. */ +export type ConnectTriggerAttachmentResult = + | { state: "attached" } + | { state: "detach-failed" } + | { state: "attach-failed" }; + +/** Inputs required to replace a Vercel Connect connector's trigger destination. */ +export interface ReplaceConnectTriggerOptions { + connectorUid: string; + projectRoot: string; + triggerPath: string; + onOutput: ProcessOutputHandler; + projectId?: string; + orgId?: string; + environment?: string; + signal?: AbortSignal; + deps?: { runVercel: (args: string[], options: RunVercelOptions) => Promise }; +} + +/** + * Replaces a connector's existing trigger destination with one route on an eve + * project. Detaching first prevents Connect from retaining a pathless default + * destination created during connector provisioning. + */ +export async function replaceConnectTrigger( + options: ReplaceConnectTriggerOptions, +): Promise { + const run = options.deps?.runVercel ?? runVercel; + const detachArgs = ["connect", "detach", options.connectorUid]; + if (options.projectId !== undefined) detachArgs.push("--project", options.projectId); + detachArgs.push("--yes"); + if (options.orgId !== undefined) detachArgs.push("--scope", options.orgId); + const detached = await run(detachArgs, { + cwd: options.projectRoot, + onOutput: options.onOutput, + nonInteractive: true, + timeoutMs: CONNECT_MUTATION_TIMEOUT_MS, + signal: options.signal, + }); + if (!detached) return { state: "detach-failed" }; + + const attachArgs = ["connect", "attach", options.connectorUid]; + if (options.projectId !== undefined) attachArgs.push("--project", options.projectId); + if (options.environment !== undefined) attachArgs.push("--environment", options.environment); + attachArgs.push("--triggers", "--trigger-path", options.triggerPath, "--yes"); + if (options.orgId !== undefined) attachArgs.push("--scope", options.orgId); + const attached = await run(attachArgs, { + cwd: options.projectRoot, + onOutput: options.onOutput, + nonInteractive: true, + timeoutMs: CONNECT_MUTATION_TIMEOUT_MS, + signal: options.signal, + }); + return attached ? { state: "attached" } : { state: "attach-failed" }; +} diff --git a/packages/eve/src/setup/photon-connect.test.ts b/packages/eve/src/setup/photon-connect.test.ts index 97d3c1c4f..19152449d 100644 --- a/packages/eve/src/setup/photon-connect.test.ts +++ b/packages/eve/src/setup/photon-connect.test.ts @@ -128,4 +128,40 @@ describe("Photon Connect provisioning", () => { expect.objectContaining({ cwd: "/tmp/imessage0", nonInteractive: true }), ); }); + + test("rejects empty connector identifiers", () => { + expect( + parseCreatedPhotonConnector( + JSON.stringify({ + id: "", + uid: "photon/imessage0", + supportedSubjectTypes: ["app"], + }), + ), + ).toBeUndefined(); + }); + + test("fails closed when the default trigger destination cannot be removed", async () => { + const runVercelCaptureStdout = vi.fn().mockResolvedValue({ + ok: true as const, + stdout: JSON.stringify({ + id: "scl_photon", + uid: "photon/imessage0", + supportedSubjectTypes: ["app"], + }), + }); + const runVercel = vi.fn(async () => false); + + 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 }, + }), + ).rejects.toThrow("default trigger destination could not be removed"); + expect(runVercel).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/eve/src/setup/photon-connect.ts b/packages/eve/src/setup/photon-connect.ts index 6940b2488..7ae673d1d 100644 --- a/packages/eve/src/setup/photon-connect.ts +++ b/packages/eve/src/setup/photon-connect.ts @@ -1,11 +1,13 @@ import type { ChannelSetupLog } from "#setup/cli/index.js"; import { createPromptCommandOutput, withPhase } from "#setup/cli/index.js"; +import { replaceConnectTrigger } from "#setup/connect-provisioning.js"; import type { VercelProjectReference } from "#setup/project-resolution.js"; import { runVercel, runVercelCaptureStdout, type RunVercelCaptureResult, } from "#setup/primitives/run-vercel.js"; +import { z } from "zod"; export const PHOTON_CONNECT_SERVICE = "photon"; export const PHOTON_CONNECTOR_TYPE = "photon"; @@ -42,24 +44,20 @@ export interface ProvisionPhotonConnectorOptions { deps?: ProvisionPhotonConnectorDeps; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +const PhotonConnectorRefSchema = z.object({ + id: z.string().min(1), + uid: z.string().min(1), + supportedSubjectTypes: z.array(z.string()).refine((types) => types.includes("app")), +}); /** 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); + const parsed = PhotonConnectorRefSchema.safeParse(JSON.parse(stdout)); + return parsed.success ? { id: parsed.data.id, uid: parsed.data.uid } : undefined; } 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 { @@ -125,54 +123,27 @@ export async function provisionPhotonConnector( 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, + // destination. Replace it so Connect has exactly one routed destination. + const attachment = await withPhase(options.log, "Connecting Photon credentials...", () => + replaceConnectTrigger({ + connectorUid: connector.uid, + projectRoot: options.projectRoot, + projectId: options.project.projectId, + orgId: options.project.orgId, + environment: "production", + triggerPath: PHOTON_TRIGGER_PATH, 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, - }, - ), + deps, + }), ); options.signal?.throwIfAborted(); - if (!attached) { + if (attachment.state !== "attached") { + const command = `vercel connect attach ${connector.uid} --project ${options.project.projectId} --environment production --triggers --trigger-path ${PHOTON_TRIGGER_PATH} --yes --scope ${options.project.orgId}`; throw new Error( - `Photon connector was created, but its credentials could not be attached. Run \`vercel connect attach ${connector.uid} --environment production --yes\`.`, + attachment.state === "detach-failed" + ? `Photon connector was created, but its default trigger destination could not be removed. Run \`vercel connect detach ${connector.uid} --project ${options.project.projectId} --yes --scope ${options.project.orgId}\`, then \`${command}\`.` + : `Photon connector was created, but its credentials could not be attached. Run \`${command}\`.`, ); } diff --git a/packages/eve/src/setup/slack-connect-lifecycle.ts b/packages/eve/src/setup/slack-connect-lifecycle.ts index 598a80200..a934969df 100644 --- a/packages/eve/src/setup/slack-connect-lifecycle.ts +++ b/packages/eve/src/setup/slack-connect-lifecycle.ts @@ -2,6 +2,11 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { SLACK_CHANNEL_DEFAULT_ROUTE } from "#setup/scaffold/index.js"; +import { + CONNECT_MUTATION_TIMEOUT_MS, + replaceConnectTrigger, + type ConnectTriggerAttachmentResult, +} from "#setup/connect-provisioning.js"; import { createPromptCommandOutput, type ChannelSetupLog } from "#setup/cli/index.js"; import { captureVercel, runVercel } from "#setup/primitives/run-vercel.js"; import { z } from "zod"; @@ -17,7 +22,6 @@ import { } from "./slack-connect.js"; export const CONNECT_LOOKUP_TIMEOUT_MS = 60_000; -export const CONNECT_MUTATION_TIMEOUT_MS = 2 * 60_000; const VercelProjectLinkSchema = z.object({ projectId: z.string().min(1), @@ -135,10 +139,7 @@ export async function findSlackConnector( * while an old trigger destination may still exist. A `detach-failed` connector * is left in a known-stale state the caller surfaces with manual recovery steps. */ -export type SlackConnectorAttachmentResult = - | { state: "attached" } - | { state: "detach-failed" } - | { state: "attach-failed" }; +export type SlackConnectorAttachmentResult = ConnectTriggerAttachmentResult; /** * Replaces the connector's default trigger destination with the eve route: @@ -153,33 +154,14 @@ export async function attachSlackConnector( onOutput: CommandOutput, signal?: AbortSignal, ): Promise { - const detached = await deps.runVercel(["connect", "detach", ref.uid, "--yes"], { - cwd: projectRoot, + return replaceConnectTrigger({ + connectorUid: ref.uid, + projectRoot, + triggerPath: SLACK_CHANNEL_DEFAULT_ROUTE, onOutput, - nonInteractive: true, - timeoutMs: CONNECT_MUTATION_TIMEOUT_MS, signal, + deps, }); - if (!detached) return { state: "detach-failed" }; - const attached = await deps.runVercel( - [ - "connect", - "attach", - ref.uid, - "--triggers", - "--trigger-path", - SLACK_CHANNEL_DEFAULT_ROUTE, - "--yes", - ], - { - cwd: projectRoot, - onOutput, - nonInteractive: true, - timeoutMs: CONNECT_MUTATION_TIMEOUT_MS, - signal, - }, - ); - return attached ? { state: "attached" } : { state: "attach-failed" }; } export type SlackWorkspaceLookup = From 57b4eb2e8fbbed2655ce1bde22a0ef9da6579e6a Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 14:29:13 -0700 Subject: [PATCH 05/12] refactor(eve): simplify Photon setup flow Signed-off-by: owenkephart --- .../eve/src/cli/commands/integration-setup.ts | 25 ++-- .../eve/src/setup/photon-setup-environment.ts | 41 ------ .../eve/src/setup/photon-setup-integration.ts | 39 ------ .../src/setup/photon-setup-integrations.ts | 9 -- packages/eve/src/setup/photon-setup-ui.ts | 29 ---- packages/eve/src/setup/photon-setup.test.ts | 52 +++---- packages/eve/src/setup/photon-setup.ts | 127 +++++++++--------- 7 files changed, 105 insertions(+), 217 deletions(-) delete mode 100644 packages/eve/src/setup/photon-setup-environment.ts delete mode 100644 packages/eve/src/setup/photon-setup-integration.ts delete mode 100644 packages/eve/src/setup/photon-setup-integrations.ts delete mode 100644 packages/eve/src/setup/photon-setup-ui.ts diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index 343ec5985..162cecca0 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -7,6 +7,7 @@ import type { AddChannelsDeps } from "#setup/integrations/channel-scaffold.js"; import { channelSetupEnvironment, describeChannelSetupEnvironment, +<<<<<<< HEAD } from "#setup/integrations/shared/environment.js"; import { channelSetupIntegration, createChannelSetupUi } from "#setup/integrations/registry.js"; import type { PhotonSetupDeps } from "#setup/photon-setup.js"; @@ -15,6 +16,14 @@ import { photonSetupEnvironment, } from "#setup/photon-setup-environment.js"; import { createPhotonSetupUi, photonSetupIntegration } from "#setup/photon-setup-integrations.js"; +======= +} from "#setup/integrations/channels/environment.js"; +import { + channelSetupIntegration, + createChannelSetupUi, +} from "#setup/integrations/channels/index.js"; +import { setupPhoton, type PhotonSetupDeps } from "#setup/photon-setup.js"; +>>>>>>> b21842f3 (refactor(eve): simplify Photon setup flow) import { detectDeployment, projectResolutionFromDeployment } from "#setup/project-resolution.js"; >>>>>>> 23729a51 (fix(eve): stabilize Photon setup) import { createPrompter, type Prompter } from "#setup/prompter.js"; @@ -74,16 +83,16 @@ export async function runIntegrationSetupCommand( const project = projectResolutionFromDeployment(deployment); if (kind === "photon") { - const integration = photonSetupIntegration(); - prompter.intro(`Set up ${integration.label}`); + prompter.intro("Set up Photon"); prompter.log.message("Checking Vercel setup..."); - const environment = photonSetupEnvironment(authStatus, project); - prompter.log.info(describePhotonSetupEnvironment(environment)); - const result = await integration.setup({ + const environment = channelSetupEnvironment(authStatus, project); + prompter.log.info(describeChannelSetupEnvironment(environment)); + const result = await setupPhoton({ + agentName: basename(appRoot), + projectPath: appRoot, environment, - state: { agentName: basename(appRoot), project, projectPath: appRoot }, - ui: createPhotonSetupUi({ asker: interactiveAsker(prompter), prompter }), - photonDeps: dependencies.photonDeps, + ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), + deps: dependencies.photonDeps, signal, }); if (result.kind === "cancelled") { diff --git a/packages/eve/src/setup/photon-setup-environment.ts b/packages/eve/src/setup/photon-setup-environment.ts deleted file mode 100644 index 58da491ed..000000000 --- a/packages/eve/src/setup/photon-setup-environment.ts +++ /dev/null @@ -1,41 +0,0 @@ -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; choose Vercel Connect or portable Photon credentials."; - case "cli-missing": - return "Vercel CLI not found; choose Vercel Connect or portable Photon credentials."; - case "unavailable": - return "Could not verify the Vercel account; choose Vercel Connect or portable Photon credentials."; - } -} - -/** 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 deleted file mode 100644 index cb78260ce..000000000 --- a/packages/eve/src/setup/photon-setup-integration.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 assignedPhoneNumber?: string; - readonly dashboardUrl: string; - } - | { 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 deleted file mode 100644 index 948c78b1a..000000000 --- a/packages/eve/src/setup/photon-setup-integrations.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 7993bcfae..000000000 --- a/packages/eve/src/setup/photon-setup-ui.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 index b080c8e98..c14a0decf 100644 --- a/packages/eve/src/setup/photon-setup.test.ts +++ b/packages/eve/src/setup/photon-setup.test.ts @@ -3,10 +3,9 @@ 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"; +import { channelSetupEnvironment } from "./integrations/channels/environment.js"; +import { createChannelSetupUi } from "./integrations/channels/ui.js"; +import { setupPhoton, type PhotonSetupDeps } from "./photon-setup.js"; function asker(answers: Record): Asker { return { @@ -38,18 +37,15 @@ describe("Photon setup", () => { const effects = deps(); await expect( - PHOTON_CHANNEL_SETUP.setup({ - environment: photonSetupEnvironment("cli-missing", { kind: "unresolved" }), - state: { - agentName: "agent", - project: { kind: "unresolved" }, - projectPath: "/project", - }, - ui: createPhotonSetupUi({ + setupPhoton({ + agentName: "agent", + projectPath: "/project", + environment: channelSetupEnvironment("cli-missing", { kind: "unresolved" }), + ui: createChannelSetupUi({ asker: asker({ "photon-phone-number": "+15551234567" }), prompter: fake.prompter, }), - photonDeps: effects, + deps: effects, }), ).resolves.toMatchObject({ kind: "done" }); @@ -69,18 +65,15 @@ describe("Photon setup", () => { const fake = createFakePrompter({ single: () => "portable" }); const effects = deps(); - await PHOTON_CHANNEL_SETUP.setup({ - environment: photonSetupEnvironment("cli-missing", { kind: "unresolved" }), - state: { - agentName: "weather-agent", - project: { kind: "unresolved" }, - projectPath: "/project", - }, - ui: createPhotonSetupUi({ + await setupPhoton({ + agentName: "weather-agent", + projectPath: "/project", + environment: channelSetupEnvironment("cli-missing", { kind: "unresolved" }), + ui: createChannelSetupUi({ asker: asker({ "photon-phone-number": "+15551234567" }), prompter: fake.prompter, }), - photonDeps: effects, + deps: effects, }); expect(effects.provisionProject).toHaveBeenCalledWith( @@ -92,15 +85,12 @@ describe("Photon setup", () => { const fake = createFakePrompter({ single: () => "vercel" }); 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(), + setupPhoton({ + agentName: "agent", + projectPath: "/project", + environment: channelSetupEnvironment("cli-missing", { kind: "unresolved" }), + ui: createChannelSetupUi({ asker: asker({}), prompter: fake.prompter }), + deps: deps(), }), ).rejects.toThrow("vercel login"); }); diff --git a/packages/eve/src/setup/photon-setup.ts b/packages/eve/src/setup/photon-setup.ts index eee4c69a8..c1f300fa6 100644 --- a/packages/eve/src/setup/photon-setup.ts +++ b/packages/eve/src/setup/photon-setup.ts @@ -2,7 +2,6 @@ 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, @@ -14,8 +13,26 @@ 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 type { ChannelSetupEnvironment } from "./integrations/channels/environment.js"; +import type { ChannelSetupUi } from "./integrations/channels/ui.js"; import { ensureVercelProject } from "./flows/ensure-vercel-project.js"; +/** Inputs for Photon project provisioning and channel scaffolding. */ +export interface PhotonSetupOptions { + agentName: string; + projectPath: string; + environment: ChannelSetupEnvironment; + ui: ChannelSetupUi; + signal?: AbortSignal; + force?: boolean; + deps?: PhotonSetupDeps; +} + +/** Outcome of Photon project provisioning and channel scaffolding. */ +export type PhotonSetupResult = + | { kind: "done"; assignedPhoneNumber?: string; dashboardUrl: string } + | { kind: "cancelled" }; + interface PhotonSetupPlan { credentials: "vercel-connect" | "environment"; photonProject: "create" | { projectId: string; projectSecret: string }; @@ -70,10 +87,10 @@ export default photonIMessageChannel({ } async function choosePhotonProject( - context: Parameters[0], + options: PhotonSetupOptions, ): Promise> { - const defaultName = `eve · ${context.state.agentName || "agent"}`; - const options = [ + const defaultName = `eve · ${options.agentName || "agent"}`; + const projectOptions = [ { value: "create" as const, label: "Create a new Photon project", @@ -85,10 +102,10 @@ async function choosePhotonProject( hint: "Enter its project credentials", }, ]; - const editable = context.ui.prompter.selectEditable - ? await context.ui.prompter.selectEditable<"create" | "existing">({ + const editable = options.ui.prompter.selectEditable + ? await options.ui.prompter.selectEditable<"create" | "existing">({ message: "Photon project", - options, + options: projectOptions, initialValue: "create", editable: { value: "create", @@ -101,16 +118,16 @@ async function choosePhotonProject( : undefined; const source = editable?.value ?? - (await context.ui.prompter.select<"create" | "existing">({ + (await options.ui.prompter.select<"create" | "existing">({ message: "Photon project", - options, + options: projectOptions, initialValue: "create", })); if (source === "existing") { - const projectId = await context.ui.asker.ask( + const projectId = await options.ui.asker.ask( text({ key: "photon-project-id", message: "Photon project ID", required: true }), ); - const projectSecret = await context.ui.asker.ask( + const projectSecret = await options.ui.asker.ask( text({ key: "photon-project-secret", message: "Photon project secret", @@ -128,17 +145,17 @@ async function choosePhotonProject( } async function chooseSetupPlan( - context: Parameters[0], + options: PhotonSetupOptions, ): Promise { try { - const destination = await context.ui.prompter.select<"vercel" | "portable">({ + const destination = await options.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" + options.environment.vercel.kind === "available" ? "Link this project and configure Photon automatically" : "Log in to Vercel and link this project", }, @@ -148,14 +165,14 @@ async function chooseSetupPlan( hint: "Configure the Photon webhook manually after deployment", }, ], - initialValue: context.environment.vercel.kind === "available" ? "vercel" : "portable", + initialValue: options.environment.vercel.kind === "available" ? "vercel" : "portable", }); - if (destination === "vercel" && context.environment.vercel.kind === "unavailable") { + if (destination === "vercel" && options.environment.vercel.kind === "unavailable") { throw new Error( "Vercel Connect requires an authenticated Vercel CLI. Run `vercel login`, then retry Photon setup.", ); } - const photon = await choosePhotonProject(context); + const photon = await choosePhotonProject(options); return { credentials: destination === "vercel" ? "vercel-connect" : "environment", ...photon }; } catch (error) { if (error instanceof WizardCancelledError) return "cancelled"; @@ -164,7 +181,7 @@ async function chooseSetupPlan( } async function resolvePhotonProject( - context: Parameters[0], + options: PhotonSetupOptions, plan: PhotonSetupPlan, phoneNumber: string, deps: PhotonSetupDeps, @@ -172,18 +189,18 @@ async function resolvePhotonProject( if (plan.photonProject !== "create") { return deps.useProject({ ...plan.photonProject, phoneNumber }); } - const spinner = context.ui.prompter.log.spinner?.("Waiting for Photon approval…", { + const spinner = options.ui.prompter.log.spinner?.("Waiting for Photon approval…", { kind: "external-action", emphasis: "browser", }); try { return await deps.provisionProject({ - projectName: plan.photonProjectName ?? `eve · ${context.state.agentName || "agent"}`, + projectName: plan.photonProjectName ?? `eve · ${options.agentName || "agent"}`, phoneNumber, - signal: context.signal, + signal: options.signal, onAuthorization(authorization) { - context.ui.prompter.log.message(`Authorize Photon: ${authorization.verificationUrl}`); - context.ui.prompter.log.message(`Photon code: ${authorization.userCode}`); + options.ui.prompter.log.message(`Authorize Photon: ${authorization.verificationUrl}`); + options.ui.prompter.log.message(`Photon code: ${authorization.userCode}`); deps.openUrl(authorization.verificationUrl); }, }); @@ -192,12 +209,12 @@ async function resolvePhotonProject( } } -async function setupPhoton( - context: Parameters[0], +async function scaffoldPhoton( + options: PhotonSetupOptions, plan: PhotonSetupPlan, deps: PhotonSetupDeps, ): Promise<{ assignedPhoneNumber?: string; dashboardUrl: string }> { - const phoneNumber = await context.ui.asker.ask( + const phoneNumber = await options.ui.asker.ask( text({ key: "photon-phone-number", message: "Your iMessage phone number", @@ -206,47 +223,47 @@ async function setupPhoton( validate: validatePhotonPhoneNumber, }), ); - const projectRoot = context.state.projectPath; - const managedProject = await resolvePhotonProject(context, plan, phoneNumber, deps); + const projectRoot = options.projectPath; + const managedProject = await resolvePhotonProject(options, 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 slug = await deps.deriveConnectorSlug(projectRoot, options.agentName); const project = await deps.ensureVercelProject({ appRoot: projectRoot, - prompter: context.ui.prompter, - signal: context.signal, + prompter: options.ui.prompter, + signal: options.signal, }); const connector = await deps.provisionConnector({ credentials: managedProject, - log: context.ui.prompter.log, + log: options.ui.prompter.log, project, projectRoot, slug, - signal: context.signal, + signal: options.signal, }); await deps.writeTextFile(channelPath, connectTemplate(connector.uid), { - force: context.force, + force: options.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([ + await deps.writeTextFile(channelPath, PORTABLE_TEMPLATE, { force: options.force }); + options.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"); + options.ui.prompter.log.success("Scaffolded channel: photon"); if (managedProject.assignedPhoneNumber !== undefined) { - context.ui.prompter.note(managedProject.assignedPhoneNumber, "Text your agent", { + options.ui.prompter.note(managedProject.assignedPhoneNumber, "Text your agent", { tone: "success", }); } const dashboardUrl = `https://app.photon.codes/dashboard/${managedProject.projectId}`; - context.ui.prompter.note(dashboardUrl, "Photon project", { tone: "success" }); + options.ui.prompter.note(dashboardUrl, "Photon project", { tone: "success" }); return managedProject.assignedPhoneNumber === undefined ? { dashboardUrl } : { assignedPhoneNumber: managedProject.assignedPhoneNumber, dashboardUrl }; @@ -256,24 +273,14 @@ async function setupPhoton( } } -/** 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" }; - const result = await setupPhoton(context, plan, context.photonDeps ?? defaultDeps); - return { - kind: "done", - state: context.state, - ...result, - }; - } catch (error) { - if (error instanceof WizardCancelledError) return { kind: "cancelled" }; - throw error; - } - }, -}; +/** Provisions a Photon project and scaffolds its iMessage channel. */ +export async function setupPhoton(options: PhotonSetupOptions): Promise { + try { + const plan = await chooseSetupPlan(options); + if (plan === "cancelled") return { kind: "cancelled" }; + return { kind: "done", ...(await scaffoldPhoton(options, plan, options.deps ?? defaultDeps)) }; + } catch (error) { + if (error instanceof WizardCancelledError) return { kind: "cancelled" }; + throw error; + } +} From 8cbb2756819e41b133acfb4860f05c21a2729bbe Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 14:31:59 -0700 Subject: [PATCH 06/12] fix(eve): name the Photon iMessage registry item Signed-off-by: owenkephart --- apps/docs/lib/integrations/data.ts | 2 +- .../public/r/channel/{photon.json => photon-imessage.json} | 4 ++-- apps/docs/registry.json | 4 ++-- apps/docs/scripts/validate-channel-registry.ts | 3 ++- docs/channels/overview.mdx | 2 +- docs/channels/photon.mdx | 4 ++-- packages/eve/src/cli/commands/registry-setup-command.test.ts | 4 ++-- packages/eve/src/cli/commands/registry.test.ts | 4 ++-- packages/eve/src/cli/dev/tui/terminal-renderer.test.ts | 4 ++-- 9 files changed, 16 insertions(+), 15 deletions(-) rename apps/docs/public/r/channel/{photon.json => photon-imessage.json} (87%) diff --git a/apps/docs/lib/integrations/data.ts b/apps/docs/lib/integrations/data.ts index da9e56c96..23cfabd79 100644 --- a/apps/docs/lib/integrations/data.ts +++ b/apps/docs/lib/integrations/data.ts @@ -850,7 +850,7 @@ See the [Kapso adapter documentation](https://chat-sdk.dev/adapters/vendor-offic install: `Add Photon from eve's registry, then follow the guided project, phone, and deployment setup: \`\`\`bash -eve add channel/photon +eve add channel/photon-imessage \`\`\``, quickStart: `Create \`agent/channels/photon.ts\`: diff --git a/apps/docs/public/r/channel/photon.json b/apps/docs/public/r/channel/photon-imessage.json similarity index 87% rename from apps/docs/public/r/channel/photon.json rename to apps/docs/public/r/channel/photon-imessage.json index 1f8bd9410..6f9c5829c 100644 --- a/apps/docs/public/r/channel/photon.json +++ b/apps/docs/public/r/channel/photon-imessage.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "channel/photon", + "name": "channel/photon-imessage", "type": "registry:item", "title": "Photon", "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", @@ -13,7 +13,7 @@ "bin": "eve", "args": ["integration", "setup", "photon"] }, - "requires": ">=0.27.11" + "requires": ">=0.29.0" } } } diff --git a/apps/docs/registry.json b/apps/docs/registry.json index 49907c134..f3e560814 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -1209,7 +1209,7 @@ ] }, { - "name": "channel/photon", + "name": "channel/photon-imessage", "type": "registry:item", "title": "Photon", "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", @@ -1222,7 +1222,7 @@ "bin": "eve", "args": ["integration", "setup", "photon"] }, - "requires": ">=0.27.11" + "requires": ">=0.29.0" } } }, diff --git a/apps/docs/scripts/validate-channel-registry.ts b/apps/docs/scripts/validate-channel-registry.ts index cb96421f0..771bcff1e 100644 --- a/apps/docs/scripts/validate-channel-registry.ts +++ b/apps/docs/scripts/validate-channel-registry.ts @@ -30,6 +30,7 @@ interface Registry { const registrySlugsByCatalogSlug: Readonly> = { eve: "web", + photon: "photon-imessage", }; const adapterDependenciesByCatalogSlug: Readonly> = { @@ -105,7 +106,7 @@ for (const [index, item] of items.entries()) { const registrySlug = expectedSlugs[index]; if (entry.slug === "slack" || entry.slug === "eve" || entry.slug === "photon") { - const expectedArgs = ["integration", "setup", registrySlug]; + const expectedArgs = ["integration", "setup", entry.slug]; if ( setup?.command !== "eve" || setup.package !== "eve" || diff --git a/docs/channels/overview.mdx b/docs/channels/overview.mdx index cfab39c05..4d0933c9e 100644 --- a/docs/channels/overview.mdx +++ b/docs/channels/overview.mdx @@ -28,7 +28,7 @@ agent/ intake.ts ``` -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. +Install a channel from the registry with `eve add channel/photon-imessage`, `eve add channel/slack`, or `eve add channel/web`. You can also author the file by hand. ## The eve HTTP channel (default) diff --git a/docs/channels/photon.mdx b/docs/channels/photon.mdx index 2d8c1368a..ef0cb827b 100644 --- a/docs/channels/photon.mdx +++ b/docs/channels/photon.mdx @@ -5,7 +5,7 @@ description: Connect an eve agent to iMessage through Photon. Use `photonIMessageChannel` to receive and reply to iMessages through a Photon project. -Run `eve add channel/photon` to create or use a Photon project and register +Run `eve add channel/photon-imessage` 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 @@ -47,7 +47,7 @@ trusted-forwarder verifier. ## Other hosts For a host without Vercel Connect, choose **Use portable credentials** during -`eve add channel/photon`. eve scaffolds the channel and writes +`eve add channel/photon-imessage`. eve scaffolds the channel and writes `IMESSAGE_PROJECT_ID` and `IMESSAGE_PROJECT_SECRET` to `.env.local`. After deploying the agent: diff --git a/packages/eve/src/cli/commands/registry-setup-command.test.ts b/packages/eve/src/cli/commands/registry-setup-command.test.ts index 7d61469d2..4d8beedcf 100644 --- a/packages/eve/src/cli/commands/registry-setup-command.test.ts +++ b/packages/eve/src/cli/commands/registry-setup-command.test.ts @@ -151,7 +151,7 @@ describe("runRegistrySetupCommand", () => { runRegistrySetupCommand( "/project", { package: "@acme/slack", bin: "acme-slack", args: [] }, - "channel/photon", + "channel/photon-imessage", options(), ), ).rejects.toThrow("Photon approval was denied.\nat setupPhoton"); @@ -180,7 +180,7 @@ describe("runRegistrySetupCommand", () => { runRegistrySetupCommand( "/project", { package: "@acme/slack", bin: "acme-slack", args: [] }, - "channel/photon", + "channel/photon-imessage", options(), ), ).resolves.toEqual({ diff --git a/packages/eve/src/cli/commands/registry.test.ts b/packages/eve/src/cli/commands/registry.test.ts index e69b58926..a4785fb40 100644 --- a/packages/eve/src/cli/commands/registry.test.ts +++ b/packages/eve/src/cli/commands/registry.test.ts @@ -363,7 +363,7 @@ describe("registry commands", () => { const logger = createLogger(); getRegistryItems.mockResolvedValue([ { - name: "channel/photon", + name: "channel/photon-imessage", type: "registry:item", meta: { eve: { @@ -374,7 +374,7 @@ describe("registry commands", () => { }, ]); - await runAddCommand(logger, "/project", "channel/photon", {}); + await runAddCommand(logger, "/project", "channel/photon-imessage", {}); expect(logger.errors).toEqual([ "This registry item requires eve >=0.30.0, but this project is using eve 0.27.8. Upgrade eve and run the command again.", diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index f0799fa74..60b217217 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -1451,7 +1451,7 @@ describe("TerminalRenderer (inline scrollback)", () => { it("hangs a successful command outcome from an elbow into a full-intensity rail", () => { const { screen, renderer } = makeRenderer(); renderer.renderCommandResult( - "Registry items added: channel/photon.\n" + + "Registry items added: channel/photon-imessage.\n" + "Text your agent: +15550000000\n" + "Photon project: https://app.photon.codes/dashboard/project-id", "success", @@ -1459,7 +1459,7 @@ describe("TerminalRenderer (inline scrollback)", () => { renderer.shutdown(); const snapshot = screen.snapshot(); - expect(snapshot).toContain("⎿ ✓ Registry items added: channel/photon."); + expect(snapshot).toContain("⎿ ✓ Registry items added: channel/photon-imessage."); expect(snapshot).toContain("│ Text your agent: +15550000000"); expect(snapshot).toContain("└ Photon project: https://app.photon.codes/dashboard/project-id"); expect(screen.rawOutput()).toContain("\u001b[32m✓\u001b[39m"); From 03e69a2f55b796fcc1728177e477a7e7c43c80df Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 14:52:03 -0700 Subject: [PATCH 07/12] fix(eve): preserve setup cancellation status Signed-off-by: owenkephart --- apps/docs/scripts/validate-channel-registry.ts | 2 +- packages/eve/src/cli/commands/integration-setup.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/docs/scripts/validate-channel-registry.ts b/apps/docs/scripts/validate-channel-registry.ts index 771bcff1e..fdd7c08e4 100644 --- a/apps/docs/scripts/validate-channel-registry.ts +++ b/apps/docs/scripts/validate-channel-registry.ts @@ -106,7 +106,7 @@ for (const [index, item] of items.entries()) { const registrySlug = expectedSlugs[index]; if (entry.slug === "slack" || entry.slug === "eve" || entry.slug === "photon") { - const expectedArgs = ["integration", "setup", entry.slug]; + const expectedArgs = ["integration", "setup", registrySlug]; if ( setup?.command !== "eve" || setup.package !== "eve" || diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index 162cecca0..e50015bfa 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -97,6 +97,7 @@ export async function runIntegrationSetupCommand( }); if (result.kind === "cancelled") { client?.cancel(); + if (process.env.EVE_SETUP === "1") process.exitCode = 130; return; } prompter.outro("Integration set up."); From ce6c334a9fc1e21d652feaed8e241ff3a072b39e Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:20:13 -0700 Subject: [PATCH 08/12] fix(eve): resolve Photon setup restack conflict Signed-off-by: owenkephart --- .../eve/src/cli/commands/integration-setup.ts | 23 ++++++------------- packages/eve/src/setup/photon-setup.test.ts | 4 ++-- packages/eve/src/setup/photon-setup.ts | 4 ++-- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index e50015bfa..eb9660efc 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -7,23 +7,9 @@ import type { AddChannelsDeps } from "#setup/integrations/channel-scaffold.js"; import { channelSetupEnvironment, describeChannelSetupEnvironment, -<<<<<<< HEAD } from "#setup/integrations/shared/environment.js"; import { channelSetupIntegration, createChannelSetupUi } from "#setup/integrations/registry.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"; -======= -} from "#setup/integrations/channels/environment.js"; -import { - channelSetupIntegration, - createChannelSetupUi, -} from "#setup/integrations/channels/index.js"; -import { setupPhoton, type PhotonSetupDeps } from "#setup/photon-setup.js"; ->>>>>>> b21842f3 (refactor(eve): simplify Photon setup flow) +import { setupPhoton } from "#setup/photon-setup.js"; import { detectDeployment, projectResolutionFromDeployment } from "#setup/project-resolution.js"; >>>>>>> 23729a51 (fix(eve): stabilize Photon setup) import { createPrompter, type Prompter } from "#setup/prompter.js"; @@ -44,7 +30,13 @@ export interface IntegrationSetupOptions { export interface IntegrationSetupDependencies { createPrompter?: () => Prompter; +<<<<<<< HEAD runnerDeps?: IntegrationSetupRunnerDeps; +======= + detectDeployment: typeof detectDeployment; + getVercelAuthStatus: typeof getVercelAuthStatus; + addChannelsDeps?: AddChannelsDeps; +>>>>>>> 855f34ab (fix(eve): resolve Photon setup restack conflict) } const defaultIntegrationSetupDependencies: IntegrationSetupDependencies = {}; @@ -92,7 +84,6 @@ export async function runIntegrationSetupCommand( projectPath: appRoot, environment, ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), - deps: dependencies.photonDeps, signal, }); if (result.kind === "cancelled") { diff --git a/packages/eve/src/setup/photon-setup.test.ts b/packages/eve/src/setup/photon-setup.test.ts index c14a0decf..0f752c35d 100644 --- a/packages/eve/src/setup/photon-setup.test.ts +++ b/packages/eve/src/setup/photon-setup.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import type { Asker, Question } from "./ask.js"; -import { channelSetupEnvironment } from "./integrations/channels/environment.js"; -import { createChannelSetupUi } from "./integrations/channels/ui.js"; +import { channelSetupEnvironment } from "./integrations/shared/environment.js"; +import { createChannelSetupUi } from "./integrations/shared/ui.js"; import { setupPhoton, type PhotonSetupDeps } from "./photon-setup.js"; function asker(answers: Record): Asker { diff --git a/packages/eve/src/setup/photon-setup.ts b/packages/eve/src/setup/photon-setup.ts index c1f300fa6..c72a137d0 100644 --- a/packages/eve/src/setup/photon-setup.ts +++ b/packages/eve/src/setup/photon-setup.ts @@ -13,8 +13,8 @@ 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 type { ChannelSetupEnvironment } from "./integrations/channels/environment.js"; -import type { ChannelSetupUi } from "./integrations/channels/ui.js"; +import type { ChannelSetupEnvironment } from "./integrations/shared/environment.js"; +import type { ChannelSetupUi } from "./integrations/shared/ui.js"; import { ensureVercelProject } from "./flows/ensure-vercel-project.js"; /** Inputs for Photon project provisioning and channel scaffolding. */ From 1f8ccff69bcc21c0cdf5e59a45ae2ce97240b6d7 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:28:15 -0700 Subject: [PATCH 09/12] fix(eve): resolve integration setup restack conflict Signed-off-by: owenkephart --- .../eve/src/cli/commands/integration-setup.ts | 75 ------------------- 1 file changed, 75 deletions(-) diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index eb9660efc..d775dbb31 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -1,17 +1,3 @@ -<<<<<<< HEAD -======= -import { basename } from "node:path"; - -import { interactiveAsker } from "#setup/ask.js"; -import type { AddChannelsDeps } from "#setup/integrations/channel-scaffold.js"; -import { - channelSetupEnvironment, - describeChannelSetupEnvironment, -} from "#setup/integrations/shared/environment.js"; -import { channelSetupIntegration, createChannelSetupUi } from "#setup/integrations/registry.js"; -import { setupPhoton } from "#setup/photon-setup.js"; -import { detectDeployment, projectResolutionFromDeployment } from "#setup/project-resolution.js"; ->>>>>>> 23729a51 (fix(eve): stabilize Photon setup) import { createPrompter, type Prompter } from "#setup/prompter.js"; import { createRegistrySetupClient } from "#setup/registry-setup-client.js"; import { @@ -30,13 +16,7 @@ export interface IntegrationSetupOptions { export interface IntegrationSetupDependencies { createPrompter?: () => Prompter; -<<<<<<< HEAD runnerDeps?: IntegrationSetupRunnerDeps; -======= - detectDeployment: typeof detectDeployment; - getVercelAuthStatus: typeof getVercelAuthStatus; - addChannelsDeps?: AddChannelsDeps; ->>>>>>> 855f34ab (fix(eve): resolve Photon setup restack conflict) } const defaultIntegrationSetupDependencies: IntegrationSetupDependencies = {}; @@ -58,7 +38,6 @@ export async function runIntegrationSetupCommand( const client = createRegistrySetupClient({ signal: options.signal }); try { const prompter = client?.prompter ?? dependencies.createPrompter?.() ?? createPrompter(); -<<<<<<< HEAD const result = await runIntegrationSetup( kind, { @@ -66,60 +45,6 @@ export async function runIntegrationSetupCommand( prompter, signal: client?.signal ?? options.signal, yes: options.yes, -======= - const signal = client?.signal ?? options.signal; - const [deployment, authStatus] = await Promise.all([ - dependencies.detectDeployment(appRoot, { signal }), - dependencies.getVercelAuthStatus(appRoot, { signal }), - ]); - const project = projectResolutionFromDeployment(deployment); - - if (kind === "photon") { - prompter.intro("Set up Photon"); - prompter.log.message("Checking Vercel setup..."); - const environment = channelSetupEnvironment(authStatus, project); - prompter.log.info(describeChannelSetupEnvironment(environment)); - const result = await setupPhoton({ - agentName: basename(appRoot), - projectPath: appRoot, - environment, - ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), - signal, - }); - if (result.kind === "cancelled") { - client?.cancel(); - if (process.env.EVE_SETUP === "1") process.exitCode = 130; - return; - } - prompter.outro("Integration set up."); - client?.complete([ - ...(result.assignedPhoneNumber === undefined - ? [] - : [ - { - label: "Text your agent", - value: result.assignedPhoneNumber, - kind: "phone" as const, - }, - ]), - { label: "Photon project", value: result.dashboardUrl, kind: "url" }, - ]); - 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({ - environment, - state: { - ...createDefaultSetupState(), - project, - projectPath: { kind: "resolved", inPlace: true, path: appRoot }, ->>>>>>> 23729a51 (fix(eve): stabilize Photon setup) }, dependencies.runnerDeps, ); From a4d3e8cfd919ed66113499e64657d5c6d23409bf Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:29:42 -0700 Subject: [PATCH 10/12] refactor(eve): register Photon setup integration Signed-off-by: owenkephart --- .../src/setup/integrations/photon/setup.ts | 42 +++++++++++++++++++ .../eve/src/setup/integrations/registry.ts | 5 ++- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 packages/eve/src/setup/integrations/photon/setup.ts diff --git a/packages/eve/src/setup/integrations/photon/setup.ts b/packages/eve/src/setup/integrations/photon/setup.ts new file mode 100644 index 000000000..4bbc3936b --- /dev/null +++ b/packages/eve/src/setup/integrations/photon/setup.ts @@ -0,0 +1,42 @@ +import { basename } from "node:path"; + +import { setupPhoton } from "#setup/photon-setup.js"; + +import type { ChannelSetupIntegration } from "../types.js"; + +/** Photon project provisioning and iMessage channel scaffolding. */ +export const PHOTON_SETUP: ChannelSetupIntegration = { + kind: "photon", + label: "Photon", + hint: "Messages through Photon", + async setup(context) { + if (context.state.projectPath.kind !== "resolved") { + throw new Error("Project path has not been resolved."); + } + const result = await setupPhoton({ + agentName: basename(context.state.projectPath.path), + projectPath: context.state.projectPath.path, + environment: context.environment, + ui: context.ui, + signal: context.signal, + force: context.force, + }); + if (result.kind === "cancelled") return result; + return { + kind: "done", + state: context.state, + facts: [ + ...(result.assignedPhoneNumber === undefined + ? [] + : [ + { + label: "Text your agent", + value: result.assignedPhoneNumber, + kind: "phone" as const, + }, + ]), + { label: "Photon project", value: result.dashboardUrl, kind: "url" as const }, + ], + }; + }, +}; diff --git a/packages/eve/src/setup/integrations/registry.ts b/packages/eve/src/setup/integrations/registry.ts index bc654ef29..071e4dfa1 100644 --- a/packages/eve/src/setup/integrations/registry.ts +++ b/packages/eve/src/setup/integrations/registry.ts @@ -1,9 +1,10 @@ -import type { SetupIntegration } from "./types.js"; +import { PHOTON_SETUP } from "./photon/setup.js"; import { SLACK_SETUP } from "./slack/setup.js"; +import type { SetupIntegration } from "./types.js"; import { WEB_SETUP } from "./web/setup.js"; /** Built-in registry setup integrations in canonical picker order. */ -export const SETUP_INTEGRATIONS: readonly SetupIntegration[] = [WEB_SETUP, SLACK_SETUP]; +export const SETUP_INTEGRATIONS: readonly SetupIntegration[] = [WEB_SETUP, SLACK_SETUP, PHOTON_SETUP]; /** Resolves one built-in setup integration by its registry setup name. */ export function setupIntegration(kind: string): SetupIntegration { From acb84d2016b7aab1a03436c75626aa5b4c787346 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:30:43 -0700 Subject: [PATCH 11/12] refactor(eve): colocate Photon setup modules Signed-off-by: owenkephart --- .../photon/connect.test.ts} | 2 +- .../photon/connect.ts} | 0 .../photon/management.test.ts} | 2 +- .../photon/management.ts} | 0 .../photon/setup-flow.test.ts} | 8 +++---- .../photon/setup-flow.ts} | 22 +++++++++---------- .../src/setup/integrations/photon/setup.ts | 2 +- 7 files changed, 18 insertions(+), 18 deletions(-) rename packages/eve/src/setup/{photon-connect.test.ts => integrations/photon/connect.test.ts} (99%) rename packages/eve/src/setup/{photon-connect.ts => integrations/photon/connect.ts} (100%) rename packages/eve/src/setup/{photon-management.test.ts => integrations/photon/management.test.ts} (99%) rename packages/eve/src/setup/{photon-management.ts => integrations/photon/management.ts} (100%) rename packages/eve/src/setup/{photon-setup.test.ts => integrations/photon/setup-flow.test.ts} (92%) rename packages/eve/src/setup/{photon-setup.ts => integrations/photon/setup-flow.ts} (93%) diff --git a/packages/eve/src/setup/photon-connect.test.ts b/packages/eve/src/setup/integrations/photon/connect.test.ts similarity index 99% rename from packages/eve/src/setup/photon-connect.test.ts rename to packages/eve/src/setup/integrations/photon/connect.test.ts index 19152449d..226ff7218 100644 --- a/packages/eve/src/setup/photon-connect.test.ts +++ b/packages/eve/src/setup/integrations/photon/connect.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import type { ChannelSetupLog } from "./cli/index.js"; -import { parseCreatedPhotonConnector, provisionPhotonConnector } from "./photon-connect.js"; +import { parseCreatedPhotonConnector, provisionPhotonConnector } from "./connect.js"; function log(): ChannelSetupLog { return { diff --git a/packages/eve/src/setup/photon-connect.ts b/packages/eve/src/setup/integrations/photon/connect.ts similarity index 100% rename from packages/eve/src/setup/photon-connect.ts rename to packages/eve/src/setup/integrations/photon/connect.ts diff --git a/packages/eve/src/setup/photon-management.test.ts b/packages/eve/src/setup/integrations/photon/management.test.ts similarity index 99% rename from packages/eve/src/setup/photon-management.test.ts rename to packages/eve/src/setup/integrations/photon/management.test.ts index cfd76a391..4217834d4 100644 --- a/packages/eve/src/setup/photon-management.test.ts +++ b/packages/eve/src/setup/integrations/photon/management.test.ts @@ -4,7 +4,7 @@ import { provisionPhotonProject, usePhotonProject, validatePhotonPhoneNumber, -} from "./photon-management.js"; +} from "./management.js"; function response(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { diff --git a/packages/eve/src/setup/photon-management.ts b/packages/eve/src/setup/integrations/photon/management.ts similarity index 100% rename from packages/eve/src/setup/photon-management.ts rename to packages/eve/src/setup/integrations/photon/management.ts diff --git a/packages/eve/src/setup/photon-setup.test.ts b/packages/eve/src/setup/integrations/photon/setup-flow.test.ts similarity index 92% rename from packages/eve/src/setup/photon-setup.test.ts rename to packages/eve/src/setup/integrations/photon/setup-flow.test.ts index 0f752c35d..73a700bc1 100644 --- a/packages/eve/src/setup/photon-setup.test.ts +++ b/packages/eve/src/setup/integrations/photon/setup-flow.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import type { Asker, Question } from "./ask.js"; -import { channelSetupEnvironment } from "./integrations/shared/environment.js"; -import { createChannelSetupUi } from "./integrations/shared/ui.js"; -import { setupPhoton, type PhotonSetupDeps } from "./photon-setup.js"; +import type { Asker, Question } from "../../ask.js"; +import { channelSetupEnvironment } from "../shared/environment.js"; +import { createChannelSetupUi } from "../shared/ui.js"; +import { setupPhoton, type PhotonSetupDeps } from "./setup-flow.js"; function asker(answers: Record): Asker { return { diff --git a/packages/eve/src/setup/photon-setup.ts b/packages/eve/src/setup/integrations/photon/setup-flow.ts similarity index 93% rename from packages/eve/src/setup/photon-setup.ts rename to packages/eve/src/setup/integrations/photon/setup-flow.ts index c72a137d0..1c4a14b95 100644 --- a/packages/eve/src/setup/photon-setup.ts +++ b/packages/eve/src/setup/integrations/photon/setup-flow.ts @@ -1,21 +1,21 @@ import { join } from "node:path"; -import { text } from "./ask.js"; -import { appendEnv } from "./append-env.js"; -import { provisionPhotonConnector } from "./photon-connect.js"; +import { text } from "../../ask.js"; +import { appendEnv } from "../../append-env.js"; +import { provisionPhotonConnector } from "./connect.js"; import { provisionPhotonProject, usePhotonProject, validatePhotonPhoneNumber, type PhotonManagedProject, -} from "./photon-management.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 type { ChannelSetupEnvironment } from "./integrations/shared/environment.js"; -import type { ChannelSetupUi } from "./integrations/shared/ui.js"; -import { ensureVercelProject } from "./flows/ensure-vercel-project.js"; +} from "./management.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 type { ChannelSetupEnvironment } from "../shared/environment.js"; +import type { ChannelSetupUi } from "../shared/ui.js"; +import { ensureVercelProject } from "../../flows/ensure-vercel-project.js"; /** Inputs for Photon project provisioning and channel scaffolding. */ export interface PhotonSetupOptions { diff --git a/packages/eve/src/setup/integrations/photon/setup.ts b/packages/eve/src/setup/integrations/photon/setup.ts index 4bbc3936b..a6f70b862 100644 --- a/packages/eve/src/setup/integrations/photon/setup.ts +++ b/packages/eve/src/setup/integrations/photon/setup.ts @@ -1,6 +1,6 @@ import { basename } from "node:path"; -import { setupPhoton } from "#setup/photon-setup.js"; +import { setupPhoton } from "#setup/integrations/photon/setup-flow.js"; import type { ChannelSetupIntegration } from "../types.js"; From 7ff90c0933e317aede3e6d4c1e38c62eb72383e2 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:52:18 -0700 Subject: [PATCH 12/12] refactor(eve): finish independent integration setup flows Signed-off-by: owenkephart --- .changeset/bright-messages-connect.md | 2 +- .../public/r/channel/photon-imessage.json | 18 ++- apps/docs/registry.json | 2 +- .../docs/scripts/validate-channel-registry.ts | 13 +- .../eve/src/cli/commands/registry.test.ts | 25 +++ packages/eve/src/cli/commands/registry.ts | 8 +- packages/eve/src/setup/flows/registry.test.ts | 31 ++++ packages/eve/src/setup/flows/registry.ts | 1 + .../setup/integrations/photon/connect.test.ts | 2 +- .../setup/integrations/photon/management.ts | 145 ++++++++++++------ .../integrations/photon/setup-flow.test.ts | 16 +- .../setup/integrations/photon/setup-flow.ts | 8 +- .../src/setup/integrations/photon/setup.ts | 14 +- .../eve/src/setup/integrations/registry.ts | 6 +- 14 files changed, 213 insertions(+), 78 deletions(-) diff --git a/.changeset/bright-messages-connect.md b/.changeset/bright-messages-connect.md index 173001495..e044ac732 100644 --- a/.changeset/bright-messages-connect.md +++ b/.changeset/bright-messages-connect.md @@ -3,4 +3,4 @@ "@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. +Add guided Photon setup through `eve add channel/photon-imessage`, including project creation, phone registration, Vercel Connect or portable credentials, and channel scaffolding. diff --git a/apps/docs/public/r/channel/photon-imessage.json b/apps/docs/public/r/channel/photon-imessage.json index 6f9c5829c..fca9a71df 100644 --- a/apps/docs/public/r/channel/photon-imessage.json +++ b/apps/docs/public/r/channel/photon-imessage.json @@ -1,19 +1,25 @@ { "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "channel/photon-imessage", - "type": "registry:item", - "title": "Photon", + "title": "Photon iMessage", "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", - "dependencies": ["@vercel/connect@0.5.0"], + "dependencies": [ + "@vercel/connect@0.5.0" + ], "meta": { "eve": { "setup": { "command": "eve", "package": "eve", "bin": "eve", - "args": ["integration", "setup", "photon"] + "args": [ + "integration", + "setup", + "photon" + ] }, "requires": ">=0.29.0" } - } -} + }, + "type": "registry:item" +} \ No newline at end of file diff --git a/apps/docs/registry.json b/apps/docs/registry.json index f3e560814..58008e42a 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -1211,7 +1211,7 @@ { "name": "channel/photon-imessage", "type": "registry:item", - "title": "Photon", + "title": "Photon iMessage", "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", "dependencies": ["@vercel/connect@0.5.0"], "meta": { diff --git a/apps/docs/scripts/validate-channel-registry.ts b/apps/docs/scripts/validate-channel-registry.ts index fdd7c08e4..38e0ec2d1 100644 --- a/apps/docs/scripts/validate-channel-registry.ts +++ b/apps/docs/scripts/validate-channel-registry.ts @@ -33,6 +33,11 @@ const registrySlugsByCatalogSlug: Readonly> = { photon: "photon-imessage", }; +const setupKindsByCatalogSlug: Readonly> = { + eve: "web", + photon: "photon", +}; + const adapterDependenciesByCatalogSlug: Readonly> = { "chat-sdk-gchat": "@chat-adapter/gchat", "chat-sdk-whatsapp": "@chat-adapter/whatsapp", @@ -106,7 +111,11 @@ for (const [index, item] of items.entries()) { const registrySlug = expectedSlugs[index]; if (entry.slug === "slack" || entry.slug === "eve" || entry.slug === "photon") { - const expectedArgs = ["integration", "setup", registrySlug]; + const expectedArgs = [ + "integration", + "setup", + setupKindsByCatalogSlug[entry.slug] ?? registrySlug, + ]; if ( setup?.command !== "eve" || setup.package !== "eve" || @@ -114,7 +123,7 @@ for (const [index, item] of items.entries()) { JSON.stringify(setup.args) !== JSON.stringify(expectedArgs) ) { throw new Error( - `Registry item "${item.name}" must delegate setup to eve integration setup ${registrySlug}.`, + `Registry item "${item.name}" must delegate setup to eve integration setup ${expectedArgs[2]}.`, ); } continue; diff --git a/packages/eve/src/cli/commands/registry.test.ts b/packages/eve/src/cli/commands/registry.test.ts index a4785fb40..6acf857b1 100644 --- a/packages/eve/src/cli/commands/registry.test.ts +++ b/packages/eve/src/cli/commands/registry.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import { + browseRegistryCatalog, runAddCommand, runRegistryAddCommand, runRegistryListCommand, @@ -55,6 +56,10 @@ function createLogger(): RegistryCommandLogger & { errors: string[]; logs: strin describe("registry commands", () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ items: [] }))), + ); isEveProject.mockResolvedValue(true); readFile.mockResolvedValue( JSON.stringify({ @@ -66,6 +71,7 @@ describe("registry commands", () => { afterEach(() => { process.exitCode = undefined; + vi.unstubAllGlobals(); }); it("installs official items through the registry SDK", async () => { @@ -406,6 +412,25 @@ describe("registry commands", () => { expect(logger.logs).toContain("Added @other to package.json."); }); + it("uses the Photon iMessage title for the official provider", async () => { + searchRegistries.mockResolvedValue({ + items: [ + { + registry: "https://eve.dev/r/registry.json", + name: "channel/photon-imessage", + addCommandArgument: "https://eve.dev/r/channel/photon-imessage.json", + }, + ], + pagination: { total: 1, offset: 0, limit: 1, hasMore: false }, + }); + + await expect(browseRegistryCatalog("/project")).resolves.toMatchObject({ + items: [ + expect.objectContaining({ name: "channel/photon-imessage", title: "Photon iMessage" }), + ], + }); + }); + it("lists the official registry without configured namespaces", async () => { const logger = createLogger(); readFile.mockResolvedValue(JSON.stringify({ name: "project" })); diff --git a/packages/eve/src/cli/commands/registry.ts b/packages/eve/src/cli/commands/registry.ts index d241c555c..569309f32 100644 --- a/packages/eve/src/cli/commands/registry.ts +++ b/packages/eve/src/cli/commands/registry.ts @@ -48,6 +48,7 @@ export interface AddCommandDependencies extends RegistrySetupDependencies { export interface RegistryCatalogItem { address: string; name: string; + title?: string; type?: string; description?: string; source: string; @@ -67,9 +68,12 @@ const defaultAddCommandDependencies: AddCommandDependencies = { (await import("./registry-setup-command.js")).runRegistrySetupCommand, }; -const OFFICIAL_REGISTRY = "https://eve.dev/r"; +const OFFICIAL_REGISTRY = process.env.EVE_DEV_OFFICIAL_REGISTRY_URL ?? "https://eve.dev/r"; const OFFICIAL_CATALOG = `${OFFICIAL_REGISTRY}/registry.json`; +// shadcn's registry search response omits titles. Keep this one exception until it includes them. +const OFFICIAL_CATALOG_TITLES = new Map([["channel/photon-imessage", "Photon iMessage"]]); + function isRegistryAddress(value: string): boolean { return value.startsWith("@") || /^https?:\/\//.test(value); } @@ -228,6 +232,8 @@ export async function browseRegistryCatalog( name: item.name, source: item.registry === OFFICIAL_CATALOG ? "Vercel" : item.registry, }; + const title = OFFICIAL_CATALOG_TITLES.get(item.name); + if (title !== undefined && item.registry === OFFICIAL_CATALOG) catalogItem.title = title; if (item.type !== undefined) catalogItem.type = item.type; if (item.description !== undefined) catalogItem.description = item.description; return catalogItem; diff --git a/packages/eve/src/setup/flows/registry.test.ts b/packages/eve/src/setup/flows/registry.test.ts index 0b37f4a34..470126c5c 100644 --- a/packages/eve/src/setup/flows/registry.test.ts +++ b/packages/eve/src/setup/flows/registry.test.ts @@ -95,6 +95,37 @@ describe("runRegistryFlow", () => { }); }); + it("uses the registry title when labeling an item", async () => { + const answers = ["category:channel", "action:back", "action:done"]; + const prompts: unknown[] = []; + const fake = createFakePrompter({ + single: (options) => { + prompts.push(options); + return answers.shift()!; + }, + }); + const flowDeps = deps({ + browseRegistryCatalog: vi.fn(async () => ({ + items: [ + { + address: "channel/photon-imessage", + name: "channel/photon-imessage", + title: "Photon", + source: "Vercel", + }, + ], + total: 1, + errors: [], + })), + }); + + await runRegistryFlow({ appRoot: APP_ROOT, prompter: fake.prompter, deps: flowDeps }); + + expect(prompts[1]).toMatchObject({ + options: expect.arrayContaining([expect.objectContaining({ label: "Photon" })]), + }); + }); + it("keeps setup on the parent prompter without leasing the terminal", async () => { const answers = ["category:channel", "item:0", "add"]; const fake = createFakePrompter({ single: () => answers.shift()! }); diff --git a/packages/eve/src/setup/flows/registry.ts b/packages/eve/src/setup/flows/registry.ts index cdcaec5a3..b541ffcce 100644 --- a/packages/eve/src/setup/flows/registry.ts +++ b/packages/eve/src/setup/flows/registry.ts @@ -60,6 +60,7 @@ export type RegistryFlowResult = | { kind: "cancelled" }; function itemLabel(item: RegistryCatalogItem): string { + if (item.title !== undefined) return item.title; const name = item.name.split("/").at(-1) ?? item.name; return name .split("-") diff --git a/packages/eve/src/setup/integrations/photon/connect.test.ts b/packages/eve/src/setup/integrations/photon/connect.test.ts index 226ff7218..03b7b73c3 100644 --- a/packages/eve/src/setup/integrations/photon/connect.test.ts +++ b/packages/eve/src/setup/integrations/photon/connect.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from "vitest"; -import type { ChannelSetupLog } from "./cli/index.js"; +import type { ChannelSetupLog } from "#setup/cli/index.js"; import { parseCreatedPhotonConnector, provisionPhotonConnector } from "./connect.js"; function log(): ChannelSetupLog { diff --git a/packages/eve/src/setup/integrations/photon/management.ts b/packages/eve/src/setup/integrations/photon/management.ts index af03e5260..cd74b2399 100644 --- a/packages/eve/src/setup/integrations/photon/management.ts +++ b/packages/eve/src/setup/integrations/photon/management.ts @@ -1,5 +1,7 @@ import { setTimeout as sleep } from "node:timers/promises"; +import { z } from "#compiled/zod/index.js"; + const PHOTON_DASHBOARD_HOST = "https://app.photon.codes"; const PHOTON_SPECTRUM_HOST = "https://spectrum.photon.codes"; const PHOTON_DEVICE_CLIENT_ID = "photon-cli"; @@ -17,6 +19,43 @@ export function validatePhotonPhoneNumber(value: string): string | null { return null; } +const DeviceAuthorizationSchema = z.object({ + device_code: z.string().min(1), + user_code: z.string().min(1), + verification_uri_complete: z.string().url().optional(), + verification_uri: z.string().url().optional(), + expires_in: z.number().positive().optional(), + interval: z.number().positive().optional(), +}); + +const DeviceTokenSchema = z.object({ + access_token: z.string().min(1).optional(), + accessToken: z.string().min(1).optional(), +}); + +const ProjectSchema = z.object({ id: z.string().min(1) }); +const ProjectSecretSchema = z.object({ projectSecret: z.string().min(1) }); +const ErrorResponseSchema = z.object({ + error: z.unknown().optional(), + message: z.unknown().optional(), +}); +const PhoneRegistrationSchema = z.object({ + assignedPhoneNumber: z.string().optional(), + phoneNumber: z.string().optional(), + data: z + .object({ + assignedPhoneNumber: z.string().optional(), + phoneNumber: z.string().optional(), + user: z + .object({ assignedPhoneNumber: z.string().optional(), phoneNumber: z.string().optional() }) + .optional(), + }) + .optional(), + user: z + .object({ assignedPhoneNumber: z.string().optional(), phoneNumber: z.string().optional() }) + .optional(), +}); + interface DeviceCodeResponse { deviceCode: string; userCode: string; @@ -63,21 +102,23 @@ const defaultDeps: PhotonManagementDeps = { 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; +function errorDetail(body: unknown, fallback: string): string { + const parsed = ErrorResponseSchema.safeParse(body); + return String(parsed.success ? (parsed.data.error ?? parsed.data.message ?? fallback) : fallback); } -async function json(response: Response, action: string): Promise> { - const body: unknown = await response.json().catch(() => ({})); +function parsePhotonResponse(schema: z.ZodType, body: unknown, message: string): T { + const parsed = schema.safeParse(body); + if (!parsed.success) throw new Error(message); + return parsed.data; +} + +async function json(response: Response, action: string): Promise { + const body: unknown = await response.json().catch(() => undefined); if (!response.ok) { - const value = record(body); - const detail = value["error"] ?? value["message"] ?? response.statusText; - throw new Error(`Photon ${action} failed: ${String(detail)}`); + throw new Error(`Photon ${action} failed: ${errorDetail(body, response.statusText)}`); } - return record(body); + return body; } function bearer(token: string): Record { @@ -97,23 +138,21 @@ async function requestDeviceCode(deps: PhotonManagementDeps): Promise ({}))); + const body: unknown = await response.json().catch(() => undefined); if (response.ok) { - const token = body["access_token"] ?? body["accessToken"]; - if (typeof token === "string" && token.length > 0) return token; + const token = parsePhotonResponse( + DeviceTokenSchema, + body, + "Photon approved device login without returning an access token.", + ); + if (token.access_token !== undefined) return token.access_token; + if (token.accessToken !== undefined) return token.accessToken; 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) { + const error = ErrorResponseSchema.safeParse(body); + const statusCode = error.success ? (error.data.error ?? error.data.message) : undefined; + if (statusCode === "authorization_pending") continue; + if (statusCode === "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 failed: ${String(statusCode ?? response.statusText)}`); } throw new Error("Photon device login timed out."); } @@ -169,9 +214,11 @@ async function createProject( 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"]; + return parsePhotonResponse( + ProjectSchema, + await json(response, "project creation"), + "Photon did not return a project ID.", + ).id; } async function regenerateSecret( @@ -183,11 +230,11 @@ async function regenerateSecret( `${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"]; + return parsePhotonResponse( + ProjectSecretSchema, + await json(response, "project credential provisioning"), + "Photon did not return the new project secret.", + ).projectSecret; } async function registerUser( @@ -204,11 +251,21 @@ async function registerUser( 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; + const body = parsePhotonResponse( + PhoneRegistrationSchema, + await json(response, "phone registration"), + "Photon returned an invalid phone registration response.", + ); + return ( + body.data?.user?.assignedPhoneNumber ?? + body.data?.user?.phoneNumber ?? + body.data?.assignedPhoneNumber ?? + body.data?.phoneNumber ?? + body.user?.assignedPhoneNumber ?? + body.user?.phoneNumber ?? + body.assignedPhoneNumber ?? + body.phoneNumber + ); } async function deleteProject( diff --git a/packages/eve/src/setup/integrations/photon/setup-flow.test.ts b/packages/eve/src/setup/integrations/photon/setup-flow.test.ts index 73a700bc1..12ad08b17 100644 --- a/packages/eve/src/setup/integrations/photon/setup-flow.test.ts +++ b/packages/eve/src/setup/integrations/photon/setup-flow.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import type { Asker, Question } from "../../ask.js"; -import { channelSetupEnvironment } from "../shared/environment.js"; -import { createChannelSetupUi } from "../shared/ui.js"; +import { integrationSetupEnvironment } from "../shared/environment.js"; +import { createIntegrationSetupUi } from "../shared/ui.js"; import { setupPhoton, type PhotonSetupDeps } from "./setup-flow.js"; function asker(answers: Record): Asker { @@ -40,8 +40,8 @@ describe("Photon setup", () => { setupPhoton({ agentName: "agent", projectPath: "/project", - environment: channelSetupEnvironment("cli-missing", { kind: "unresolved" }), - ui: createChannelSetupUi({ + environment: integrationSetupEnvironment("cli-missing", { kind: "unresolved" }), + ui: createIntegrationSetupUi({ asker: asker({ "photon-phone-number": "+15551234567" }), prompter: fake.prompter, }), @@ -68,8 +68,8 @@ describe("Photon setup", () => { await setupPhoton({ agentName: "weather-agent", projectPath: "/project", - environment: channelSetupEnvironment("cli-missing", { kind: "unresolved" }), - ui: createChannelSetupUi({ + environment: integrationSetupEnvironment("cli-missing", { kind: "unresolved" }), + ui: createIntegrationSetupUi({ asker: asker({ "photon-phone-number": "+15551234567" }), prompter: fake.prompter, }), @@ -88,8 +88,8 @@ describe("Photon setup", () => { setupPhoton({ agentName: "agent", projectPath: "/project", - environment: channelSetupEnvironment("cli-missing", { kind: "unresolved" }), - ui: createChannelSetupUi({ asker: asker({}), prompter: fake.prompter }), + environment: integrationSetupEnvironment("cli-missing", { kind: "unresolved" }), + ui: createIntegrationSetupUi({ asker: asker({}), prompter: fake.prompter }), deps: deps(), }), ).rejects.toThrow("vercel login"); diff --git a/packages/eve/src/setup/integrations/photon/setup-flow.ts b/packages/eve/src/setup/integrations/photon/setup-flow.ts index 1c4a14b95..165b5510a 100644 --- a/packages/eve/src/setup/integrations/photon/setup-flow.ts +++ b/packages/eve/src/setup/integrations/photon/setup-flow.ts @@ -13,16 +13,16 @@ 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 type { ChannelSetupEnvironment } from "../shared/environment.js"; -import type { ChannelSetupUi } from "../shared/ui.js"; +import type { IntegrationSetupEnvironment } from "../shared/environment.js"; +import type { IntegrationSetupUi } from "../shared/ui.js"; import { ensureVercelProject } from "../../flows/ensure-vercel-project.js"; /** Inputs for Photon project provisioning and channel scaffolding. */ export interface PhotonSetupOptions { agentName: string; projectPath: string; - environment: ChannelSetupEnvironment; - ui: ChannelSetupUi; + environment: IntegrationSetupEnvironment; + ui: IntegrationSetupUi; signal?: AbortSignal; force?: boolean; deps?: PhotonSetupDeps; diff --git a/packages/eve/src/setup/integrations/photon/setup.ts b/packages/eve/src/setup/integrations/photon/setup.ts index a6f70b862..0bc879c1c 100644 --- a/packages/eve/src/setup/integrations/photon/setup.ts +++ b/packages/eve/src/setup/integrations/photon/setup.ts @@ -1,21 +1,18 @@ import { basename } from "node:path"; -import { setupPhoton } from "#setup/integrations/photon/setup-flow.js"; +import { setupPhoton } from "./setup-flow.js"; -import type { ChannelSetupIntegration } from "../types.js"; +import type { SetupIntegration } from "../types.js"; /** Photon project provisioning and iMessage channel scaffolding. */ -export const PHOTON_SETUP: ChannelSetupIntegration = { +export const PHOTON_SETUP: SetupIntegration = { kind: "photon", label: "Photon", hint: "Messages through Photon", async setup(context) { - if (context.state.projectPath.kind !== "resolved") { - throw new Error("Project path has not been resolved."); - } const result = await setupPhoton({ - agentName: basename(context.state.projectPath.path), - projectPath: context.state.projectPath.path, + agentName: basename(context.appRoot), + projectPath: context.appRoot, environment: context.environment, ui: context.ui, signal: context.signal, @@ -24,7 +21,6 @@ export const PHOTON_SETUP: ChannelSetupIntegration = { if (result.kind === "cancelled") return result; return { kind: "done", - state: context.state, facts: [ ...(result.assignedPhoneNumber === undefined ? [] diff --git a/packages/eve/src/setup/integrations/registry.ts b/packages/eve/src/setup/integrations/registry.ts index 071e4dfa1..2120953a6 100644 --- a/packages/eve/src/setup/integrations/registry.ts +++ b/packages/eve/src/setup/integrations/registry.ts @@ -4,7 +4,11 @@ import type { SetupIntegration } from "./types.js"; import { WEB_SETUP } from "./web/setup.js"; /** Built-in registry setup integrations in canonical picker order. */ -export const SETUP_INTEGRATIONS: readonly SetupIntegration[] = [WEB_SETUP, SLACK_SETUP, PHOTON_SETUP]; +export const SETUP_INTEGRATIONS: readonly SetupIntegration[] = [ + WEB_SETUP, + SLACK_SETUP, + PHOTON_SETUP, +]; /** Resolves one built-in setup integration by its registry setup name. */ export function setupIntegration(kind: string): SetupIntegration {