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/.changeset/friendly-photons-wave.md b/.changeset/friendly-photons-wave.md new file mode 100644 index 000000000..a649b994a --- /dev/null +++ b/.changeset/friendly-photons-wave.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add `photonIMessageChannel`, a first-class Photon iMessage channel with lazy credentials, Vercel OIDC webhook verification, and automatic eve session routing. diff --git a/apps/docs/lib/integrations/data.ts b/apps/docs/lib/integrations/data.ts index 6bf6cc0ef..23cfabd79 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-imessage \`\`\``, - 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 { photonIMessageChannel } 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 photonIMessageChannel({ + credentials: connectPhotonCredentials("photon/my-agent"), }); - -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-imessage.json b/apps/docs/public/r/channel/photon-imessage.json new file mode 100644 index 000000000..fca9a71df --- /dev/null +++ b/apps/docs/public/r/channel/photon-imessage.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "channel/photon-imessage", + "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": { + "eve": { + "setup": { + "command": "eve", + "package": "eve", + "bin": "eve", + "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 dda92c36c..58008e42a 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -1209,22 +1209,22 @@ ] }, { - "name": "channel/chat-sdk-photon", + "name": "channel/photon-imessage", "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" + "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": { + "eve": { + "setup": { + "command": "eve", + "package": "eve", + "bin": "eve", + "args": ["integration", "setup", "photon"] + }, + "requires": ">=0.29.0" } - ] + } }, { "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..fdd7c08e4 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> = { @@ -44,7 +45,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 +65,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 +105,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/meta.json b/docs/channels/meta.json index c0bcc03d2..c8119fbce 100644 --- a/docs/channels/meta.json +++ b/docs/channels/meta.json @@ -4,6 +4,7 @@ "overview", "eve", "slack", + "photon", "discord", "teams", "telegram", diff --git a/docs/channels/overview.mdx b/docs/channels/overview.mdx index 892ed8ea7..4d0933c9e 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-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 new file mode 100644 index 000000000..ef0cb827b --- /dev/null +++ b/docs/channels/photon.mdx @@ -0,0 +1,78 @@ +--- +title: Photon +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-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 +the adapter first initializes: + +```ts title="agent/channels/photon.ts" +import { connectPhotonCredentials } from "@vercel/connect/eve"; +import { photonIMessageChannel } from "eve/channels/photon"; + +export default photonIMessageChannel({ + credentials: connectPhotonCredentials("photon/my-agent"), +}); +``` + +The default webhook route is `/eve/v1/photon`. The channel verifies forwarded +webhooks with same-project Vercel OIDC by default, marks accepted messages as +read, and continues the same eve session for every message in an iMessage +conversation. A new accepted message cooperatively cancels an active turn and +steers its replacement turn. + +Customize inbound dispatch with `onMessage`. Return `null` to ignore a message: + +```ts +export default photonIMessageChannel({ + credentials: connectPhotonCredentials("photon/my-agent"), + onMessage(_ctx, message) { + if (message.author.isBot) return null; + return { + auth: null, + context: [`The sender is ${message.author.fullName}.`], + }; + }, +}); +``` + +Set `route` to override the webhook path or `webhookVerifier` to use a different +trusted-forwarder verifier. + +## Other hosts + +For a host without Vercel Connect, choose **Use portable credentials** during +`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: + +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/package.json b/packages/eve/package.json index 731aa6b1f..747936b86 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -236,6 +236,11 @@ "import": "./dist/src/public/channels/chat-sdk/index.js", "default": "./dist/src/public/channels/chat-sdk/index.js" }, + "./channels/photon": { + "types": "./dist/src/public/channels/photon/index.d.ts", + "import": "./dist/src/public/channels/photon/index.js", + "default": "./dist/src/public/channels/photon/index.js" + }, "./channels/github": { "types": "./dist/src/public/channels/github/index.d.ts", "import": "./dist/src/public/channels/github/index.js", @@ -323,6 +328,7 @@ "@opentelemetry/context-async-hooks": "catalog:", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/sdk-trace-base": "catalog:", + "@photon-ai/chat-adapter-imessage": "3.2.0", "@standard-schema/spec": "1.1.0", "@sveltejs/kit": "^2.0.0", "@types/json-schema": "7.0.15", diff --git a/packages/eve/scripts/vendor-compiled/@photon-ai/chat-adapter-imessage.mjs b/packages/eve/scripts/vendor-compiled/@photon-ai/chat-adapter-imessage.mjs new file mode 100644 index 000000000..ea70cda0d --- /dev/null +++ b/packages/eve/scripts/vendor-compiled/@photon-ai/chat-adapter-imessage.mjs @@ -0,0 +1,28 @@ +import { createDeclarationCopier } from "../_shared.mjs"; + +export default { + packageName: "@photon-ai/chat-adapter-imessage", + compiledPath: "@photon-ai/chat-adapter-imessage", + copyDeclarations: createDeclarationCopier({ + rewrites: { + chat: { kind: "vendored", compiledPath: "chat" }, + "@chat-adapter/shared": { + kind: "stub", + stubBaseName: "_chat-adapter-shared", + build: () => "export {};\n", + }, + "@spectrum-ts/core": { + kind: "stub", + stubBaseName: "_spectrum-core", + build: () => + "export type AppUrl = unknown;\nexport type ContentBuilder = unknown;\nexport type SpectrumInstance = unknown;\n", + }, + "@spectrum-ts/imessage": { + kind: "stub", + stubBaseName: "_spectrum-imessage", + build: () => + "export type CustomizedMiniAppInput = unknown;\nexport type IMessageMessageEffect = string;\n", + }, + }, + }), +}; diff --git a/packages/eve/scripts/vendor-compiled/index.mjs b/packages/eve/scripts/vendor-compiled/index.mjs index 2b74bfce7..90a35ac9b 100644 --- a/packages/eve/scripts/vendor-compiled/index.mjs +++ b/packages/eve/scripts/vendor-compiled/index.mjs @@ -14,6 +14,7 @@ import providerUtils from "./@ai-sdk/provider-utils.mjs"; import chatAdapterSlack from "./@chat-adapter/slack.mjs"; import chatAdapterStateMemory from "./@chat-adapter/state-memory.mjs"; import chatAdapterTwilio from "./@chat-adapter/twilio.mjs"; +import photonChatAdapterIMessage from "./@photon-ai/chat-adapter-imessage.mjs"; import opentelemetryApi from "./@opentelemetry/api.mjs"; import opentelemetryOtlpTransformer from "./@opentelemetry/otlp-transformer.mjs"; @@ -69,6 +70,7 @@ export const MODULES = [ opentelemetryApi, opentelemetryOtlpTransformer, otel, + photonChatAdapterIMessage, picocolors, provider, providerUtils, 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..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 () => { @@ -363,7 +369,7 @@ describe("registry commands", () => { const logger = createLogger(); getRegistryItems.mockResolvedValue([ { - name: "channel/photon", + name: "channel/photon-imessage", type: "registry:item", meta: { eve: { @@ -374,7 +380,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.", @@ -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/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..60b217217 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-imessage.\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-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"); + 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/public/channels/photon/inboundContent.test.ts b/packages/eve/src/public/channels/photon/inboundContent.test.ts new file mode 100644 index 000000000..c031c075e --- /dev/null +++ b/packages/eve/src/public/channels/photon/inboundContent.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { Message } from "#compiled/chat/index.js"; +import { photonInboundContent } from "#public/channels/photon/inboundContent.js"; + +function message(text: string, attachments: Message["attachments"] = []): Message { + return new Message({ + attachments, + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text, + threadId: "thread-id", + }); +} + +describe("photonInboundContent", () => { + it("returns plain text", () => { + expect(photonInboundContent(message("hello"))).toBe("hello"); + }); + + it("drops blank messages", () => { + expect(photonInboundContent(message(" \n"))).toBeUndefined(); + }); + + it("drops attachment-only messages when Photon provides no attachment URL", () => { + expect( + photonInboundContent( + message("", [ + { + mimeType: "image/jpeg", + name: "photo.jpg", + size: 10, + type: "image", + }, + ]), + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/eve/src/public/channels/photon/inboundContent.ts b/packages/eve/src/public/channels/photon/inboundContent.ts new file mode 100644 index 000000000..6f5dec1c6 --- /dev/null +++ b/packages/eve/src/public/channels/photon/inboundContent.ts @@ -0,0 +1,13 @@ +import type { UserContent } from "ai"; + +import type { Message } from "#compiled/chat/index.js"; +import { messageToUserContent } from "#public/channels/chat-sdk/index.js"; + +/** Returns model-visible Photon content, or `undefined` for non-message events. */ +export function photonInboundContent(message: Message): string | UserContent | undefined { + const content = messageToUserContent(message); + if (typeof content === "string") { + return content.trim().length > 0 ? content : undefined; + } + return content.length > 0 ? content : undefined; +} diff --git a/packages/eve/src/public/channels/photon/index.ts b/packages/eve/src/public/channels/photon/index.ts new file mode 100644 index 000000000..249b38e0c --- /dev/null +++ b/packages/eve/src/public/channels/photon/index.ts @@ -0,0 +1,9 @@ +export { + photonIMessageChannel, + type PhotonIMessageChannel, + type PhotonIMessageChannelConfig, + type PhotonIMessageChannelCredentials, + type PhotonInboundMessageContext, + type PhotonInboundResult, + type PhotonInboundResultOrPromise, +} from "#public/channels/photon/photonIMessageChannel.js"; diff --git a/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts b/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts new file mode 100644 index 000000000..e52be32b9 --- /dev/null +++ b/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { directMessage, newMessage, send } = vi.hoisted(() => ({ + directMessage: vi.fn(), + newMessage: vi.fn(), + send: vi.fn(), +})); + +vi.mock("#public/channels/chat-sdk/index.js", () => ({ + chatSdkChannel: () => ({ + bot: { + getAdapter: () => ({ markRead: vi.fn() }), + onDirectMessage: directMessage, + onNewMessage: newMessage, + }, + channel: { routes: [] }, + send, + }), + messageToUserContent: (message: Message) => message.text, +})); +vi.mock("#compiled/@chat-adapter/state-memory/index.js", () => ({ + createMemoryState: vi.fn(), +})); +vi.mock("#compiled/@photon-ai/chat-adapter-imessage/index.js", () => ({ + createiMessageAdapter: vi.fn(), +})); +vi.mock("#public/channels/auth.js", () => ({ vercelOidc: vi.fn() })); + +import { photonIMessageChannel } from "#public/channels/photon/photonIMessageChannel.js"; +import { Message } from "#compiled/chat/index.js"; + +describe("photonIMessageChannel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("cancels the active Eve turn before steering a direct message into its thread", async () => { + photonIMessageChannel({ + credentials: async () => ({ projectId: "project-id", projectSecret: "project-secret" }), + }); + const handler = directMessage.mock.calls[0]?.[0]; + if (handler === undefined) throw new Error("Expected an inbound direct-message handler."); + const thread = { id: "thread-id" }; + const message = new Message({ + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text: "Steer this response", + threadId: thread.id, + }); + + await handler(thread, message); + + expect(send).toHaveBeenCalledWith( + { context: [], message: "Steer this response" }, + { auth: null, thread, turnPolicy: "experimental-steer" }, + ); + }); + + it("drops blank inbound messages without cancelling or sending", async () => { + photonIMessageChannel({ + credentials: async () => ({ projectId: "project-id", projectSecret: "project-secret" }), + }); + const handler = directMessage.mock.calls[0]?.[0]; + if (handler === undefined) throw new Error("Expected an inbound direct-message handler."); + const thread = { id: "thread-id" }; + const message = new Message({ + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text: " \n", + threadId: thread.id, + }); + + await handler(thread, message); + + expect(send).not.toHaveBeenCalled(); + }); + + it("routes group messages without a Chat SDK subscription", async () => { + photonIMessageChannel({ + credentials: async () => ({ projectId: "project-id", projectSecret: "project-secret" }), + }); + const [pattern, handler] = newMessage.mock.calls[0] ?? []; + if (!(pattern instanceof RegExp) || handler === undefined) { + throw new Error("Expected an inbound group-message handler."); + } + const thread = { id: "group-thread-id" }; + const message = new Message({ + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text: "Hello group", + threadId: thread.id, + }); + + expect(pattern.test(message.text)).toBe(true); + await handler(thread, message); + + expect(send).toHaveBeenCalledWith( + { context: [], message: "Hello group" }, + { auth: null, thread, turnPolicy: "experimental-steer" }, + ); + }); +}); diff --git a/packages/eve/src/public/channels/photon/photonIMessageChannel.ts b/packages/eve/src/public/channels/photon/photonIMessageChannel.ts new file mode 100644 index 000000000..f3f93c3c8 --- /dev/null +++ b/packages/eve/src/public/channels/photon/photonIMessageChannel.ts @@ -0,0 +1,139 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import { vercelOidc } from "#public/channels/auth.js"; +import { + chatSdkChannel, + type ChatSdkChannel, + type ChatSdkChannelBridge, + type ChatSdkChannelEvents, +} from "#public/channels/chat-sdk/index.js"; +import type { Message, Thread } from "#compiled/chat/index.js"; +import { createMemoryState } from "#compiled/@chat-adapter/state-memory/index.js"; +import { + createiMessageAdapter, + type iMessageAdapter, + type iMessageCredentialProvider, + type iMessageWebhookVerifier, +} from "#compiled/@photon-ai/chat-adapter-imessage/index.js"; +import { photonInboundContent } from "#public/channels/photon/inboundContent.js"; + +/** Photon project credentials used by {@link photonIMessageChannel}. */ +export type PhotonIMessageChannelCredentials = iMessageCredentialProvider; + +/** Context passed to {@link PhotonIMessageChannelConfig.onMessage}. */ +export interface PhotonInboundMessageContext { + /** Low-level Chat SDK thread for iMessage-specific operations. */ + readonly thread: Thread; +} + +/** Result of {@link PhotonIMessageChannelConfig.onMessage}. Return `null` to drop the message. */ +export type PhotonInboundResult = { + readonly auth: SessionAuthContext | null; + readonly context?: readonly string[]; +} | null; + +/** Sync or async {@link PhotonInboundResult}. */ +export type PhotonInboundResultOrPromise = PhotonInboundResult | Promise; + +/** Configuration for {@link photonIMessageChannel}. */ +export interface PhotonIMessageChannelConfig { + /** Lazy Photon project credentials, such as `connectPhotonCredentials(...)`. */ + readonly credentials: PhotonIMessageChannelCredentials; + /** Per-event overrides for the underlying Chat SDK channel. */ + readonly events?: ChatSdkChannelEvents<{ imessage: iMessageAdapter }>; + /** Inbound message policy. Defaults to dispatching every message with no user auth. */ + readonly onMessage?: ( + ctx: PhotonInboundMessageContext, + message: Message, + ) => PhotonInboundResultOrPromise; + /** Override the default webhook route (`/eve/v1/photon`). */ + readonly route?: string; + /** Display name used by the Chat SDK runtime. Defaults to `"eve"`. */ + readonly userName?: string; + /** Photon webhook signing secret. Falls back to `IMESSAGE_WEBHOOK_SECRET`. */ + readonly webhookSecret?: string; + /** Trusted webhook verifier. Takes precedence over `webhookSecret`. */ + readonly webhookVerifier?: iMessageWebhookVerifier; +} + +/** First-class eve channel backed by Photon iMessage. */ +export interface PhotonIMessageChannel extends ChatSdkChannel {} + +/** + * Creates an eve channel for Photon-powered iMessage. + * + * @example + * ```ts + * import { connectPhotonCredentials } from "@vercel/connect/eve"; + * import { photonIMessageChannel } from "eve/channels/photon"; + * + * export default photonIMessageChannel({ + * credentials: connectPhotonCredentials("photon/my-agent"), + * }); + * ``` + */ +export function photonIMessageChannel(config: PhotonIMessageChannelConfig): PhotonIMessageChannel { + const webhookSecret = config.webhookSecret ?? process.env.IMESSAGE_WEBHOOK_SECRET; + const imessage = createiMessageAdapter({ + credentials: config.credentials, + ...(config.webhookVerifier + ? { webhookVerifier: config.webhookVerifier } + : webhookSecret + ? { webhookSecret } + : { webhookVerifier: vercelOidc() }), + }); + const bridge = chatSdkChannel({ + adapters: { imessage }, + concurrency: "concurrent", + events: config.events, + routes: { imessage: config.route ?? "/eve/v1/photon" }, + state: createMemoryState(), + streaming: false, + userName: config.userName ?? "eve", + }); + const onMessage = config.onMessage ?? defaultOnMessage; + + bridge.bot.onDirectMessage(async (thread: Thread, message: Message) => { + await dispatchMessage(bridge, onMessage, thread, message); + }); + bridge.bot.onNewMessage(/[\s\S]*/, async (thread: Thread, message: Message) => { + await dispatchMessage(bridge, onMessage, thread, message); + }); + + return bridge.channel; +} + +async function defaultOnMessage(): Promise { + return { auth: null }; +} + +async function dispatchMessage( + bridge: ChatSdkChannelBridge<{ imessage: iMessageAdapter }>, + onMessage: NonNullable, + thread: Thread, + message: Message, +): Promise { + const result = await onMessage({ thread }, message); + if (result === null) return; + await markReadBestEffort(bridge.bot.getAdapter("imessage"), thread, message); + const content = photonInboundContent(message); + if (content === undefined) return; + await bridge.send( + { + context: [...(result.context ?? [])], + message: content, + }, + { auth: result.auth, thread, turnPolicy: "experimental-steer" }, + ); +} + +async function markReadBestEffort( + adapter: iMessageAdapter, + thread: Thread, + message: Message, +): Promise { + try { + await adapter.markRead(thread.id, message.id); + } catch { + // A read receipt should never prevent the user's message from reaching eve. + } +} 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/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 new file mode 100644 index 000000000..03b7b73c3 --- /dev/null +++ b/packages/eve/src/setup/integrations/photon/connect.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { ChannelSetupLog } from "#setup/cli/index.js"; +import { parseCreatedPhotonConnector, provisionPhotonConnector } from "./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("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, + 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 }), + ); + }); + + 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/integrations/photon/connect.ts b/packages/eve/src/setup/integrations/photon/connect.ts new file mode 100644 index 000000000..7ae673d1d --- /dev/null +++ b/packages/eve/src/setup/integrations/photon/connect.ts @@ -0,0 +1,151 @@ +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"; +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; +} + +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 { + try { + const parsed = PhotonConnectorRefSchema.safeParse(JSON.parse(stdout)); + return parsed.success ? { id: parsed.data.id, uid: parsed.data.uid } : undefined; + } catch { + return undefined; + } +} + +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.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:\n${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. 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, + deps, + }), + ); + options.signal?.throwIfAborted(); + 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( + 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}\`.`, + ); + } + + return connector; +} diff --git a/packages/eve/src/setup/integrations/photon/management.test.ts b/packages/eve/src/setup/integrations/photon/management.test.ts new file mode 100644 index 000000000..4217834d4 --- /dev/null +++ b/packages/eve/src/setup/integrations/photon/management.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + provisionPhotonProject, + usePhotonProject, + validatePhotonPhoneNumber, +} from "./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/integrations/photon/management.ts b/packages/eve/src/setup/integrations/photon/management.ts new file mode 100644 index 000000000..cd74b2399 --- /dev/null +++ b/packages/eve/src/setup/integrations/photon/management.ts @@ -0,0 +1,332 @@ +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"; +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; +} + +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; + 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 errorDetail(body: unknown, fallback: string): string { + const parsed = ErrorResponseSchema.safeParse(body); + return String(parsed.success ? (parsed.data.error ?? parsed.data.message ?? fallback) : fallback); +} + +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) { + throw new Error(`Photon ${action} failed: ${errorDetail(body, response.statusText)}`); + } + return 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 = parsePhotonResponse( + DeviceAuthorizationSchema, + await json(response, "device login"), + "Photon returned an invalid device authorization response.", + ); + const verificationUrl = body.verification_uri_complete ?? body.verification_uri; + if (verificationUrl === undefined) { + throw new Error("Photon returned an invalid device authorization response."); + } + return { + deviceCode: body.device_code, + userCode: body.user_code, + verificationUrl, + expiresIn: body.expires_in ?? 1800, + interval: 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: unknown = await response.json().catch(() => undefined); + if (response.ok) { + 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 = 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(statusCode ?? 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, + }), + }); + return parsePhotonResponse( + ProjectSchema, + await json(response, "project creation"), + "Photon did not return a project ID.", + ).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: "{}" }, + ); + return parsePhotonResponse( + ProjectSecretSchema, + await json(response, "project credential provisioning"), + "Photon did not return the new project secret.", + ).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 = 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( + 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/integrations/photon/setup-flow.test.ts b/packages/eve/src/setup/integrations/photon/setup-flow.test.ts new file mode 100644 index 000000000..12ad08b17 --- /dev/null +++ b/packages/eve/src/setup/integrations/photon/setup-flow.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; + +import type { Asker, Question } from "../../ask.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 { + 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), + ensureVercelProject: 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( + setupPhoton({ + agentName: "agent", + projectPath: "/project", + environment: integrationSetupEnvironment("cli-missing", { kind: "unresolved" }), + ui: createIntegrationSetupUi({ + asker: asker({ "photon-phone-number": "+15551234567" }), + prompter: fake.prompter, + }), + deps: 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("uses the agent name in the default Photon project name", async () => { + const fake = createFakePrompter({ single: () => "portable" }); + const effects = deps(); + + await setupPhoton({ + agentName: "weather-agent", + projectPath: "/project", + environment: integrationSetupEnvironment("cli-missing", { kind: "unresolved" }), + ui: createIntegrationSetupUi({ + asker: asker({ "photon-phone-number": "+15551234567" }), + prompter: fake.prompter, + }), + deps: 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" }); + + await expect( + setupPhoton({ + agentName: "agent", + projectPath: "/project", + 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 new file mode 100644 index 000000000..165b5510a --- /dev/null +++ b/packages/eve/src/setup/integrations/photon/setup-flow.ts @@ -0,0 +1,286 @@ +import { join } from "node:path"; + +import { text } from "../../ask.js"; +import { appendEnv } from "../../append-env.js"; +import { provisionPhotonConnector } from "./connect.js"; +import { + provisionPhotonProject, + usePhotonProject, + validatePhotonPhoneNumber, + type PhotonManagedProject, +} 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 { 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: IntegrationSetupEnvironment; + ui: IntegrationSetupUi; + 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 }; + photonProjectName?: string; +} + +export interface PhotonSetupDeps { + appendEnv: typeof appendEnv; + deriveConnectorSlug: typeof deriveSlackConnectorSlug; + ensureVercelProject: typeof ensureVercelProject; + openUrl: typeof openUrl; + provisionConnector: typeof provisionPhotonConnector; + provisionProject: typeof provisionPhotonProject; + useProject: typeof usePhotonProject; + writeTextFile: typeof writeTextFile; +} + +const defaultDeps: PhotonSetupDeps = { + appendEnv, + deriveConnectorSlug: deriveSlackConnectorSlug, + ensureVercelProject, + openUrl, + provisionConnector: provisionPhotonConnector, + provisionProject: provisionPhotonProject, + useProject: usePhotonProject, + writeTextFile, +}; + +const PORTABLE_TEMPLATE = `import { photonIMessageChannel } 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 photonIMessageChannel({ + credentials: photonCredentials, + webhookSecret: process.env.IMESSAGE_WEBHOOK_SECRET, +}); +`; + +function connectTemplate(connectorUid: string): string { + return `import { connectPhotonCredentials } from "@vercel/connect/eve"; +import { photonIMessageChannel } from "eve/channels/photon"; + +export default photonIMessageChannel({ + credentials: connectPhotonCredentials(${JSON.stringify(connectorUid)}), +}); +`; +} + +async function choosePhotonProject( + options: PhotonSetupOptions, +): Promise> { + const defaultName = `eve · ${options.agentName || "agent"}`; + const projectOptions = [ + { + 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 = options.ui.prompter.selectEditable + ? await options.ui.prompter.selectEditable<"create" | "existing">({ + message: "Photon project", + options: projectOptions, + 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 options.ui.prompter.select<"create" | "existing">({ + message: "Photon project", + options: projectOptions, + initialValue: "create", + })); + if (source === "existing") { + const projectId = await options.ui.asker.ask( + text({ key: "photon-project-id", message: "Photon project ID", required: true }), + ); + const projectSecret = await options.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( + options: PhotonSetupOptions, +): Promise { + try { + 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: + options.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: options.environment.vercel.kind === "available" ? "vercel" : "portable", + }); + 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(options); + return { credentials: destination === "vercel" ? "vercel-connect" : "environment", ...photon }; + } catch (error) { + if (error instanceof WizardCancelledError) return "cancelled"; + throw error; + } +} + +async function resolvePhotonProject( + options: PhotonSetupOptions, + plan: PhotonSetupPlan, + phoneNumber: string, + deps: PhotonSetupDeps, +): Promise { + if (plan.photonProject !== "create") { + return deps.useProject({ ...plan.photonProject, phoneNumber }); + } + 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 · ${options.agentName || "agent"}`, + phoneNumber, + signal: options.signal, + onAuthorization(authorization) { + options.ui.prompter.log.message(`Authorize Photon: ${authorization.verificationUrl}`); + options.ui.prompter.log.message(`Photon code: ${authorization.userCode}`); + deps.openUrl(authorization.verificationUrl); + }, + }); + } finally { + spinner?.stop(); + } +} + +async function scaffoldPhoton( + options: PhotonSetupOptions, + plan: PhotonSetupPlan, + deps: PhotonSetupDeps, +): Promise<{ assignedPhoneNumber?: string; dashboardUrl: string }> { + const phoneNumber = await options.ui.asker.ask( + text({ + key: "photon-phone-number", + message: "Your iMessage phone number", + placeholder: "+15551234567", + required: true, + validate: validatePhotonPhoneNumber, + }), + ); + 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, options.agentName); + const project = await deps.ensureVercelProject({ + appRoot: projectRoot, + prompter: options.ui.prompter, + signal: options.signal, + }); + const connector = await deps.provisionConnector({ + credentials: managedProject, + log: options.ui.prompter.log, + project, + projectRoot, + slug, + signal: options.signal, + }); + await deps.writeTextFile(channelPath, connectTemplate(connector.uid), { + 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: 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.", + ]); + } + options.ui.prompter.log.success("Scaffolded channel: photon"); + if (managedProject.assignedPhoneNumber !== undefined) { + options.ui.prompter.note(managedProject.assignedPhoneNumber, "Text your agent", { + tone: "success", + }); + } + const dashboardUrl = `https://app.photon.codes/dashboard/${managedProject.projectId}`; + options.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; + } +} + +/** 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; + } +} 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..0bc879c1c --- /dev/null +++ b/packages/eve/src/setup/integrations/photon/setup.ts @@ -0,0 +1,38 @@ +import { basename } from "node:path"; + +import { setupPhoton } from "./setup-flow.js"; + +import type { SetupIntegration } from "../types.js"; + +/** Photon project provisioning and iMessage channel scaffolding. */ +export const PHOTON_SETUP: SetupIntegration = { + kind: "photon", + label: "Photon", + hint: "Messages through Photon", + async setup(context) { + const result = await setupPhoton({ + agentName: basename(context.appRoot), + projectPath: context.appRoot, + environment: context.environment, + ui: context.ui, + signal: context.signal, + force: context.force, + }); + if (result.kind === "cancelled") return result; + return { + kind: "done", + 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..2120953a6 100644 --- a/packages/eve/src/setup/integrations/registry.ts +++ b/packages/eve/src/setup/integrations/registry.ts @@ -1,9 +1,14 @@ -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 { diff --git a/packages/eve/src/setup/primitives/run-vercel.test.ts b/packages/eve/src/setup/primitives/run-vercel.test.ts index c4fffce85..b95396d9d 100644 --- a/packages/eve/src/setup/primitives/run-vercel.test.ts +++ b/packages/eve/src/setup/primitives/run-vercel.test.ts @@ -22,6 +22,7 @@ const mockedSpawn = vi.mocked(spawn); * streams, close/error events, and a spyable `kill`. */ type ChildProcessDouble = ChildProcess & { + stdin: PassThrough; stdout: PassThrough; stderr: PassThrough; kill: ReturnType boolean>>; @@ -29,6 +30,7 @@ type ChildProcessDouble = ChildProcess & { function createChildProcess(): ChildProcessDouble { const child = new EventEmitter() as ChildProcessDouble; + child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.kill = vi.fn((_signal?: NodeJS.Signals | number) => true); @@ -144,6 +146,52 @@ describe("runVercel", () => { }); }); + test("writes supplied stdin without exposing it in argv", async () => { + const child = createChildProcess(); + mockSpawnReturn(child); + const inputChunks: Buffer[] = []; + child.stdin.on("data", (chunk: Buffer) => inputChunks.push(chunk)); + + const result = runVercelCaptureStdout( + ["connect", "create", "photon", "--data", "@-", "-F", "json"], + { + cwd: "/tmp/eve-agent", + nonInteractive: true, + stdin: '{"values":[{"value":"secret"}]}', + }, + ); + child.emit("close", 0); + + await expect(result).resolves.toEqual({ ok: true, stdout: "" }); + expectSpawnedVercel( + ["connect", "create", "photon", "--data", "@-", "-F", "json", "--non-interactive"], + { stdio: ["pipe", "pipe", "inherit"] }, + ); + expect(Buffer.concat(inputChunks).toString("utf8")).toBe('{"values":[{"value":"secret"}]}'); + }); + + test("settles as a failure when the child closes stdin before secrets are written", async () => { + const child = createChildProcess(); + mockSpawnReturn(child); + const onOutput = vi.fn(); + + const result = runVercelCaptureStdout(["connect", "create", "photon", "--data", "@-"], { + cwd: "/tmp/eve-agent", + nonInteractive: true, + stdin: '{"projectSecret":"secret"}', + onOutput, + }); + const error: NodeJS.ErrnoException = new Error("write EPIPE"); + error.code = "EPIPE"; + child.stdin.emit("error", error); + + await expect(result).resolves.toEqual({ ok: false, stdout: "" }); + expect(onOutput).toHaveBeenCalledWith({ + stream: "stderr", + text: "vercel connect create photon --data @- stdin failed: write EPIPE", + }); + }); + test("passes cancellation to the child and settles without a stale failure line", async () => { const child = createChildProcess(); mockSpawnReturn(child); diff --git a/packages/eve/src/setup/primitives/run-vercel.ts b/packages/eve/src/setup/primitives/run-vercel.ts index ef7c4b932..724db7fad 100644 --- a/packages/eve/src/setup/primitives/run-vercel.ts +++ b/packages/eve/src/setup/primitives/run-vercel.ts @@ -50,6 +50,8 @@ export interface RunVercelOptions { extraEnv?: Readonly>; /** Pass `--non-interactive` and close stdin so automation cannot stop on a prompt. */ nonInteractive?: boolean; + /** UTF-8 data written to stdin, used to keep connector secrets out of argv. */ + stdin?: string; /** Streams command output to a parent-owned renderer instead of writing outside it. */ onOutput?: ProcessOutputHandler; /** Aborts the Vercel CLI subprocess when its parent setup flow is interrupted. */ @@ -158,11 +160,26 @@ export function resolveVercelInvocation( : { command: localBinary, commandArgs: args }; } +function stdinMode(options: RunVercelOptions): "inherit" | "ignore" | "pipe" { + if (options.stdin !== undefined) return "pipe"; + return options.nonInteractive ? "ignore" : "inherit"; +} + +function writeStdin( + child: ChildProcess, + input: string | undefined, + onError: (error: NodeJS.ErrnoException) => void, +): void { + if (input === undefined || child.stdin === null) return; + child.stdin.once("error", onError); + child.stdin.end(input, "utf8"); +} + function stdioForRun( options: RunVercelOptions, -): ["inherit" | "ignore", "pipe", "pipe"] | "inherit" { - if (options.onOutput) { - return [options.nonInteractive ? "ignore" : "inherit", "pipe", "pipe"]; +): ["inherit" | "ignore" | "pipe", "pipe", "pipe"] | "inherit" { + if (options.onOutput || options.stdin !== undefined) { + return [stdinMode(options), "pipe", "pipe"]; } return options.nonInteractive ? ["ignore", "pipe", "pipe"] : "inherit"; } @@ -209,6 +226,10 @@ export async function runVercel(args: string[], options: RunVercelOptions): Prom reportFailure(timeoutMessage(args, options.timeoutMs ?? 0)); settle(false); }); + writeStdin(child, options.stdin, (error) => { + reportFailure(`vercel ${args.join(" ")} stdin failed: ${error.message}`); + settle(false); + }); child.on("error", (error: NodeJS.ErrnoException) => { if (isAbortError(error, options.signal)) { @@ -270,11 +291,7 @@ export async function runVercelCaptureStdout( const outputBuffer = options.onOutput && createProcessOutputBuffer(options.onOutput); const child = spawn(invocation.command, invocation.commandArgs, { cwd, - stdio: [ - options.nonInteractive ? "ignore" : "inherit", - "pipe", - options.onOutput ? "pipe" : "inherit", - ], + stdio: [stdinMode(options), "pipe", options.onOutput ? "pipe" : "inherit"], env: buildSpawnEnv(options.extraEnv ?? {}), shell: invocation.shell, signal: options.signal, @@ -309,6 +326,10 @@ export async function runVercelCaptureStdout( reportFailure(timeoutMessage(args, options.timeoutMs ?? 0)); settle(false); }); + writeStdin(child, options.stdin, (error) => { + reportFailure(`vercel ${args.join(" ")} stdin failed: ${error.message}`); + settle(false); + }); child.on("error", (error: NodeJS.ErrnoException) => { if (isAbortError(error, options.signal)) { @@ -392,7 +413,7 @@ export async function captureVercel( const outputBuffer = options.onOutput && createProcessOutputBuffer(options.onOutput); const child = spawn(invocation.command, invocation.commandArgs, { cwd, - stdio: ["ignore", "pipe", "pipe"], + stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], env: buildSpawnEnv(options.extraEnv ?? {}), shell: invocation.shell, signal: options.signal, @@ -429,6 +450,14 @@ export async function captureVercel( message: timeoutMessage(args, options.timeoutMs ?? 0), }); }); + writeStdin(child, options.stdin, (error) => { + fail({ + errno: error.code, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + message: `vercel ${args.join(" ")} stdin failed: ${error.message}`, + }); + }); child.on("error", (error: NodeJS.ErrnoException) => { if (isAbortError(error, options.signal)) { diff --git a/packages/eve/src/setup/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 = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 736b8879b..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 @@ -1258,6 +1258,9 @@ importers: '@opentelemetry/sdk-trace-base': specifier: 'catalog:' version: 2.6.1(@opentelemetry/api@1.9.1) + '@photon-ai/chat-adapter-imessage': + specifier: 3.2.0 + version: 3.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))(supports-color@10.2.2)(typescript@7.0.2)(zod@4.4.3) '@standard-schema/spec': specifier: 1.1.0 version: 1.1.0 @@ -1374,7 +1377,7 @@ importers: version: 7.8.4 shadcn: specifier: 4.16.0 - version: 4.16.0(supports-color@10.2.2)(typescript@6.0.3) + version: 4.16.0(supports-color@10.2.2)(typescript@7.0.2) svelte: specifier: ^5.0.0 version: 5.56.1(@typescript-eslint/types@8.59.4) @@ -7077,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' @@ -7206,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} @@ -16424,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)': @@ -17736,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: @@ -18424,6 +18434,20 @@ snapshots: - typescript - zod + '@photon-ai/chat-adapter-imessage@3.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))(supports-color@10.2.2)(typescript@7.0.2)(zod@4.4.3)': + dependencies: + '@chat-adapter/shared': 4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) + '@spectrum-ts/core': 10.0.0(supports-color@10.2.2)(typescript@7.0.2) + '@spectrum-ts/imessage': 10.0.0(@spectrum-ts/core@10.0.0(supports-color@10.2.2)(typescript@7.0.2))(supports-color@10.2.2)(typescript@7.0.2) + chat: 4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) + mime-types: 3.0.2 + transitivePeerDependencies: + - ai + - ffmpeg-static + - supports-color + - typescript + - zod + '@photon-ai/otel@3.3.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@opentelemetry/api': 1.9.1 @@ -18445,6 +18469,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@photon-ai/otel@3.3.0(supports-color@10.2.2)(typescript@7.0.2)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.218.0 + '@opentelemetry/context-async-hooks': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.218.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) + '@opentelemetry/semantic-conventions': 1.43.0 + typescript: 7.0.2 + optionalDependencies: + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) + '@opentelemetry/instrumentation-undici': 0.29.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@photon-ai/proto@0.2.4': dependencies: '@bufbuild/protobuf': 2.13.0 @@ -19832,6 +19877,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@spectrum-ts/core@10.0.0(supports-color@10.2.2)(typescript@7.0.2)': + dependencies: + '@photon-ai/otel': 3.3.0(supports-color@10.2.2)(typescript@7.0.2) + '@photon-ai/proto': 0.2.4 + '@repeaterjs/repeater': 3.1.0 + marked: 18.0.7 + mime-types: 3.0.2 + open-graph-scraper: 6.12.0 + typescript: 7.0.2 + vcf: 2.1.2 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + '@spectrum-ts/imessage@10.0.0(@spectrum-ts/core@10.0.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@photon-ai/advanced-imessage': 1.0.0 @@ -19844,6 +19903,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@spectrum-ts/imessage@10.0.0(@spectrum-ts/core@10.0.0(supports-color@10.2.2)(typescript@7.0.2))(supports-color@10.2.2)(typescript@7.0.2)': + dependencies: + '@photon-ai/advanced-imessage': 1.0.0 + '@photon-ai/otel': 3.3.0(supports-color@10.2.2)(typescript@7.0.2) + '@spectrum-ts/core': 10.0.0(supports-color@10.2.2)(typescript@7.0.2) + lru-cache: 11.5.2 + marked: 18.0.7 + typescript: 7.0.2 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + '@speed-highlight/core@1.2.17': {} '@stablelib/base64@1.0.1': {} @@ -20727,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 @@ -21019,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 @@ -22583,6 +22665,15 @@ snapshots: optionalDependencies: typescript: 6.0.3 + cosmiconfig@9.0.2(typescript@7.0.2): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 7.0.2 + crc-32@1.2.2: {} crc32-stream@6.0.0: @@ -29273,6 +29364,46 @@ snapshots: - supports-color - typescript + shadcn@4.16.0(supports-color@10.2.2)(typescript@7.0.2): + dependencies: + '@babel/core': 7.29.0(supports-color@10.2.2) + '@babel/parser': 7.29.7 + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.0(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.0(supports-color@10.2.2))(supports-color@10.2.2) + '@dotenvx/dotenvx': 1.75.1 + '@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.6 + commander: 14.0.3 + cosmiconfig: 9.0.2(typescript@7.0.2) + dedent: 1.7.2 + deepmerge: 4.3.1 + diff: 8.0.4 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.3.6 + fuzzysort: 3.1.0 + kleur: 4.1.5 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + prompts: 2.4.2 + recast: 0.23.12 + stringify-object: 5.0.0 + tailwind-merge: 3.6.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + undici: 7.28.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - babel-plugin-macros + - supports-color + - typescript + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 42ad44eb8..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" @@ -79,6 +79,7 @@ minimumReleaseAgeExclude: - chat - chat-adapter-sendblue - "@ai-sdk/*" + - "@photon-ai/chat-adapter-imessage" - "@rolldown/*" - "@typescript/typescript-*" - "@turbo/*"