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/calm-photons-queue.md b/.changeset/calm-photons-queue.md new file mode 100644 index 000000000..5e2fb9ef2 --- /dev/null +++ b/.changeset/calm-photons-queue.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Queue overlapping Photon messages instead of dropping them, and ignore inbound iMessage events that contain no model-visible content. diff --git a/apps/docs/lib/integrations/data.ts b/apps/docs/lib/integrations/data.ts index 6bf6cc0ef..e8efda52e 100644 --- a/apps/docs/lib/integrations/data.ts +++ b/apps/docs/lib/integrations/data.ts @@ -125,10 +125,10 @@ const channelPresentations: Record = { logo: "slack", docsHref: "/docs/channels/slack", keywords: ["chat", "messaging", "bot", "webhook"], - install: `The eve CLI scaffolds the channel for you. \`eve channels add slack\` writes \`agent/channels/slack.ts\`, adds \`@vercel/connect\`, and runs the Connect setup flow: + install: `The eve CLI scaffolds the channel for you. \`eve add channel/slack\` writes \`agent/channels/slack.ts\`, adds \`@vercel/connect\`, and runs the Connect setup flow: \`\`\`bash -eve channels add slack +eve add channel/slack \`\`\` To wire it up by hand instead, install the framework and the Connect SDK. Slack channels use [Vercel Connect](https://vercel.com/docs/connect) for both the outbound bot token and inbound webhook verification: @@ -314,7 +314,7 @@ export default linearChannel({ install: `The eve CLI scaffolds the full Next.js web chat app alongside \`agent/channels/eve.ts\`: \`\`\`bash -eve channels add web +eve add channel/web \`\`\` To wire it up by hand instead, install the framework: @@ -842,64 +842,29 @@ export default channel; See the [Kapso adapter documentation](https://chat-sdk.dev/adapters/vendor-official/kapso) for supported events, capabilities, and credentials.`, configure: `Connect a WhatsApp number in Kapso, set \`KAPSO_API_KEY\`, \`KAPSO_PHONE_NUMBER_ID\`, and \`KAPSO_WEBHOOK_SECRET\`, then point the Kapso webhook at \`/eve/v1/kapso\`. Use this provider-managed option when you do not want to integrate directly with the WhatsApp Cloud API. See the [Chat SDK channel docs](/docs/channels/chat-sdk) for eve session dispatch, state, streaming, and human-in-the-loop behavior.`, }, - "chat-sdk-photon": { + photon: { logo: "photon", - docsHref: "/docs/channels/chat-sdk", - badge: "Provider official", - keywords: [ - "chat sdk", - "imessage", - "apple messages", - "sms", - "mms", - "rcs", - "photon", - "sendblue", - "linq", - "agentphone", - "dial", - ], - install: `Add this Chat SDK channel from eve's registry. This writes \`agent/channels/imessage.ts\` and installs Chat SDK and its adapter dependencies: + docsHref: "/docs/channels/photon", + badge: "First-party", + keywords: ["imessage", "apple messages", "photon", "sms", "phone"], + install: `Add Photon from eve's registry, then follow the guided project, phone, and deployment setup: \`\`\`bash -eve add channel/chat-sdk-photon +eve add channel/photon \`\`\``, - quickStart: `Create \`agent/channels/imessage.ts\`: + quickStart: `Create \`agent/channels/photon.ts\`: \`\`\`ts -// agent/channels/imessage.ts -import { createiMessageAdapter } from "@photon-ai/chat-adapter-imessage"; -import { createMemoryState } from "@chat-adapter/state-memory"; -import type { Message, Thread } from "chat"; -import { chatSdkChannel } from "eve/channels/chat-sdk"; +import { connectPhotonCredentials } from "@vercel/connect/eve"; +import { photonChannel } from "eve/channels/photon"; -export const { bot, channel, send } = chatSdkChannel({ - userName: "My Agent", - adapters: { - imessage: createiMessageAdapter({ - local: false, - projectId: process.env.IMESSAGE_PROJECT_ID, - projectSecret: process.env.IMESSAGE_PROJECT_SECRET, - }), - }, - state: createMemoryState(), +export default photonChannel({ + credentials: connectPhotonCredentials(process.env.PHOTON_CONNECTOR_ID!), }); - -bot.onNewMention(async (thread: Thread, message: Message) => { - await thread.subscribe(); - await send(message.text, { thread }); -}); - -bot.onSubscribedMessage(async (thread: Thread, message: Message) => { - await send(message.text, { thread }); -}); - -export default channel; -\`\`\` - -See the [Photon adapter documentation](https://chat-sdk.dev/adapters/vendor-official/photon) for all supported events and credentials.`, - configure: `Set \`IMESSAGE_PROJECT_ID\` and \`IMESSAGE_PROJECT_SECRET\`, then point Photon’s signed webhook at \`/eve/v1/imessage\`. Photon supports cloud, self-hosted, and local macOS deployments. See the [Chat SDK channel docs](/docs/channels/chat-sdk) for eve session dispatch, state, streaming, and human-in-the-loop behavior.`, +\`\`\``, + configure: `The guided setup can create a dedicated Photon project or use existing credentials, register your phone, and choose Vercel Connect or portable environment credentials. Connect-backed setup creates a native Photon connector and routes verified triggers to \`/eve/v1/photon\`; portable setup registers a signed Photon webhook directly.`, }, + "chat-sdk-dial": { logo: "dial", docsHref: "/docs/channels/chat-sdk", diff --git a/apps/docs/lib/integrations/discovery.test.ts b/apps/docs/lib/integrations/discovery.test.ts index 67352b55d..b771c232e 100644 --- a/apps/docs/lib/integrations/discovery.test.ts +++ b/apps/docs/lib/integrations/discovery.test.ts @@ -29,7 +29,7 @@ describe("integration discovery", () => { const markdown = integrationMarkdown(slack!); expect(markdown).toContain("## Install"); expect(markdown).toContain("## Quick start"); - expect(markdown).toContain("eve channels add slack"); + expect(markdown).toContain("eve add channel/slack"); }); it("renders the Browserbase extension setup", () => { diff --git a/apps/docs/public/r/channel/photon.json b/apps/docs/public/r/channel/photon.json new file mode 100644 index 000000000..1f8bd9410 --- /dev/null +++ b/apps/docs/public/r/channel/photon.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "channel/photon", + "type": "registry:item", + "title": "Photon", + "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", + "dependencies": ["@vercel/connect@0.5.0"], + "meta": { + "eve": { + "setup": { + "command": "eve", + "package": "eve", + "bin": "eve", + "args": ["integration", "setup", "photon"] + }, + "requires": ">=0.27.11" + } + } +} diff --git a/apps/docs/registry.json b/apps/docs/registry.json index dda92c36c..49907c134 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -1209,22 +1209,22 @@ ] }, { - "name": "channel/chat-sdk-photon", + "name": "channel/photon", "type": "registry:item", "title": "Photon", - "description": "Cloud, self-hosted, and local iMessage messaging through Photon.", - "dependencies": ["chat", "@photon-ai/chat-adapter-imessage", "@chat-adapter/state-memory"], - "envVars": { - "IMESSAGE_PROJECT_ID": "", - "IMESSAGE_PROJECT_SECRET": "" - }, - "files": [ - { - "path": "registry/channels/chat-sdk-photon.ts", - "type": "registry:file", - "target": "agent/channels/imessage.ts" + "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", + "dependencies": ["@vercel/connect@0.5.0"], + "meta": { + "eve": { + "setup": { + "command": "eve", + "package": "eve", + "bin": "eve", + "args": ["integration", "setup", "photon"] + }, + "requires": ">=0.27.11" } - ] + } }, { "name": "channel/chat-sdk-dial", diff --git a/apps/docs/registry/channels/chat-sdk-photon.ts b/apps/docs/registry/channels/chat-sdk-photon.ts deleted file mode 100644 index 83e239a9e..000000000 --- a/apps/docs/registry/channels/chat-sdk-photon.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createiMessageAdapter } from "@photon-ai/chat-adapter-imessage"; -import { createMemoryState } from "@chat-adapter/state-memory"; -import type { Message, Thread } from "chat"; -import { chatSdkChannel } from "eve/channels/chat-sdk"; - -export const { bot, channel, send } = chatSdkChannel({ - userName: "My Agent", - adapters: { - imessage: createiMessageAdapter({ - local: false, - projectId: process.env.IMESSAGE_PROJECT_ID, - projectSecret: process.env.IMESSAGE_PROJECT_SECRET, - }), - }, - state: createMemoryState(), -}); - -bot.onNewMention(async (thread: Thread, message: Message) => { - await thread.subscribe(); - await send(message.text, { thread }); -}); - -bot.onSubscribedMessage(async (thread: Thread, message: Message) => { - await send(message.text, { thread }); -}); - -export default channel; diff --git a/apps/docs/scripts/validate-channel-registry.ts b/apps/docs/scripts/validate-channel-registry.ts index 11c65402c..cb96421f0 100644 --- a/apps/docs/scripts/validate-channel-registry.ts +++ b/apps/docs/scripts/validate-channel-registry.ts @@ -44,7 +44,6 @@ const adapterDependenciesByCatalogSlug: Readonly> = { "chat-sdk-liveblocks": "@liveblocks/chat-sdk-adapter", "chat-sdk-linq": "@linqapp/chat-sdk-adapter", "chat-sdk-kapso": "@kapso/chat-adapter", - "chat-sdk-photon": "@photon-ai/chat-adapter-imessage", "chat-sdk-dial": "@getdial/chat-sdk-adapter", "chat-sdk-agentphone": "@agentphone/chat-sdk-adapter", "chat-sdk-lark": "@larksuite/vercel-chat-adapter", @@ -65,7 +64,6 @@ const targetSlugsByCatalogSlug: Readonly> = { "chat-sdk-liveblocks": "liveblocks", "chat-sdk-linq": "linq", "chat-sdk-kapso": "kapso", - "chat-sdk-photon": "imessage", "chat-sdk-dial": "dial", "chat-sdk-agentphone": "agentphone", "chat-sdk-lark": "lark", @@ -106,7 +104,7 @@ for (const [index, item] of items.entries()) { if (entry === undefined) throw new Error(`Unexpected channel registry item "${item.name}".`); const registrySlug = expectedSlugs[index]; - if (entry.slug === "slack" || entry.slug === "eve") { + if (entry.slug === "slack" || entry.slug === "eve" || entry.slug === "photon") { const expectedArgs = ["integration", "setup", registrySlug]; if ( setup?.command !== "eve" || diff --git a/docs/channels/overview.mdx b/docs/channels/overview.mdx index 892ed8ea7..cfab39c05 100644 --- a/docs/channels/overview.mdx +++ b/docs/channels/overview.mdx @@ -28,7 +28,7 @@ agent/ intake.ts ``` -Install a built-in channel with `eve add channel/slack` or `eve add channel/web`. You can also author the file by hand. +Install a channel from the registry with `eve add channel/photon`, `eve add channel/slack`, or `eve add channel/web`. You can also author the file by hand. ## The eve HTTP channel (default) diff --git a/docs/channels/photon.mdx b/docs/channels/photon.mdx index 9dec08b18..6b8f97640 100644 --- a/docs/channels/photon.mdx +++ b/docs/channels/photon.mdx @@ -4,8 +4,12 @@ description: Connect an eve agent to iMessage through Photon. --- Use `photonChannel` to receive and reply to iMessages through a Photon project. -With Vercel Connect, the channel resolves credentials lazily when the adapter -first initializes: + +Run `eve add channel/photon` to create or use a Photon project and register +your phone number. It then asks whether to configure Vercel Connect or portable +credentials. With Vercel Connect, eve creates the connector and configures +Photon’s webhook through Connect. The channel resolves credentials lazily when +the adapter first initializes: ```ts title="agent/channels/photon.ts" import { connectPhotonCredentials } from "@vercel/connect/eve"; @@ -37,6 +41,37 @@ export default photonChannel({ ``` Set `route` to override the webhook path or `webhookVerifier` to use a different -trusted-forwarder verifier. For direct Photon webhooks, pass `webhookSecret` or -set `IMESSAGE_WEBHOOK_SECRET`; the signing secret takes precedence over the -default OIDC verifier. +trusted-forwarder verifier. + +## Other hosts + +For a host without Vercel Connect, choose **Use portable credentials** during +`eve add channel/photon`. eve scaffolds the channel and writes +`IMESSAGE_PROJECT_ID` and `IMESSAGE_PROJECT_SECRET` to `.env.local`. + +After deploying the agent: + +1. Create a Photon webhook for your public `https://…/eve/v1/photon` URL. +2. Copy its signing secret into `IMESSAGE_WEBHOOK_SECRET`. +3. Set `IMESSAGE_PROJECT_ID`, `IMESSAGE_PROJECT_SECRET`, and + `IMESSAGE_WEBHOOK_SECRET` in the host’s encrypted environment variables. + +To configure the channel by hand, use lazy environment-backed credentials: + +```ts title="agent/channels/photon.ts" +import { photonChannel } from "eve/channels/photon"; + +export default photonChannel({ + async credentials() { + const projectId = process.env.IMESSAGE_PROJECT_ID; + const projectSecret = process.env.IMESSAGE_PROJECT_SECRET; + if (!projectId || !projectSecret) throw new Error("Photon project credentials are required."); + return { projectId, projectSecret }; + }, + webhookSecret: process.env.IMESSAGE_WEBHOOK_SECRET, +}); +``` + +For direct Photon webhooks, pass `webhookSecret` or set +`IMESSAGE_WEBHOOK_SECRET`; the signing secret takes precedence over the default +OIDC verifier. diff --git a/packages/eve-catalog/src/index.ts b/packages/eve-catalog/src/index.ts index 88fa4adbe..4b4fe0ede 100644 --- a/packages/eve-catalog/src/index.ts +++ b/packages/eve-catalog/src/index.ts @@ -81,12 +81,6 @@ export function connectionProtocols(connection: ConnectionIdentity): ConnectionP * The canonical set of eve integrations. Order is display order. Each entry * carries only shared identity; the scaffolder and docs overlay their own * surface-specific data keyed by {@link IntegrationEntry.slug}. - * - * `surfaces.scaffoldable` reflects what eve's interactive setup flow can - * provision and scaffold today: Slack and Web Chat for channels, plus its - * curated connections. Registry installation is independent of this flag. - * The remaining channels are runtime modules configured by hand, so they - * appear in the gallery but not the setup picker. */ export const INTEGRATIONS: readonly IntegrationEntry[] = [ { @@ -223,10 +217,10 @@ export const INTEGRATIONS: readonly IntegrationEntry[] = [ surfaces: { scaffoldable: false, gallery: true }, }, { - slug: "chat-sdk-photon", + slug: "photon", name: "Photon", kind: "channel", - tagline: "Cloud, self-hosted, and local iMessage messaging through Photon.", + tagline: "iMessage through Photon, with guided project and phone setup.", surfaces: { scaffoldable: false, gallery: true }, }, { diff --git a/packages/eve/src/cli/commands/integration-setup.test.ts b/packages/eve/src/cli/commands/integration-setup.test.ts index 82b4a0e77..bea43b95c 100644 --- a/packages/eve/src/cli/commands/integration-setup.test.ts +++ b/packages/eve/src/cli/commands/integration-setup.test.ts @@ -1,8 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import type { AddChannelsDeps } from "#setup/integrations/channels/setup.js"; -import { deriveSlackConnectorSlug } from "#setup/scaffold/index.js"; import { runIntegrationSetupCommand } from "./integration-setup.js"; import type { RegistryCommandLogger } from "./registry.js"; @@ -19,57 +17,49 @@ function logger(): RegistryCommandLogger & { errors: string[] } { return { errors, error: (message) => errors.push(message), log: () => {} }; } -function addChannelsDeps(): AddChannelsDeps { - return { - ensureChannel: vi.fn(async (options) => ({ - kind: "web", - action: "created", - filesWritten: [`${options.projectRoot}/app/page.tsx`], - filesSkipped: [], - packageJsonUpdated: [], - })), - deriveSlackConnectorSlug, - provisionSlackbot: vi.fn(), - reconcileSlackUid: vi.fn(async () => true), - detectPackageManager: vi.fn(async () => ({ - kind: "pnpm", - source: "default", - })), - runPackageManagerInstall: vi.fn(async () => true), - runVercel: vi.fn(async () => true), - detectDeployment: vi.fn(async () => ({ - state: "unlinked", - })), - }; -} - afterEach(() => { process.exitCode = undefined; }); describe("runIntegrationSetupCommand", () => { - it("runs registry-owned setup without mutating or installing dependencies", async () => { + it("does not let --yes select portable Slack credentials", async () => { const output = logger(); - const deps = addChannelsDeps(); - const fake = createFakePrompter(); + const ensureChannel = vi.fn(); await runIntegrationSetupCommand( output, "/project", - "web", - {}, + "slack", + { yes: true }, { - createPrompter: () => fake.prompter, detectDeployment: vi.fn(async () => ({ state: "unlinked" as const })), - getVercelAuthStatus: vi.fn(async () => "cli-missing" as const), - addChannelsDeps: deps, + getVercelAuthStatus: vi.fn(async () => "logged-out" as const), + createPrompter: () => { + throw new Error("Slack credential selection should remain interactive."); + }, + addChannelsDeps: { + ensureChannel, + deriveSlackConnectorSlug: vi.fn(), + provisionSlackbot: vi.fn(), + reconcileSlackUid: vi.fn(), + detectPackageManager: vi.fn(), + runPackageManagerInstall: vi.fn(), + ensureLinkedVercelProject: vi.fn(), + }, }, ); - expect(deps.ensureChannel).toHaveBeenCalledWith( - expect.objectContaining({ kind: "web", skipDependencyMutation: true }), - ); - expect(deps.runPackageManagerInstall).not.toHaveBeenCalled(); - expect(output.errors).toEqual([]); + expect(output.errors).toEqual(["Slack credential selection should remain interactive."]); + expect(ensureChannel).not.toHaveBeenCalled(); + }); + + it("rejects unsupported setup kinds", async () => { + const output = logger(); + + await runIntegrationSetupCommand(output, "/project", "discord"); + + expect(output.errors).toEqual([ + 'Integration setup "discord" is not available in this version of eve. Upgrade eve and try again.', + ]); }); }); diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index ac523553a..d53f80d4a 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -8,6 +8,12 @@ import { channelSetupIntegration, createChannelSetupUi, } from "#setup/integrations/channels/index.js"; +import type { PhotonSetupDeps } from "#setup/photon-setup.js"; +import { + describePhotonSetupEnvironment, + photonSetupEnvironment, +} from "#setup/photon-setup-environment.js"; +import { createPhotonSetupUi, photonSetupIntegration } from "#setup/photon-setup-integrations.js"; import { detectDeployment, projectResolutionFromDeployment } from "#setup/project-resolution.js"; import { createPrompter, type Prompter } from "#setup/prompter.js"; import { isEveProject, type ChannelKind } from "#setup/scaffold/index.js"; @@ -19,6 +25,7 @@ import type { RegistryCommandLogger } from "./registry.js"; export interface IntegrationSetupOptions { yes?: boolean; + signal?: AbortSignal; } export interface IntegrationSetupDependencies { @@ -26,6 +33,7 @@ export interface IntegrationSetupDependencies { detectDeployment: typeof detectDeployment; getVercelAuthStatus: typeof getVercelAuthStatus; addChannelsDeps?: AddChannelsDeps; + photonDeps?: PhotonSetupDeps; } const defaultIntegrationSetupDependencies: IntegrationSetupDependencies = { @@ -33,7 +41,7 @@ const defaultIntegrationSetupDependencies: IntegrationSetupDependencies = { getVercelAuthStatus, }; -/** Runs a built-in integration setup after its registry payload is installed. */ +/** Runs built-in integration setup after its registry payload is installed. */ export async function runIntegrationSetupCommand( logger: RegistryCommandLogger, appRoot: string, @@ -48,21 +56,40 @@ export async function runIntegrationSetupCommand( } try { - if (kind !== "slack" && kind !== "web") { + if (kind !== "photon" && kind !== "slack" && kind !== "web") { throw new Error( `Integration setup "${kind}" is not available in this version of eve. Upgrade eve and try again.`, ); } - const channelKind: ChannelKind = kind; const prompter = dependencies.createPrompter?.() ?? createPrompter(); - const integration = channelSetupIntegration(channelKind); - prompter.intro(`Set up ${integration.label}`); - prompter.log.message("Checking Vercel setup..."); const [deployment, authStatus] = await Promise.all([ - dependencies.detectDeployment(appRoot), - dependencies.getVercelAuthStatus(appRoot), + dependencies.detectDeployment(appRoot, { signal: options.signal }), + dependencies.getVercelAuthStatus(appRoot, { signal: options.signal }), ]); const project = projectResolutionFromDeployment(deployment); + + if (kind === "photon") { + const integration = photonSetupIntegration(); + prompter.intro(`Set up ${integration.label}`); + prompter.log.message("Checking Vercel setup..."); + const environment = photonSetupEnvironment(authStatus, project); + prompter.log.info(describePhotonSetupEnvironment(environment)); + const result = await integration.setup({ + environment, + state: { agentName: "", project, projectPath: appRoot }, + ui: createPhotonSetupUi({ asker: interactiveAsker(prompter), prompter }), + photonDeps: dependencies.photonDeps, + signal: options.signal, + }); + if (result.kind === "cancelled") return; + prompter.outro("Integration set up."); + return; + } + + const channelKind: ChannelKind = kind; + const integration = channelSetupIntegration(channelKind); + prompter.intro(`Set up ${integration.label}`); + prompter.log.message("Checking Vercel setup..."); const environment = channelSetupEnvironment(authStatus, project); prompter.log.info(describeChannelSetupEnvironment(environment)); const result = await integration.setup({ @@ -75,9 +102,9 @@ export async function runIntegrationSetupCommand( }, ui: createChannelSetupUi({ asker: interactiveAsker(prompter), prompter }), presetCreateSlackbot: options.yes ? true : undefined, - presetPortableCredentials: options.yes ? true : undefined, skipDependencyMutation: true, deps: dependencies.addChannelsDeps, + signal: options.signal, }); if (result.kind === "cancelled" && process.env.EVE_SETUP === "1") process.exitCode = 130; prompter.outro(result.kind === "done" ? "Integration set up." : "No changes made."); diff --git a/packages/eve/src/cli/dev/tui/agent-header.test.ts b/packages/eve/src/cli/dev/tui/agent-header.test.ts index e41d55653..aedc41819 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.test.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentInfoResult, AgentInfoToolEntry } from "#client/index.js"; +import { stripAnsi } from "#cli/ui/terminal-text.js"; import { AGENT_HEADER_TIPS, buildAgentHeader, pickAgentHeaderTip } from "./agent-header.js"; import { createTheme } from "./theme.js"; diff --git a/packages/eve/src/public/channels/photon/inboundContent.test.ts b/packages/eve/src/public/channels/photon/inboundContent.test.ts new file mode 100644 index 000000000..c031c075e --- /dev/null +++ b/packages/eve/src/public/channels/photon/inboundContent.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { Message } from "#compiled/chat/index.js"; +import { photonInboundContent } from "#public/channels/photon/inboundContent.js"; + +function message(text: string, attachments: Message["attachments"] = []): Message { + return new Message({ + attachments, + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text, + threadId: "thread-id", + }); +} + +describe("photonInboundContent", () => { + it("returns plain text", () => { + expect(photonInboundContent(message("hello"))).toBe("hello"); + }); + + it("drops blank messages", () => { + expect(photonInboundContent(message(" \n"))).toBeUndefined(); + }); + + it("drops attachment-only messages when Photon provides no attachment URL", () => { + expect( + photonInboundContent( + message("", [ + { + mimeType: "image/jpeg", + name: "photo.jpg", + size: 10, + type: "image", + }, + ]), + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/eve/src/public/channels/photon/inboundContent.ts b/packages/eve/src/public/channels/photon/inboundContent.ts new file mode 100644 index 000000000..6f5dec1c6 --- /dev/null +++ b/packages/eve/src/public/channels/photon/inboundContent.ts @@ -0,0 +1,13 @@ +import type { UserContent } from "ai"; + +import type { Message } from "#compiled/chat/index.js"; +import { messageToUserContent } from "#public/channels/chat-sdk/index.js"; + +/** Returns model-visible Photon content, or `undefined` for non-message events. */ +export function photonInboundContent(message: Message): string | UserContent | undefined { + const content = messageToUserContent(message); + if (typeof content === "string") { + return content.trim().length > 0 ? content : undefined; + } + return content.length > 0 ? content : undefined; +} diff --git a/packages/eve/src/public/channels/photon/photonChannel.ts b/packages/eve/src/public/channels/photon/photonChannel.ts index 0e9db2e90..4374c01b5 100644 --- a/packages/eve/src/public/channels/photon/photonChannel.ts +++ b/packages/eve/src/public/channels/photon/photonChannel.ts @@ -2,7 +2,6 @@ import type { SessionAuthContext } from "#channel/types.js"; import { vercelOidc } from "#public/channels/auth.js"; import { chatSdkChannel, - messageToUserContent, type ChatSdkChannel, type ChatSdkChannelBridge, type ChatSdkChannelEvents, @@ -15,6 +14,7 @@ import { type iMessageCredentialProvider, type iMessageWebhookVerifier, } from "#compiled/@photon-ai/chat-adapter-imessage/index.js"; +import { photonInboundContent } from "#public/channels/photon/inboundContent.js"; /** Photon project credentials used by {@link photonChannel}. */ export type PhotonChannelCredentials = iMessageCredentialProvider; @@ -83,6 +83,7 @@ export function photonChannel(config: PhotonChannelConfig): PhotonChannel { }); const bridge = chatSdkChannel({ adapters: { imessage }, + concurrency: "queue", events: config.events, routes: { imessage: config.route ?? "/eve/v1/photon" }, state: createMemoryState(), @@ -115,11 +116,13 @@ async function dispatchMessage( const result = await onMessage({ thread }, message); if (result === null) return; await markReadBestEffort(bridge.bot.getAdapter("imessage"), thread, message); + const content = photonInboundContent(message); + if (content === undefined) return; if (subscribe) await thread.subscribe(); await bridge.send( { context: [...(result.context ?? [])], - message: messageToUserContent(message), + message: content, }, { auth: result.auth, thread }, ); diff --git a/packages/eve/src/setup/integrations/channels/index.test.ts b/packages/eve/src/setup/integrations/channels/index.test.ts index 5e84be3cf..e7357bf75 100644 --- a/packages/eve/src/setup/integrations/channels/index.test.ts +++ b/packages/eve/src/setup/integrations/channels/index.test.ts @@ -61,8 +61,7 @@ describe("channel setup integrations", () => { source: "default", })), runPackageManagerInstall: vi.fn(), - runVercel: vi.fn(), - detectDeployment: vi.fn(), + ensureLinkedVercelProject: vi.fn(), }, }); diff --git a/packages/eve/src/setup/photon-connect.test.ts b/packages/eve/src/setup/photon-connect.test.ts new file mode 100644 index 000000000..17d7bcbc8 --- /dev/null +++ b/packages/eve/src/setup/photon-connect.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { ChannelSetupLog } from "./cli/index.js"; +import { parseCreatedPhotonConnector, provisionPhotonConnector } from "./photon-connect.js"; + +function log(): ChannelSetupLog { + return { + message: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + commandOutput: vi.fn(), + }; +} + +describe("Photon Connect provisioning", () => { + test("parses an app-scoped connector", () => { + expect( + parseCreatedPhotonConnector( + JSON.stringify({ + id: "scl_photon", + uid: "photon/imessage0", + supportedSubjectTypes: ["app"], + }), + ), + ).toEqual({ id: "scl_photon", uid: "photon/imessage0" }); + }); + + test("creates a native connector, detaches, then attaches the routed trigger", async () => { + const runVercelCaptureStdout = vi.fn().mockResolvedValueOnce({ + ok: true as const, + stdout: JSON.stringify({ + id: "scl_photon", + uid: "photon/imessage0", + supportedSubjectTypes: ["app"], + }), + }); + const runVercel = vi.fn(async () => true); + + await expect( + provisionPhotonConnector({ + credentials: { projectId: "project-id", projectSecret: "project-secret" }, + log: log(), + project: { orgId: "team_123", projectId: "prj_123" }, + projectRoot: "/tmp/imessage0", + slug: "imessage0", + deps: { runVercel, runVercelCaptureStdout }, + }), + ).resolves.toEqual({ id: "scl_photon", uid: "photon/imessage0" }); + + expect(runVercelCaptureStdout).toHaveBeenCalledWith( + [ + "connect", + "create", + "photon", + "--connector-type", + "photon", + "--data", + "@-", + "--name", + "imessage0", + "--triggers", + "-F", + "json", + "--scope", + "team_123", + ], + expect.objectContaining({ + cwd: "/tmp/imessage0", + nonInteractive: true, + stdin: JSON.stringify({ projectId: "project-id", projectSecret: "project-secret" }), + }), + ); + expect(runVercel).toHaveBeenNthCalledWith( + 1, + [ + "connect", + "detach", + "photon/imessage0", + "--project", + "prj_123", + "--yes", + "--scope", + "team_123", + ], + expect.objectContaining({ cwd: "/tmp/imessage0", nonInteractive: true }), + ); + expect(runVercel).toHaveBeenNthCalledWith( + 2, + [ + "connect", + "attach", + "photon/imessage0", + "--project", + "prj_123", + "--environment", + "production", + "--triggers", + "--trigger-path", + "/eve/v1/photon", + "--yes", + "--scope", + "team_123", + ], + expect.objectContaining({ cwd: "/tmp/imessage0", nonInteractive: true }), + ); + }); +}); diff --git a/packages/eve/src/setup/photon-connect.ts b/packages/eve/src/setup/photon-connect.ts new file mode 100644 index 000000000..44dd5f798 --- /dev/null +++ b/packages/eve/src/setup/photon-connect.ts @@ -0,0 +1,178 @@ +import type { ChannelSetupLog } from "#setup/cli/index.js"; +import { createPromptCommandOutput, withPhase } from "#setup/cli/index.js"; +import type { VercelProjectReference } from "#setup/project-resolution.js"; +import { + runVercel, + runVercelCaptureStdout, + type RunVercelCaptureResult, +} from "#setup/primitives/run-vercel.js"; + +export const PHOTON_CONNECT_SERVICE = "photon"; +export const PHOTON_CONNECTOR_TYPE = "photon"; +export const PHOTON_TRIGGER_PATH = "/eve/v1/photon"; + +/** Photon project credentials collected separately from their Connect storage encoding. */ +export interface PhotonProjectCredentials { + projectId: string; + projectSecret: string; +} + +/** Identity of a native Photon connector created through Vercel Connect. */ +export interface PhotonConnectorRef { + id: string; + uid: string; + /** Direct project URL used until Photon is available as a managed trigger connector. */ + webhookUrl?: string; +} + +/** Effects used to provision a Photon connector. */ +export interface ProvisionPhotonConnectorDeps { + runVercel: typeof runVercel; + runVercelCaptureStdout: typeof runVercelCaptureStdout; +} + +/** Input for provisioning one Photon connector and its eve webhook destination. */ +export interface ProvisionPhotonConnectorOptions { + credentials: PhotonProjectCredentials; + log: ChannelSetupLog; + project: VercelProjectReference; + projectRoot: string; + slug: string; + signal?: AbortSignal; + deps?: ProvisionPhotonConnectorDeps; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Parses `vercel connect create -F json` output for a Photon connector. */ +export function parseCreatedPhotonConnector(stdout: string): PhotonConnectorRef | undefined { + let value: unknown; + try { + value = JSON.parse(stdout); + } catch { + return undefined; + } + if (!isRecord(value) || typeof value["id"] !== "string" || typeof value["uid"] !== "string") { + return undefined; + } + const subjects = value["supportedSubjectTypes"]; + if (!Array.isArray(subjects) || !subjects.includes("app")) return undefined; + return { id: value["id"], uid: value["uid"] }; +} + +function createData(credentials: PhotonProjectCredentials): string { + return JSON.stringify({ + projectId: credentials.projectId.trim(), + projectSecret: credentials.projectSecret.trim(), + }); +} + +function requireCreatedConnector(result: RunVercelCaptureResult): PhotonConnectorRef { + if (!result.ok) { + const detail = result.stderr?.trim() || result.stdout.trim(); + throw new Error( + detail ? `Photon connector creation failed: ${detail}` : "Photon connector creation failed.", + ); + } + const connector = parseCreatedPhotonConnector(result.stdout); + if (connector === undefined) { + throw new Error("Vercel returned an invalid Photon connector after creation."); + } + return connector; +} + +/** + * Creates a native Photon connector and attaches its verified webhook trigger + * to the linked project. Secrets travel over stdin, never argv. + */ +export async function provisionPhotonConnector( + options: ProvisionPhotonConnectorOptions, +): Promise { + const deps = options.deps ?? { runVercel, runVercelCaptureStdout }; + const onOutput = createPromptCommandOutput(options.log); + const result = await withPhase(options.log, "Creating Photon connector...", () => + deps.runVercelCaptureStdout( + [ + "connect", + "create", + PHOTON_CONNECT_SERVICE, + "--connector-type", + PHOTON_CONNECTOR_TYPE, + "--data", + "@-", + "--name", + options.slug, + "--triggers", + "-F", + "json", + "--scope", + options.project.orgId, + ], + { + cwd: options.projectRoot, + nonInteractive: true, + onOutput, + signal: options.signal, + stdin: createData(options.credentials), + }, + ), + ); + options.signal?.throwIfAborted(); + const connector = requireCreatedConnector(result); + + // Creation can auto-attach the cwd's linked project with a pathless default + // destination. Detach first so attach installs exactly one routed destination. + await deps.runVercel( + [ + "connect", + "detach", + connector.uid, + "--project", + options.project.projectId, + "--yes", + "--scope", + options.project.orgId, + ], + { + cwd: options.projectRoot, + nonInteractive: true, + onOutput, + signal: options.signal, + }, + ); + const attached = await withPhase(options.log, "Connecting Photon credentials...", () => + deps.runVercel( + [ + "connect", + "attach", + connector.uid, + "--project", + options.project.projectId, + "--environment", + "production", + "--triggers", + "--trigger-path", + PHOTON_TRIGGER_PATH, + "--yes", + "--scope", + options.project.orgId, + ], + { + cwd: options.projectRoot, + nonInteractive: true, + onOutput, + signal: options.signal, + }, + ), + ); + options.signal?.throwIfAborted(); + if (!attached) { + throw new Error( + `Photon connector was created, but its credentials could not be attached. Run \`vercel connect attach ${connector.uid} --environment production --yes\`.`, + ); + } + + return connector; +} diff --git a/packages/eve/src/setup/photon-management.test.ts b/packages/eve/src/setup/photon-management.test.ts new file mode 100644 index 000000000..cfd76a391 --- /dev/null +++ b/packages/eve/src/setup/photon-management.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + provisionPhotonProject, + usePhotonProject, + validatePhotonPhoneNumber, +} from "./photon-management.js"; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("Photon management provisioning", () => { + test("authorizes and creates a dedicated project with an operator", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + response({ + device_code: "device-code", + user_code: "ABCD-1234", + verification_uri_complete: "https://app.photon.codes/device?code=ABCD-1234", + expires_in: 600, + interval: 1, + }), + ) + .mockResolvedValueOnce(response({ access_token: "dashboard-token" })) + .mockResolvedValueOnce(response({ id: "project-id" })) + .mockResolvedValueOnce(response({ projectSecret: "project-secret" })) + .mockResolvedValueOnce( + response({ + succeed: true, + data: { user: { id: "user-id", assignedPhoneNumber: "+15550000000" } }, + }), + ); + const onAuthorization = vi.fn(); + + await expect( + provisionPhotonProject({ + projectName: "eve · demo", + phoneNumber: "+15551234567", + onAuthorization, + deps: { fetch, delay: vi.fn(async () => {}) }, + }), + ).resolves.toMatchObject({ + projectId: "project-id", + projectSecret: "project-secret", + assignedPhoneNumber: "+15550000000", + cleanup: expect.any(Function), + }); + + expect(onAuthorization).toHaveBeenCalledWith({ + userCode: "ABCD-1234", + verificationUrl: "https://app.photon.codes/device?code=ABCD-1234", + }); + const projectRequest = fetch.mock.calls[2]; + expect(JSON.parse(String(projectRequest?.[1]?.body))).toMatchObject({ + name: "eve · demo", + platforms: ["imessage"], + }); + const userRequest = fetch.mock.calls[4]; + expect(JSON.parse(String(userRequest?.[1]?.body))).toMatchObject({ + phoneNumber: "+15551234567", + }); + }); + + test("uses an existing project without dashboard authorization", async () => { + const fetch = vi.fn(async () => + response({ succeed: true, data: { user: { assignedPhoneNumber: "+15550000000" } } }), + ); + + await expect( + usePhotonProject({ + projectId: "project-id", + projectSecret: "project-secret", + phoneNumber: "+15551234567", + deps: { fetch, delay: vi.fn(async () => {}) }, + }), + ).resolves.toMatchObject({ + projectId: "project-id", + projectSecret: "project-secret", + assignedPhoneNumber: "+15550000000", + }); + expect(fetch).toHaveBeenCalledOnce(); + }); + + test.each([ + ["+15551234567", null], + ["+442079460123", null], + ["+1555123456", "exactly 10 digits"], + ["+155512345678", "exactly 10 digits"], + ["15551234567", "E.164"], + ["+123456", "E.164"], + ])("validates phone number %s", (phoneNumber, expected) => { + const result = validatePhotonPhoneNumber(phoneNumber); + if (expected === null) expect(result).toBeNull(); + else expect(result).toContain(expected); + }); + + test("rejects non-E.164 phone numbers before authorization", async () => { + const fetch = vi.fn(); + await expect( + provisionPhotonProject({ + projectName: "eve · demo", + phoneNumber: "555-1234", + onAuthorization: vi.fn(), + deps: { fetch, delay: vi.fn(async () => {}) }, + }), + ).rejects.toThrow("E.164"); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/setup/photon-management.ts b/packages/eve/src/setup/photon-management.ts new file mode 100644 index 000000000..af03e5260 --- /dev/null +++ b/packages/eve/src/setup/photon-management.ts @@ -0,0 +1,275 @@ +import { setTimeout as sleep } from "node:timers/promises"; + +const PHOTON_DASHBOARD_HOST = "https://app.photon.codes"; +const PHOTON_SPECTRUM_HOST = "https://spectrum.photon.codes"; +const PHOTON_DEVICE_CLIENT_ID = "photon-cli"; +const E164 = /^\+[1-9]\d{6,14}$/; + +/** Validates international syntax and the fixed-length North American numbering plan. */ +export function validatePhotonPhoneNumber(value: string): string | null { + const phoneNumber = value.trim(); + if (!E164.test(phoneNumber)) { + return "Use E.164 format: + followed by 7–15 digits, for example +15551234567"; + } + if (phoneNumber.startsWith("+1") && phoneNumber.length !== 12) { + return "US and Canadian numbers must be +1 followed by exactly 10 digits"; + } + return null; +} + +interface DeviceCodeResponse { + deviceCode: string; + userCode: string; + verificationUrl: string; + expiresIn: number; + interval: number; +} + +export interface PhotonManagedProject { + projectId: string; + projectSecret: string; + assignedPhoneNumber?: string; + /** Deletes the dedicated project when a later setup phase fails. */ + cleanup(): Promise; +} + +export interface PhotonDeviceAuthorization { + userCode: string; + verificationUrl: string; +} + +export interface ProvisionPhotonProjectOptions { + projectName: string; + phoneNumber: string; + onAuthorization(authorization: PhotonDeviceAuthorization): void; + signal?: AbortSignal; + deps?: PhotonManagementDeps; +} + +export interface UsePhotonProjectOptions { + projectId: string; + projectSecret: string; + phoneNumber: string; + deps?: PhotonManagementDeps; +} + +export interface PhotonManagementDeps { + fetch: typeof fetch; + delay(ms: number, signal?: AbortSignal): Promise; +} + +const defaultDeps: PhotonManagementDeps = { + fetch, + delay: (ms, signal) => sleep(ms, undefined, { signal }), +}; + +function record(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Photon returned an invalid response."); + } + return value as Record; +} + +async function json(response: Response, action: string): Promise> { + const body: unknown = await response.json().catch(() => ({})); + if (!response.ok) { + const value = record(body); + const detail = value["error"] ?? value["message"] ?? response.statusText; + throw new Error(`Photon ${action} failed: ${String(detail)}`); + } + return record(body); +} + +function bearer(token: string): Record { + return { authorization: `Bearer ${token}`, "content-type": "application/json" }; +} + +function basic(projectId: string, projectSecret: string): Record { + return { + authorization: `Basic ${Buffer.from(`${projectId}:${projectSecret}`).toString("base64")}`, + "content-type": "application/json", + }; +} + +async function requestDeviceCode(deps: PhotonManagementDeps): Promise { + const response = await deps.fetch(`${PHOTON_DASHBOARD_HOST}/api/auth/device/code`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ client_id: PHOTON_DEVICE_CLIENT_ID, scope: "openid profile email" }), + }); + const body = await json(response, "device login"); + const deviceCode = body["device_code"]; + const userCode = body["user_code"]; + const verificationUrl = body["verification_uri_complete"] ?? body["verification_uri"]; + if ( + typeof deviceCode !== "string" || + typeof userCode !== "string" || + typeof verificationUrl !== "string" + ) { + throw new Error("Photon returned an invalid device authorization response."); + } + return { + deviceCode, + userCode, + verificationUrl, + expiresIn: typeof body["expires_in"] === "number" ? body["expires_in"] : 1800, + interval: typeof body["interval"] === "number" ? body["interval"] : 5, + }; +} + +async function pollForToken( + code: DeviceCodeResponse, + deps: PhotonManagementDeps, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + code.expiresIn * 1000; + let intervalMs = code.interval * 1000; + while (Date.now() < deadline) { + await deps.delay(intervalMs, signal); + const response = await deps.fetch(`${PHOTON_DASHBOARD_HOST}/api/auth/device/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: code.deviceCode, + client_id: PHOTON_DEVICE_CLIENT_ID, + }), + signal, + }); + const body = record(await response.json().catch(() => ({}))); + if (response.ok) { + const token = body["access_token"] ?? body["accessToken"]; + if (typeof token === "string" && token.length > 0) return token; + throw new Error("Photon approved device login without returning an access token."); + } + const error = body["error"] ?? body["message"]; + if (error === "authorization_pending") continue; + if (error === "slow_down" || response.status === 429) { + intervalMs += response.status === 429 ? 10_000 : 5_000; + continue; + } + throw new Error(`Photon device login failed: ${String(error ?? response.statusText)}`); + } + throw new Error("Photon device login timed out."); +} + +async function createProject( + token: string, + name: string, + deps: PhotonManagementDeps, +): Promise { + const response = await deps.fetch(`${PHOTON_DASHBOARD_HOST}/api/projects`, { + method: "POST", + headers: bearer(token), + body: JSON.stringify({ + name, + location: "United States", + platforms: ["imessage"], + template: false, + observability: false, + }), + }); + const body = await json(response, "project creation"); + if (typeof body["id"] !== "string") throw new Error("Photon did not return a project ID."); + return body["id"]; +} + +async function regenerateSecret( + token: string, + projectId: string, + deps: PhotonManagementDeps, +): Promise { + const response = await deps.fetch( + `${PHOTON_DASHBOARD_HOST}/api/projects/${encodeURIComponent(projectId)}/regenerate-secret`, + { method: "POST", headers: bearer(token), body: "{}" }, + ); + const body = await json(response, "project credential provisioning"); + if (typeof body["projectSecret"] !== "string") { + throw new Error("Photon did not return the new project secret."); + } + return body["projectSecret"]; +} + +async function registerUser( + projectId: string, + projectSecret: string, + phoneNumber: string, + deps: PhotonManagementDeps, +): Promise { + const response = await deps.fetch( + `${PHOTON_SPECTRUM_HOST}/projects/${encodeURIComponent(projectId)}/users/`, + { + method: "POST", + headers: basic(projectId, projectSecret), + body: JSON.stringify({ type: "shared", phoneNumber }), + }, + ); + const body = await json(response, "phone registration"); + const data = record(body["data"] ?? body); + const user = record(data["user"] ?? data); + const assignedPhoneNumber = user["assignedPhoneNumber"] ?? user["phoneNumber"]; + return typeof assignedPhoneNumber === "string" ? assignedPhoneNumber : undefined; +} + +async function deleteProject( + token: string, + projectId: string, + deps: PhotonManagementDeps, +): Promise { + const response = await deps.fetch( + `${PHOTON_DASHBOARD_HOST}/api/projects/${encodeURIComponent(projectId)}`, + { method: "DELETE", headers: bearer(token) }, + ); + if (!response.ok && response.status !== 404) await json(response, "project cleanup"); +} + +/** Authorizes Photon and creates an isolated iMessage project for one eve agent. */ +export async function provisionPhotonProject( + options: ProvisionPhotonProjectOptions, +): Promise { + const phoneNumber = options.phoneNumber.trim(); + const validationError = validatePhotonPhoneNumber(phoneNumber); + if (validationError !== null) + throw new Error(`Photon phone number is invalid. ${validationError}.`); + const deps = options.deps ?? defaultDeps; + const code = await requestDeviceCode(deps); + options.onAuthorization({ userCode: code.userCode, verificationUrl: code.verificationUrl }); + const token = await pollForToken(code, deps, options.signal); + const projectId = await createProject(token, options.projectName, deps); + const cleanup = () => deleteProject(token, projectId, deps); + try { + const projectSecret = await regenerateSecret(token, projectId, deps); + const assignedPhoneNumber = await registerUser(projectId, projectSecret, phoneNumber, deps); + return assignedPhoneNumber === undefined + ? { projectId, projectSecret, cleanup } + : { projectId, projectSecret, assignedPhoneNumber, cleanup }; + } catch (error) { + await cleanup().catch(() => {}); + throw error; + } +} + +/** Validates existing Photon credentials and registers the agent's iMessage user. */ +export async function usePhotonProject( + options: UsePhotonProjectOptions, +): Promise { + const phoneNumber = options.phoneNumber.trim(); + const validationError = validatePhotonPhoneNumber(phoneNumber); + if (validationError !== null) + throw new Error(`Photon phone number is invalid. ${validationError}.`); + const projectId = options.projectId.trim(); + const projectSecret = options.projectSecret.trim(); + if (!projectId || !projectSecret) { + throw new Error("Photon project ID and project secret are required."); + } + const assignedPhoneNumber = await registerUser( + projectId, + projectSecret, + phoneNumber, + options.deps ?? defaultDeps, + ); + const cleanup = async () => {}; + return assignedPhoneNumber === undefined + ? { projectId, projectSecret, cleanup } + : { projectId, projectSecret, assignedPhoneNumber, cleanup }; +} diff --git a/packages/eve/src/setup/photon-setup-environment.ts b/packages/eve/src/setup/photon-setup-environment.ts new file mode 100644 index 000000000..58da491ed --- /dev/null +++ b/packages/eve/src/setup/photon-setup-environment.ts @@ -0,0 +1,41 @@ +import type { ProjectResolution } from "./project-resolution.js"; +import type { VercelAuthStatus } from "./vercel-project.js"; + +/** Read-only hosting facts available to Photon-owned setup hooks. */ +export interface PhotonSetupEnvironment { + vercel: + | { kind: "available"; project: ProjectResolution } + | { kind: "unavailable"; reason: Exclude }; +} + +/** Describes the result of the read-only Vercel capability probe. */ +export function describePhotonSetupEnvironment(environment: PhotonSetupEnvironment): string { + if (environment.vercel.kind === "available") { + switch (environment.vercel.project.kind) { + case "deployed": + return `Found an authenticated Vercel account and deployed project (${environment.vercel.project.productionUrl}).`; + case "linked": + return "Found an authenticated Vercel account and linked project."; + case "unresolved": + return "Found an authenticated Vercel account; this directory is not linked to a project."; + } + } + switch (environment.vercel.reason) { + case "logged-out": + return "No authenticated Vercel account found; choose Vercel Connect or portable Photon credentials."; + case "cli-missing": + return "Vercel CLI not found; choose Vercel Connect or portable Photon credentials."; + case "unavailable": + return "Could not verify the Vercel account; choose Vercel Connect or portable Photon credentials."; + } +} + +/** Builds Photon setup facts from the independent Vercel probes. */ +export function photonSetupEnvironment( + authStatus: VercelAuthStatus, + project: ProjectResolution, +): PhotonSetupEnvironment { + return authStatus === "authenticated" + ? { vercel: { kind: "available", project } } + : { vercel: { kind: "unavailable", reason: authStatus } }; +} diff --git a/packages/eve/src/setup/photon-setup-integration.ts b/packages/eve/src/setup/photon-setup-integration.ts new file mode 100644 index 000000000..6897723e0 --- /dev/null +++ b/packages/eve/src/setup/photon-setup-integration.ts @@ -0,0 +1,34 @@ +import type { PhotonSetupEnvironment } from "./photon-setup-environment.js"; +import type { PhotonSetupUi } from "./photon-setup-ui.js"; +import type { ProjectResolution } from "./project-resolution.js"; + +/** State owned by a Photon setup invocation. */ +export interface PhotonSetupState { + readonly agentName: string; + readonly project: ProjectResolution; + readonly projectPath: string; +} + +/** Shared inputs available to the Photon setup implementation. */ +export interface PhotonSetupContext { + readonly environment: PhotonSetupEnvironment; + readonly state: Readonly; + readonly ui: PhotonSetupUi; + readonly signal?: AbortSignal; + readonly force?: boolean; + readonly headless?: boolean; + readonly photonDeps?: import("./photon-setup.js").PhotonSetupDeps; +} + +/** Structured outcome from the Photon setup implementation. */ +export type PhotonSetupResult = + | { readonly kind: "done"; readonly state: PhotonSetupState } + | { readonly kind: "cancelled" }; + +/** Guided Photon setup behavior. */ +export interface PhotonSetupIntegration { + readonly kind: "photon"; + readonly label: string; + readonly hint?: string; + setup(context: PhotonSetupContext): Promise; +} diff --git a/packages/eve/src/setup/photon-setup-integrations.ts b/packages/eve/src/setup/photon-setup-integrations.ts new file mode 100644 index 000000000..948c78b1a --- /dev/null +++ b/packages/eve/src/setup/photon-setup-integrations.ts @@ -0,0 +1,9 @@ +import { PHOTON_CHANNEL_SETUP } from "./photon-setup.js"; +import type { PhotonSetupIntegration } from "./photon-setup-integration.js"; + +/** Resolves the Photon setup integration. */ +export function photonSetupIntegration(): PhotonSetupIntegration { + return PHOTON_CHANNEL_SETUP; +} + +export { createPhotonSetupUi } from "./photon-setup-ui.js"; diff --git a/packages/eve/src/setup/photon-setup-ui.ts b/packages/eve/src/setup/photon-setup-ui.ts new file mode 100644 index 000000000..7993bcfae --- /dev/null +++ b/packages/eve/src/setup/photon-setup-ui.ts @@ -0,0 +1,29 @@ +import { confirm, SkippedSignal, type Asker } from "./ask.js"; +import type { Prompter } from "./prompter.js"; + +/** UI capabilities available to a Photon-owned setup hook. */ +export interface PhotonSetupUi { + readonly asker: Asker; + readonly prompter: Prompter; + confirm(input: { key: string; message: string; recommended?: boolean }): Promise; + nextSteps(lines: readonly string[]): void; +} + +/** Adapts the shared setup asker and prompter to the Photon setup UI. */ +export function createPhotonSetupUi(input: { asker: Asker; prompter: Prompter }): PhotonSetupUi { + return { + ...input, + async confirm(question) { + try { + return await input.asker.ask(confirm(question)); + } catch (error) { + if (error instanceof SkippedSignal) return false; + throw error; + } + }, + nextSteps(lines) { + if (lines.length === 0) return; + input.prompter.note(lines.join("\n"), "Next steps", { tone: "success" }); + }, + }; +} diff --git a/packages/eve/src/setup/photon-setup.test.ts b/packages/eve/src/setup/photon-setup.test.ts new file mode 100644 index 000000000..445f2744a --- /dev/null +++ b/packages/eve/src/setup/photon-setup.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; + +import type { Asker, Question } from "./ask.js"; +import { photonSetupEnvironment } from "./photon-setup-environment.js"; +import type { PhotonSetupDeps } from "./photon-setup.js"; +import { PHOTON_CHANNEL_SETUP } from "./photon-setup.js"; +import { createPhotonSetupUi } from "./photon-setup-ui.js"; + +function asker(answers: Record): Asker { + return { + ask: async (question: Question) => answers[question.key] as T, + askMany: async () => [], + }; +} + +function deps(): PhotonSetupDeps { + return { + appendEnv: vi.fn(async () => ({ written: [], skipped: [] })), + deriveConnectorSlug: vi.fn(async () => "agent" as never), + ensureLinkedVercelProject: vi.fn(async () => ({ orgId: "team-id", projectId: "project-id" })), + openUrl: vi.fn(), + provisionConnector: vi.fn(), + provisionProject: vi.fn(async () => ({ + projectId: "project-id", + projectSecret: "project-secret", + cleanup: vi.fn(async () => {}), + })), + useProject: vi.fn(), + writeTextFile: vi.fn(async () => {}), + }; +} + +describe("Photon setup", () => { + it("scaffolds portable credentials when Vercel Connect is unavailable", async () => { + const fake = createFakePrompter({ single: () => "portable" }); + const effects = deps(); + + await expect( + PHOTON_CHANNEL_SETUP.setup({ + environment: photonSetupEnvironment("cli-missing", { kind: "unresolved" }), + state: { + agentName: "agent", + project: { kind: "unresolved" }, + projectPath: "/project", + }, + ui: createPhotonSetupUi({ + asker: asker({ "photon-phone-number": "+15551234567" }), + prompter: fake.prompter, + }), + photonDeps: effects, + }), + ).resolves.toMatchObject({ kind: "done" }); + + expect(effects.appendEnv).toHaveBeenCalledWith("/project/.env.local", { + IMESSAGE_PROJECT_ID: "project-id", + IMESSAGE_PROJECT_SECRET: "project-secret", + }); + expect(effects.provisionConnector).not.toHaveBeenCalled(); + expect(effects.writeTextFile).toHaveBeenCalledWith( + "/project/agent/channels/photon.ts", + expect.stringContaining("IMESSAGE_WEBHOOK_SECRET"), + { force: undefined }, + ); + }); + + it("requires Vercel login when Connect is selected without an authenticated CLI", async () => { + const fake = createFakePrompter({ single: () => "vercel" }); + + await expect( + PHOTON_CHANNEL_SETUP.setup({ + environment: photonSetupEnvironment("cli-missing", { kind: "unresolved" }), + state: { + agentName: "agent", + project: { kind: "unresolved" }, + projectPath: "/project", + }, + ui: createPhotonSetupUi({ asker: asker({}), prompter: fake.prompter }), + photonDeps: deps(), + }), + ).rejects.toThrow("vercel login"); + }); +}); diff --git a/packages/eve/src/setup/photon-setup.ts b/packages/eve/src/setup/photon-setup.ts new file mode 100644 index 000000000..f3b62c364 --- /dev/null +++ b/packages/eve/src/setup/photon-setup.ts @@ -0,0 +1,271 @@ +import { join } from "node:path"; + +import { text } from "./ask.js"; +import { appendEnv } from "./append-env.js"; +import type { PhotonSetupIntegration } from "./photon-setup-integration.js"; +import { provisionPhotonConnector } from "./photon-connect.js"; +import { + provisionPhotonProject, + usePhotonProject, + validatePhotonPhoneNumber, + type PhotonManagedProject, +} from "./photon-management.js"; +import { openUrl } from "./primitives/open-url.js"; +import { deriveSlackConnectorSlug } from "./scaffold/index.js"; +import { writeTextFile } from "./scaffold/files.js"; +import { WizardCancelledError } from "./step.js"; +import { ensureLinkedVercelProject } from "./vercel-project.js"; + +interface PhotonSetupPlan { + credentials: "vercel-connect" | "environment"; + photonProject: "create" | { projectId: string; projectSecret: string }; + photonProjectName?: string; +} + +export interface PhotonSetupDeps { + appendEnv: typeof appendEnv; + deriveConnectorSlug: typeof deriveSlackConnectorSlug; + ensureLinkedVercelProject: typeof ensureLinkedVercelProject; + openUrl: typeof openUrl; + provisionConnector: typeof provisionPhotonConnector; + provisionProject: typeof provisionPhotonProject; + useProject: typeof usePhotonProject; + writeTextFile: typeof writeTextFile; +} + +const defaultDeps: PhotonSetupDeps = { + appendEnv, + deriveConnectorSlug: deriveSlackConnectorSlug, + ensureLinkedVercelProject, + openUrl, + provisionConnector: provisionPhotonConnector, + provisionProject: provisionPhotonProject, + useProject: usePhotonProject, + writeTextFile, +}; + +const PORTABLE_TEMPLATE = `import { photonChannel } from "eve/channels/photon"; + +async function photonCredentials() { + const projectId = process.env.IMESSAGE_PROJECT_ID; + const projectSecret = process.env.IMESSAGE_PROJECT_SECRET; + if (!projectId || !projectSecret) throw new Error("Photon project credentials are required."); + return { projectId, projectSecret }; +} + +export default photonChannel({ + credentials: photonCredentials, + webhookSecret: process.env.IMESSAGE_WEBHOOK_SECRET, +}); +`; + +function connectTemplate(connectorUid: string): string { + return `import { connectPhotonCredentials } from "@vercel/connect/eve"; +import { photonChannel } from "eve/channels/photon"; + +export default photonChannel({ + credentials: connectPhotonCredentials(${JSON.stringify(connectorUid)}), +}); +`; +} + +async function choosePhotonProject( + context: Parameters[0], +): Promise> { + const defaultName = `eve · ${context.state.agentName || "agent"}`; + const options = [ + { + value: "create" as const, + label: "Create a new Photon project", + hint: `Name: ${defaultName}`, + }, + { + value: "existing" as const, + label: "Use an existing Photon project", + hint: "Enter its project credentials", + }, + ]; + const editable = context.ui.prompter.selectEditable + ? await context.ui.prompter.selectEditable<"create" | "existing">({ + message: "Photon project", + options, + initialValue: "create", + editable: { + value: "create", + defaultValue: defaultName, + formatHint: (value) => `Name: ${value}`, + validate: (value) => + value.trim().length === 0 ? "Project name cannot be empty." : undefined, + }, + }) + : undefined; + const source = + editable?.value ?? + (await context.ui.prompter.select<"create" | "existing">({ + message: "Photon project", + options, + initialValue: "create", + })); + if (source === "existing") { + const projectId = await context.ui.asker.ask( + text({ key: "photon-project-id", message: "Photon project ID", required: true }), + ); + const projectSecret = await context.ui.asker.ask( + text({ + key: "photon-project-secret", + message: "Photon project secret", + required: true, + sensitive: true, + }), + ); + return { photonProject: { projectId: projectId.trim(), projectSecret: projectSecret.trim() } }; + } + + return { + photonProject: "create", + photonProjectName: editable?.kind === "edited" ? editable.text.trim() : defaultName, + }; +} + +async function chooseSetupPlan( + context: Parameters[0], +): Promise { + try { + const destination = await context.ui.prompter.select<"vercel" | "portable">({ + message: "How would you like to configure Photon?", + options: [ + { + value: "vercel", + label: "Set up Vercel Connect", + hint: + context.environment.vercel.kind === "available" + ? "Link this project and configure Photon automatically" + : "Log in to Vercel and link this project", + }, + { + value: "portable", + label: "Use portable credentials", + hint: "Configure the Photon webhook manually after deployment", + }, + ], + initialValue: context.environment.vercel.kind === "available" ? "vercel" : "portable", + }); + if (destination === "vercel" && context.environment.vercel.kind === "unavailable") { + throw new Error( + "Vercel Connect requires an authenticated Vercel CLI. Run `vercel login`, then retry Photon setup.", + ); + } + const photon = await choosePhotonProject(context); + return { credentials: destination === "vercel" ? "vercel-connect" : "environment", ...photon }; + } catch (error) { + if (error instanceof WizardCancelledError) return "cancelled"; + throw error; + } +} + +async function resolvePhotonProject( + context: Parameters[0], + plan: PhotonSetupPlan, + phoneNumber: string, + deps: PhotonSetupDeps, +): Promise { + if (plan.photonProject !== "create") { + return deps.useProject({ ...plan.photonProject, phoneNumber }); + } + return deps.provisionProject({ + projectName: plan.photonProjectName ?? `eve · ${context.state.agentName || "agent"}`, + phoneNumber, + signal: context.signal, + onAuthorization(authorization) { + context.ui.prompter.log.message(`Authorize Photon: ${authorization.verificationUrl}`); + context.ui.prompter.log.message(`Photon code: ${authorization.userCode}`); + deps.openUrl(authorization.verificationUrl); + }, + }); +} + +async function setupPhoton( + context: Parameters[0], + plan: PhotonSetupPlan, + deps: PhotonSetupDeps, +): Promise<"created" | "cancelled"> { + const phoneNumber = await context.ui.asker.ask( + text({ + key: "photon-phone-number", + message: "Your iMessage phone number", + placeholder: "+15551234567", + required: true, + validate: validatePhotonPhoneNumber, + }), + ); + const projectRoot = context.state.projectPath; + const managedProject = await resolvePhotonProject(context, plan, phoneNumber, deps); + try { + const channelPath = join(projectRoot, "agent/channels/photon.ts"); + if (plan.credentials === "vercel-connect") { + const slug = await deps.deriveConnectorSlug(projectRoot, context.state.agentName); + const project = await deps.ensureLinkedVercelProject({ + projectRoot, + prompter: context.ui.prompter, + signal: context.signal, + }); + const connector = await deps.provisionConnector({ + credentials: managedProject, + log: context.ui.prompter.log, + project, + projectRoot, + slug, + signal: context.signal, + }); + await deps.writeTextFile(channelPath, connectTemplate(connector.uid), { + force: context.force, + }); + } else { + await deps.appendEnv(join(projectRoot, ".env.local"), { + IMESSAGE_PROJECT_ID: managedProject.projectId, + IMESSAGE_PROJECT_SECRET: managedProject.projectSecret, + }); + await deps.writeTextFile(channelPath, PORTABLE_TEMPLATE, { force: context.force }); + context.ui.nextSteps([ + "Deploy the agent, then create a Photon webhook pointing to https:///eve/v1/photon.", + "Copy the webhook signing secret into IMESSAGE_WEBHOOK_SECRET, alongside IMESSAGE_PROJECT_ID and IMESSAGE_PROJECT_SECRET, in your host's encrypted environment variables.", + ]); + } + context.ui.prompter.log.success("Scaffolded channel: photon"); + if (managedProject.assignedPhoneNumber !== undefined) { + context.ui.prompter.note(managedProject.assignedPhoneNumber, "Text your agent", { + tone: "success", + }); + } + context.ui.prompter.note( + `https://app.photon.codes/dashboard/${managedProject.projectId}`, + "Photon project", + { tone: "success" }, + ); + return "created"; + } catch (error) { + await managedProject.cleanup().catch(() => {}); + throw error; + } +} + +/** Photon-managed project provisioning and channel scaffolding. */ +export const PHOTON_CHANNEL_SETUP: PhotonSetupIntegration = { + kind: "photon", + label: "Photon", + hint: "Messages through Photon", + async setup(context) { + try { + const plan = await chooseSetupPlan(context); + if (plan === "cancelled") return { kind: "cancelled" }; + await setupPhoton(context, plan, context.photonDeps ?? defaultDeps); + return { + kind: "done", + state: context.state, + }; + } catch (error) { + if (error instanceof WizardCancelledError) return { kind: "cancelled" }; + throw error; + } + }, +}; diff --git a/packages/eve/src/setup/primitives/run-vercel.test.ts b/packages/eve/src/setup/primitives/run-vercel.test.ts index c4fffce85..b95396d9d 100644 --- a/packages/eve/src/setup/primitives/run-vercel.test.ts +++ b/packages/eve/src/setup/primitives/run-vercel.test.ts @@ -22,6 +22,7 @@ const mockedSpawn = vi.mocked(spawn); * streams, close/error events, and a spyable `kill`. */ type ChildProcessDouble = ChildProcess & { + stdin: PassThrough; stdout: PassThrough; stderr: PassThrough; kill: ReturnType boolean>>; @@ -29,6 +30,7 @@ type ChildProcessDouble = ChildProcess & { function createChildProcess(): ChildProcessDouble { const child = new EventEmitter() as ChildProcessDouble; + child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.kill = vi.fn((_signal?: NodeJS.Signals | number) => true); @@ -144,6 +146,52 @@ describe("runVercel", () => { }); }); + test("writes supplied stdin without exposing it in argv", async () => { + const child = createChildProcess(); + mockSpawnReturn(child); + const inputChunks: Buffer[] = []; + child.stdin.on("data", (chunk: Buffer) => inputChunks.push(chunk)); + + const result = runVercelCaptureStdout( + ["connect", "create", "photon", "--data", "@-", "-F", "json"], + { + cwd: "/tmp/eve-agent", + nonInteractive: true, + stdin: '{"values":[{"value":"secret"}]}', + }, + ); + child.emit("close", 0); + + await expect(result).resolves.toEqual({ ok: true, stdout: "" }); + expectSpawnedVercel( + ["connect", "create", "photon", "--data", "@-", "-F", "json", "--non-interactive"], + { stdio: ["pipe", "pipe", "inherit"] }, + ); + expect(Buffer.concat(inputChunks).toString("utf8")).toBe('{"values":[{"value":"secret"}]}'); + }); + + test("settles as a failure when the child closes stdin before secrets are written", async () => { + const child = createChildProcess(); + mockSpawnReturn(child); + const onOutput = vi.fn(); + + const result = runVercelCaptureStdout(["connect", "create", "photon", "--data", "@-"], { + cwd: "/tmp/eve-agent", + nonInteractive: true, + stdin: '{"projectSecret":"secret"}', + onOutput, + }); + const error: NodeJS.ErrnoException = new Error("write EPIPE"); + error.code = "EPIPE"; + child.stdin.emit("error", error); + + await expect(result).resolves.toEqual({ ok: false, stdout: "" }); + expect(onOutput).toHaveBeenCalledWith({ + stream: "stderr", + text: "vercel connect create photon --data @- stdin failed: write EPIPE", + }); + }); + test("passes cancellation to the child and settles without a stale failure line", async () => { const child = createChildProcess(); mockSpawnReturn(child); diff --git a/packages/eve/src/setup/primitives/run-vercel.ts b/packages/eve/src/setup/primitives/run-vercel.ts index ef7c4b932..724db7fad 100644 --- a/packages/eve/src/setup/primitives/run-vercel.ts +++ b/packages/eve/src/setup/primitives/run-vercel.ts @@ -50,6 +50,8 @@ export interface RunVercelOptions { extraEnv?: Readonly>; /** Pass `--non-interactive` and close stdin so automation cannot stop on a prompt. */ nonInteractive?: boolean; + /** UTF-8 data written to stdin, used to keep connector secrets out of argv. */ + stdin?: string; /** Streams command output to a parent-owned renderer instead of writing outside it. */ onOutput?: ProcessOutputHandler; /** Aborts the Vercel CLI subprocess when its parent setup flow is interrupted. */ @@ -158,11 +160,26 @@ export function resolveVercelInvocation( : { command: localBinary, commandArgs: args }; } +function stdinMode(options: RunVercelOptions): "inherit" | "ignore" | "pipe" { + if (options.stdin !== undefined) return "pipe"; + return options.nonInteractive ? "ignore" : "inherit"; +} + +function writeStdin( + child: ChildProcess, + input: string | undefined, + onError: (error: NodeJS.ErrnoException) => void, +): void { + if (input === undefined || child.stdin === null) return; + child.stdin.once("error", onError); + child.stdin.end(input, "utf8"); +} + function stdioForRun( options: RunVercelOptions, -): ["inherit" | "ignore", "pipe", "pipe"] | "inherit" { - if (options.onOutput) { - return [options.nonInteractive ? "ignore" : "inherit", "pipe", "pipe"]; +): ["inherit" | "ignore" | "pipe", "pipe", "pipe"] | "inherit" { + if (options.onOutput || options.stdin !== undefined) { + return [stdinMode(options), "pipe", "pipe"]; } return options.nonInteractive ? ["ignore", "pipe", "pipe"] : "inherit"; } @@ -209,6 +226,10 @@ export async function runVercel(args: string[], options: RunVercelOptions): Prom reportFailure(timeoutMessage(args, options.timeoutMs ?? 0)); settle(false); }); + writeStdin(child, options.stdin, (error) => { + reportFailure(`vercel ${args.join(" ")} stdin failed: ${error.message}`); + settle(false); + }); child.on("error", (error: NodeJS.ErrnoException) => { if (isAbortError(error, options.signal)) { @@ -270,11 +291,7 @@ export async function runVercelCaptureStdout( const outputBuffer = options.onOutput && createProcessOutputBuffer(options.onOutput); const child = spawn(invocation.command, invocation.commandArgs, { cwd, - stdio: [ - options.nonInteractive ? "ignore" : "inherit", - "pipe", - options.onOutput ? "pipe" : "inherit", - ], + stdio: [stdinMode(options), "pipe", options.onOutput ? "pipe" : "inherit"], env: buildSpawnEnv(options.extraEnv ?? {}), shell: invocation.shell, signal: options.signal, @@ -309,6 +326,10 @@ export async function runVercelCaptureStdout( reportFailure(timeoutMessage(args, options.timeoutMs ?? 0)); settle(false); }); + writeStdin(child, options.stdin, (error) => { + reportFailure(`vercel ${args.join(" ")} stdin failed: ${error.message}`); + settle(false); + }); child.on("error", (error: NodeJS.ErrnoException) => { if (isAbortError(error, options.signal)) { @@ -392,7 +413,7 @@ export async function captureVercel( const outputBuffer = options.onOutput && createProcessOutputBuffer(options.onOutput); const child = spawn(invocation.command, invocation.commandArgs, { cwd, - stdio: ["ignore", "pipe", "pipe"], + stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], env: buildSpawnEnv(options.extraEnv ?? {}), shell: invocation.shell, signal: options.signal, @@ -429,6 +450,14 @@ export async function captureVercel( message: timeoutMessage(args, options.timeoutMs ?? 0), }); }); + writeStdin(child, options.stdin, (error) => { + fail({ + errno: error.code, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + message: `vercel ${args.join(" ")} stdin failed: ${error.message}`, + }); + }); child.on("error", (error: NodeJS.ErrnoException) => { if (isAbortError(error, options.signal)) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91c80a0fb..69cc2437a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ catalogs: specifier: 19.2.3 version: 19.2.3 '@vercel/connect': - specifier: 0.4.2 - version: 0.4.2 + specifier: 0.4.3 + version: 0.4.3 '@vercel/otel': specifier: 2.1.3 version: 2.1.3 @@ -239,7 +239,7 @@ importers: version: 0.2.0(ai@7.0.38(zod@4.4.3))(chat@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(react-native@0.86.0(@babel/core@7.29.0(supports-color@10.2.2))(@types/react@19.2.15)(bufferutil@4.1.0)(react@19.2.6)(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3) '@github-tools/eve-extension': specifier: ^0.1.0 - version: 0.1.0(@vercel/connect@0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + version: 0.1.0(@vercel/connect@0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) '@jetty/eve': specifier: ^0.3.0 version: 0.3.0(eve@packages+eve) @@ -293,7 +293,7 @@ importers: version: 0.2.4(ai@7.0.38(zod@4.4.3))(chat@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) '@vercel/connect': specifier: 'catalog:' - version: 0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + version: 0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) '@vercel/otel': specifier: 'catalog:' version: 2.1.3(@opentelemetry/api-logs@0.221.0)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2))(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)) @@ -381,7 +381,7 @@ importers: version: 0.41.2 '@vercel/connect': specifier: 'catalog:' - version: 0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + version: 0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) '@vercel/oidc': specifier: 3.8.0 version: 3.8.0 @@ -6889,12 +6889,15 @@ packages: '@vercel/cli-config@0.2.0': resolution: {integrity: sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ==} + '@vercel/cli-config@0.2.1': + resolution: {integrity: sha512-RhfyXmRLHdbnry8RJqHDc+5rGxMZ0bu+fpysZjtv3bE+BubpuwxTancHOKiH5zKQREsdwFVr3mOI2kOvxlOyxA==} + '@vercel/cli-exec@1.0.0': resolution: {integrity: sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug==} engines: {node: '>= 18'} - '@vercel/connect@0.4.2': - resolution: {integrity: sha512-3Lr2yFI2Z2NPR+T3DWOqrygBpxZUK6H+wCesijT2pV7VmuG8l5ZYSxk4tdzZwF4SWYJOXQFzcELtFLCGvBw5mg==} + '@vercel/connect@0.4.3': + resolution: {integrity: sha512-NDB5MhjvmeBXWAohLX7wKYnaI18pzvcqmPgSqdPHUcIdYNgHk8KlqjSkII92MgOb+zzP5t0J0yiQScwXKzkmow==} peerDependencies: '@ai-sdk/mcp': ^1 || ^2 '@auth/core': '>=0.37.0' @@ -7018,6 +7021,10 @@ packages: resolution: {integrity: sha512-r00laGW6Pv778RoR6M2NxX91ycSj+PBwVo+fOb9Bif+F0IyUKt25zrvBzfEzQpeAzbqOgPZyQibEWDdDFApd+A==} engines: {node: '>= 20'} + '@vercel/oidc@3.8.1': + resolution: {integrity: sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw==} + engines: {node: '>= 20'} + '@vercel/otel@2.1.3': resolution: {integrity: sha512-Ofvzs9qhftRD1YMLuPnhbXjQZG6IKrJ9AmEKmRHRGfoWlV89ed2gAOkvddkFiZDFZJm2rrFNdeZKRVxoCdnWiw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -16043,26 +16050,26 @@ snapshots: - react-native - supports-color - '@github-tools/eve-extension@0.1.0(@vercel/connect@0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)': + '@github-tools/eve-extension@0.1.0(@vercel/connect@0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)': dependencies: - '@github-tools/sdk': 1.8.2(@vercel/connect@0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)(zod@4.4.3) + '@github-tools/sdk': 1.8.2(@vercel/connect@0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)(zod@4.4.3) eve: link:packages/eve zod: 4.4.3 optionalDependencies: - '@vercel/connect': 0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + '@vercel/connect': 0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) transitivePeerDependencies: - '@ai-sdk/workflow' - '@workflow/ai' - ai - workflow - '@github-tools/sdk@1.8.2(@vercel/connect@0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)(zod@4.4.3)': + '@github-tools/sdk@1.8.2(@vercel/connect@0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)(zod@4.4.3)': dependencies: ai: 7.0.38(zod@4.4.3) octokit: 5.0.5 zod: 4.4.3 optionalDependencies: - '@vercel/connect': 0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + '@vercel/connect': 0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) eve: link:packages/eve '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(bufferutil@4.1.0)(supports-color@10.2.2)': @@ -17353,7 +17360,7 @@ snapshots: '@onkernel/eve-extension@0.1.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(bufferutil@4.1.0)(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)': dependencies: - '@vercel/connect': 0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) + '@vercel/connect': 0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve) eve: link:packages/eve zod: 4.4.3 transitivePeerDependencies: @@ -20385,13 +20392,18 @@ snapshots: xdg-app-paths: 5.1.0 zod: 4.1.11 + '@vercel/cli-config@0.2.1': + dependencies: + xdg-app-paths: 5.1.0 + zod: 4.1.11 + '@vercel/cli-exec@1.0.0': dependencies: execa: 5.1.1 - '@vercel/connect@0.4.2(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)': + '@vercel/connect@0.4.3(@ai-sdk/mcp@2.0.16(zod@4.4.3))(@auth/core@0.41.2)(@chat-adapter/slack@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(ai@7.0.38(zod@4.4.3))(eve@packages+eve)': dependencies: - '@vercel/oidc': 3.8.0 + '@vercel/oidc': 3.8.1 optionalDependencies: '@ai-sdk/mcp': 2.0.16(zod@4.4.3) '@auth/core': 0.41.2 @@ -20677,6 +20689,12 @@ snapshots: '@vercel/cli-exec': 1.0.0 jose: 5.10.0 + '@vercel/oidc@3.8.1': + dependencies: + '@vercel/cli-config': 0.2.1 + '@vercel/cli-exec': 1.0.0 + jose: 5.10.0 + '@vercel/otel@2.1.3(@opentelemetry/api-logs@0.221.0)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2))(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e44b1dfe1..c21cc0551 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -36,7 +36,7 @@ catalog: "@types/node": "25.9.1" "@types/react": "19.2.15" "@types/react-dom": "19.2.3" - "@vercel/connect": "0.4.2" + "@vercel/connect": "0.4.3" "@vercel/otel": "2.1.3" "@vercel/sandbox": "2.8.0" ai: "^7.0.38"