veryfront 0.1.555 → 0.1.557
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/cli/templates/manifest.d.ts +48 -2
- package/esm/cli/templates/manifest.js +79 -33
- package/esm/deno.js +1 -1
- package/esm/src/integrations/_data.js +18 -18
- package/esm/src/oauth/providers/common.d.ts.map +1 -1
- package/esm/src/oauth/providers/common.js +18 -2
- package/esm/src/utils/version-constant.d.ts +1 -1
- package/esm/src/utils/version-constant.js +1 -1
- package/package.json +1 -1
|
@@ -98,26 +98,32 @@ export default {
|
|
|
98
98
|
"app/api/integrations/token-storage/route.ts": "/**\n * Token Storage Status API\n *\n * Returns the current token storage mode and encryption status.\n * This endpoint is self-contained to work with any version of token-store.\n */\nexport async function GET(): Promise<Response> {\n const env = process.env;\n\n let mode: \"memory\" | \"database\" | \"kv\" | \"redis\" = \"memory\";\n if (env.DATABASE_URL) {\n mode = \"database\";\n } else if (env.KV_REST_API_URL) {\n mode = \"kv\";\n } else if (env.REDIS_URL) {\n mode = \"redis\";\n }\n\n const hasExplicitKey = env.TOKEN_ENCRYPTION_KEY?.length === 64;\n\n return Response.json({\n mode,\n encrypted: true,\n autoGenerated: !hasExplicitKey,\n });\n}\n",
|
|
99
99
|
"app/components/ServiceConnections.tsx": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\ninterface Service {\n id: string;\n name: string;\n connected: boolean;\n authUrl: string;\n}\n\ninterface ServiceConnectionsProps {\n services: Array<{\n id: string;\n name: string;\n authUrl: string;\n }>;\n className?: string;\n}\n\nfunction useIntegrationStatus(): { status: Record<string, boolean>; loading: boolean } {\n const [status, setStatus] = useState<Record<string, boolean>>({});\n const [loading, setLoading] = useState<boolean>(true);\n\n useEffect(() => {\n async function checkStatus(): Promise<void> {\n try {\n const res = await fetch(\"/api/integrations/status\");\n if (!res.ok) return;\n\n const data = await res.json();\n const integrations = data?.integrations ?? [];\n\n const statusMap: Record<string, boolean> = {};\n for (const integration of integrations) {\n statusMap[integration.id] = integration.connected;\n }\n\n setStatus(statusMap);\n } catch (error) {\n console.error(\"Failed to check service status:\", error);\n } finally {\n setLoading(false);\n }\n }\n\n checkStatus();\n }, []);\n\n return { status, loading };\n}\n\nfunction withStatus(\n services: ServiceConnectionsProps[\"services\"],\n status: Record<string, boolean>,\n): Service[] {\n return services.map((service) => ({\n ...service,\n connected: status[service.id] ?? false,\n }));\n}\n\nexport function ServiceConnections({\n services,\n className = \"\",\n}: ServiceConnectionsProps): React.ReactElement {\n const { status, loading } = useIntegrationStatus();\n\n if (loading) {\n return (\n <div className={`flex items-center gap-2 ${className}`}>\n <div className=\"animate-pulse h-6 w-32 bg-neutral-200 dark:bg-neutral-700 rounded\" />\n </div>\n );\n }\n\n const servicesWithStatus = withStatus(services, status);\n const connectedCount = servicesWithStatus.reduce(\n (count, service) => count + (service.connected ? 1 : 0),\n 0,\n );\n\n return (\n <div className={`flex items-center gap-2 ${className}`}>\n {servicesWithStatus.map((service) => (\n <ServiceBadge key={service.id} service={service} />\n ))}\n {connectedCount < services.length && (\n <span className=\"text-xs text-neutral-500 dark:text-neutral-400 ml-1\">\n {connectedCount}/{services.length} connected\n </span>\n )}\n </div>\n );\n}\n\nfunction ServiceBadge({ service }: { service: Service }): React.ReactElement {\n if (service.connected) {\n return (\n <span\n className=\"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400\"\n title={`${service.name} connected`}\n >\n <span className=\"w-1.5 h-1.5 rounded-full bg-green-500\" />\n {service.name}\n </span>\n );\n }\n\n const handleConnect = (): void => {\n globalThis.location.href = service.authUrl;\n };\n\n return (\n <button\n type=\"button\"\n onClick={handleConnect}\n className=\"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700 transition-colors\"\n title={`Connect ${service.name}`}\n >\n <span className=\"w-1.5 h-1.5 rounded-full bg-neutral-400\" />\n {service.name}\n </button>\n );\n}\n\nexport function ServiceConnectionsCard({\n services,\n className = \"\",\n}: ServiceConnectionsProps): React.ReactElement | null {\n const { status, loading } = useIntegrationStatus();\n\n if (loading) return null;\n\n const disconnectedServices = withStatus(services, status).filter((service) => !service.connected);\n if (disconnectedServices.length === 0) return null;\n\n return (\n <div\n className={`rounded-lg border border-amber-200 dark:border-amber-900/50 bg-amber-50 dark:bg-amber-900/20 p-4 ${className}`}\n >\n <h3 className=\"font-medium text-amber-900 dark:text-amber-200 mb-2\">\n Connect your services\n </h3>\n <p className=\"text-sm text-amber-700 dark:text-amber-300/80 mb-3\">\n Connect the following services to unlock all features:\n </p>\n <div className=\"flex flex-wrap gap-2\">\n {disconnectedServices.map((service) => (\n <a\n key={service.id}\n href={service.authUrl}\n className=\"inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium bg-amber-100 text-amber-800 hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60 transition-colors\"\n >\n Connect {service.name}\n </a>\n ))}\n </div>\n </div>\n );\n}\n",
|
|
100
100
|
"app/page.tsx": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { Chat, useChat } from 'veryfront/chat'\n\ninterface Integration {\n id: string\n name: string\n connected: boolean\n connectUrl: string\n}\n\nexport default function ChatPage(): React.ReactElement {\n const chat = useChat({ api: '/api/ag-ui' })\n\n return (\n <div className=\"flex flex-col h-screen bg-white dark:bg-neutral-900\">\n <header className=\"sticky top-0 z-10 flex-shrink-0 border-b border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900\">\n <div className=\"px-4 py-3 flex items-center justify-between\">\n <h1 className=\"font-medium text-neutral-900 dark:text-white\">AI Agent</h1>\n <div className=\"flex items-center gap-4\">\n <ServiceStatusFromAPI />\n <a\n href=\"/setup\"\n className=\"text-sm text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200\"\n >\n Setup\n </a>\n </div>\n </div>\n </header>\n\n <Chat {...chat} className=\"flex-1 min-h-0\" placeholder=\"Message\" />\n </div>\n )\n}\n\nfunction ServiceStatusFromAPI(): React.ReactElement | null {\n const [integrations, setIntegrations] = useState<Integration[]>([])\n const [loading, setLoading] = useState<boolean>(true)\n\n useEffect((): void => {\n async function fetchStatus(): Promise<void> {\n try {\n const res = await fetch('/api/integrations/status')\n if (!res.ok) return\n\n const data = await res.json()\n setIntegrations(data.integrations ?? [])\n } catch (error) {\n console.error('Failed to fetch integration status:', error)\n } finally {\n setLoading(false)\n }\n }\n\n void fetchStatus()\n }, [])\n\n if (loading) {\n return (\n <div className=\"flex items-center gap-2\">\n <div className=\"animate-pulse h-6 w-24 bg-neutral-200 dark:bg-neutral-700 rounded-full\" />\n </div>\n )\n }\n\n if (integrations.length === 0) return null\n\n const connected: Integration[] = []\n const disconnected: Integration[] = []\n\n for (const integration of integrations) {\n if (integration.connected) connected.push(integration)\n else disconnected.push(integration)\n }\n\n return (\n <div className=\"flex items-center gap-2\">\n {connected.map(service => (\n <span\n key={service.id}\n className=\"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400\"\n title={`${service.name} connected`}\n >\n <span className=\"w-1.5 h-1.5 rounded-full bg-green-500\" />\n {service.name}\n </span>\n ))}\n\n {disconnected.map(service => (\n <a\n key={service.id}\n href={service.connectUrl}\n className=\"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700 transition-colors\"\n title={`Connect ${service.name}`}\n >\n <span className=\"w-1.5 h-1.5 rounded-full bg-neutral-400\" />\n {service.name}\n </a>\n ))}\n\n {disconnected.length > 0 && (\n <span className=\"text-xs text-neutral-500 dark:text-neutral-400\">\n {connected.length}/{integrations.length}\n </span>\n )}\n </div>\n )\n}\n",
|
|
101
|
-
"app/setup/page-helpers.tsx": "import type { JSX } from \"react\";\n\nexport interface Integration {\n id: string;\n name: string;\n icon: string;\n connected: boolean;\n connectUrl: string;\n}\n\nexport interface SetupStep {\n id: string;\n title: string;\n description: string;\n completed: boolean;\n action?: () => void;\n link?: string;\n}\n\ninterface SetupGuide {\n title: string;\n steps: string[];\n link: string;\n envVars: string[];\n category: string;\n}\n\nexport interface TokenStorageStatus {\n mode: \"memory\" | \"database\" | \"kv\" | \"redis\" | \"custom\";\n encrypted: boolean;\n autoGenerated?: boolean;\n}\n\nexport type TokenStorageStyles = {\n container: string;\n iconWrapper: string;\n title: string;\n text: string;\n isMemory: boolean;\n};\n\nexport const CATEGORIES = [\n { id: \"google\", name: \"Google Services\", icon: \"google\" },\n { id: \"microsoft\", name: \"Microsoft Services\", icon: \"microsoft\" },\n { id: \"atlassian\", name: \"Atlassian\", icon: \"atlassian\" },\n { id: \"communication\", name: \"Communication\", icon: \"chat\" },\n { id: \"development\", name: \"Development\", icon: \"code\" },\n { id: \"productivity\", name: \"Productivity\", icon: \"tasks\" },\n { id: \"storage\", name: \"Storage\", icon: \"folder\" },\n { id: \"infrastructure\", name: \"Infrastructure\", icon: \"server\" },\n { id: \"sales\", name: \"Sales & CRM\", icon: \"users\" },\n { id: \"support\", name: \"Support\", icon: \"headset\" },\n { id: \"finance\", name: \"Finance\", icon: \"dollar\" },\n { id: \"marketing\", name: \"Marketing\", icon: \"megaphone\" },\n { id: \"design\", name: \"Design\", icon: \"palette\" },\n { id: \"ai\", name: \"AI Providers\", icon: \"brain\" },\n] as const;\n\nexport const OAUTH_SETUP_GUIDES: Record<string, SetupGuide> = {\n gmail: {\n title: \"Google OAuth Setup (Gmail)\",\n category: \"google\",\n steps: [\n \"Go to Google Cloud Console\",\n \"Create a new project or select existing one\",\n \"Enable Gmail API in APIs & Services > Library\",\n \"Go to APIs & Services > Credentials\",\n \"Create OAuth 2.0 credentials (Web application)\",\n \"Add redirect URI: http://localhost:3000/api/auth/gmail/callback\",\n \"Copy Client ID and Secret to your .env file\",\n ],\n link: \"https://console.cloud.google.com/apis/credentials\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n calendar: {\n title: \"Google Calendar Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials as Gmail\",\n \"Enable Calendar API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/calendar/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/calendar-json.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n drive: {\n title: \"Google Drive Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Drive API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/drive/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/drive.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n sheets: {\n title: \"Google Sheets Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Sheets API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/sheets/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/sheets.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n \"docs-google\": {\n title: \"Google Docs Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Docs API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/docs-google/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/docs.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n outlook: {\n title: \"Microsoft Outlook Setup\",\n category: \"microsoft\",\n steps: [\n \"Go to Azure Portal > Azure Active Directory\",\n \"Click App registrations > New registration\",\n \"Set redirect URI: http://localhost:3000/api/auth/outlook/callback\",\n \"Go to API permissions > Add Microsoft Graph permissions\",\n \"Add: Mail.Read, Mail.Send, Mail.ReadWrite\",\n \"Go to Certificates & secrets > New client secret\",\n \"Copy Application ID and Secret to .env\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n teams: {\n title: \"Microsoft Teams Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials as Outlook\",\n \"Add Teams permissions: Chat.Read, Chat.ReadWrite, Channel.ReadBasic.All\",\n \"Add redirect URI: http://localhost:3000/api/auth/teams/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n onedrive: {\n title: \"Microsoft OneDrive Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials\",\n \"Add permissions: Files.Read, Files.ReadWrite\",\n \"Add redirect URI: http://localhost:3000/api/auth/onedrive/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n sharepoint: {\n title: \"Microsoft SharePoint Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials\",\n \"Add permissions: Sites.Read.All, Sites.ReadWrite.All\",\n \"Add redirect URI: http://localhost:3000/api/auth/sharepoint/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n jira: {\n title: \"Atlassian Jira Setup\",\n category: \"atlassian\",\n steps: [\n \"Go to Atlassian Developer Console\",\n \"Click Create > OAuth 2.0 integration\",\n \"Add Jira API scopes: read:jira-work, write:jira-work\",\n \"Set callback URL: http://localhost:3000/api/auth/jira/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.atlassian.com/console/myapps/\",\n envVars: [\"ATLASSIAN_CLIENT_ID\", \"ATLASSIAN_CLIENT_SECRET\"],\n },\n confluence: {\n title: \"Atlassian Confluence Setup\",\n category: \"atlassian\",\n steps: [\n \"Uses same Atlassian OAuth credentials as Jira\",\n \"Add Confluence scopes: read:confluence-content.all, write:confluence-content\",\n \"Add callback URL: http://localhost:3000/api/auth/confluence/callback\",\n ],\n link: \"https://developer.atlassian.com/console/myapps/\",\n envVars: [\"ATLASSIAN_CLIENT_ID\", \"ATLASSIAN_CLIENT_SECRET\"],\n },\n bitbucket: {\n title: \"Atlassian Bitbucket Setup\",\n category: \"atlassian\",\n steps: [\n \"Go to Bitbucket Settings > OAuth consumers\",\n \"Click Add consumer\",\n \"Set callback URL: http://localhost:3000/api/auth/bitbucket/callback\",\n \"Add permissions: repository:read, repository:write\",\n \"Copy Key and Secret to .env\",\n ],\n link: \"https://bitbucket.org/account/settings/app-passwords/\",\n envVars: [\"BITBUCKET_CLIENT_ID\", \"BITBUCKET_CLIENT_SECRET\"],\n },\n slack: {\n title: \"Slack App Setup\",\n category: \"communication\",\n steps: [\n \"Go to Slack API Apps page\",\n \"Click Create New App > From scratch\",\n \"Go to OAuth & Permissions\",\n \"Add scopes: channels:read, chat:write, users:read, channels:history\",\n \"Add redirect URL: http://localhost:3000/api/auth/slack/callback\",\n \"Install to Workspace\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://api.slack.com/apps\",\n envVars: [\"SLACK_CLIENT_ID\", \"SLACK_CLIENT_SECRET\"],\n },\n discord: {\n title: \"Discord App Setup\",\n category: \"communication\",\n steps: [\n \"Go to Discord Developer Portal\",\n \"Click New Application\",\n \"Go to OAuth2 section\",\n \"Add redirect: http://localhost:3000/api/auth/discord/callback\",\n \"Copy Client ID and Secret to .env\",\n \"Add bot permissions as needed\",\n ],\n link: \"https://discord.com/developers/applications\",\n envVars: [\"DISCORD_CLIENT_ID\", \"DISCORD_CLIENT_SECRET\"],\n },\n zoom: {\n title: \"Zoom App Setup\",\n category: \"communication\",\n steps: [\n \"Go to Zoom App Marketplace\",\n \"Click Develop > Build App\",\n \"Choose OAuth app type\",\n \"Add redirect URL: http://localhost:3000/api/auth/zoom/callback\",\n \"Add scopes: meeting:read, meeting:write, user:read\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://marketplace.zoom.us/develop/create\",\n envVars: [\"ZOOM_CLIENT_ID\", \"ZOOM_CLIENT_SECRET\"],\n },\n webex: {\n title: \"Webex Integration Setup\",\n category: \"communication\",\n steps: [\n \"Go to Webex Developer Portal\",\n \"Create a new integration\",\n \"Add redirect URI: http://localhost:3000/api/auth/webex/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.webex.com/my-apps\",\n envVars: [\"WEBEX_CLIENT_ID\", \"WEBEX_CLIENT_SECRET\"],\n },\n twilio: {\n title: \"Twilio Setup\",\n category: \"communication\",\n steps: [\n \"Go to Twilio Console\",\n \"Copy Account SID and Auth Token\",\n \"Get a phone number for SMS\",\n \"Add credentials to .env\",\n ],\n link: \"https://console.twilio.com/\",\n envVars: [\"TWILIO_ACCOUNT_SID\", \"TWILIO_AUTH_TOKEN\", \"TWILIO_PHONE_NUMBER\"],\n },\n github: {\n title: \"GitHub OAuth App Setup\",\n category: \"development\",\n steps: [\n \"Go to GitHub Developer Settings\",\n \"Click OAuth Apps > New OAuth App\",\n \"Set Homepage URL: http://localhost:3000\",\n \"Set callback URL: http://localhost:3000/api/auth/github/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://github.com/settings/developers\",\n envVars: [\"GITHUB_CLIENT_ID\", \"GITHUB_CLIENT_SECRET\"],\n },\n gitlab: {\n title: \"GitLab OAuth Setup\",\n category: \"development\",\n steps: [\n \"Go to GitLab User Settings > Applications\",\n \"Create new application\",\n \"Add redirect URI: http://localhost:3000/api/auth/gitlab/callback\",\n \"Select scopes: api, read_user, read_repository\",\n \"Copy Application ID and Secret to .env\",\n ],\n link: \"https://gitlab.com/-/profile/applications\",\n envVars: [\"GITLAB_CLIENT_ID\", \"GITLAB_CLIENT_SECRET\"],\n },\n sentry: {\n title: \"Sentry Setup\",\n category: \"development\",\n steps: [\n \"Go to Sentry Settings > Developer Settings\",\n \"Create new integration\",\n \"Add redirect URL: http://localhost:3000/api/auth/sentry/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://sentry.io/settings/developer-settings/\",\n envVars: [\"SENTRY_CLIENT_ID\", \"SENTRY_CLIENT_SECRET\"],\n },\n posthog: {\n title: \"PostHog Setup\",\n category: \"development\",\n steps: [\"Go to PostHog Project Settings\", \"Copy your Project API Key\", \"Add to .env file\"],\n link: \"https://app.posthog.com/project/settings\",\n envVars: [\"POSTHOG_API_KEY\", \"POSTHOG_HOST\"],\n },\n mixpanel: {\n title: \"Mixpanel Setup\",\n category: \"development\",\n steps: [\n \"Go to Mixpanel Project Settings\",\n \"Copy your Project Token\",\n \"For API access, create a Service Account\",\n \"Add credentials to .env\",\n ],\n link: \"https://mixpanel.com/settings/project\",\n envVars: [\"MIXPANEL_TOKEN\", \"MIXPANEL_API_SECRET\"],\n },\n notion: {\n title: \"Notion Integration Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Notion Integrations page\",\n \"Click New integration\",\n \"Name your integration and select workspace\",\n \"Copy the Internal Integration Token\",\n \"Share desired pages/databases with your integration\",\n \"Add token to .env\",\n ],\n link: \"https://www.notion.so/my-integrations\",\n envVars: [\"NOTION_API_KEY\"],\n },\n linear: {\n title: \"Linear OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Linear Settings > API\",\n \"Create new OAuth application\",\n \"Add redirect URI: http://localhost:3000/api/auth/linear/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://linear.app/settings/api\",\n envVars: [\"LINEAR_CLIENT_ID\", \"LINEAR_CLIENT_SECRET\"],\n },\n asana: {\n title: \"Asana OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Asana Developer Console\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/asana/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.asana.com/0/developer-console\",\n envVars: [\"ASANA_CLIENT_ID\", \"ASANA_CLIENT_SECRET\"],\n },\n trello: {\n title: \"Trello Power-Up Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Trello Power-Ups Admin\",\n \"Create new Power-Up\",\n \"Add redirect URI: http://localhost:3000/api/auth/trello/callback\",\n \"Copy API Key and Secret to .env\",\n ],\n link: \"https://trello.com/power-ups/admin\",\n envVars: [\"TRELLO_API_KEY\", \"TRELLO_API_SECRET\"],\n },\n monday: {\n title: \"Monday.com App Setup\",\n category: \"productivity\",\n steps: [\n \"Go to monday.com Developers\",\n \"Create new app\",\n \"Add OAuth redirect: http://localhost:3000/api/auth/monday/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://monday.com/developers/apps\",\n envVars: [\"MONDAY_CLIENT_ID\", \"MONDAY_CLIENT_SECRET\"],\n },\n clickup: {\n title: \"ClickUp OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to ClickUp Settings > Apps\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/clickup/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.clickup.com/settings/apps\",\n envVars: [\"CLICKUP_CLIENT_ID\", \"CLICKUP_CLIENT_SECRET\"],\n },\n dropbox: {\n title: \"Dropbox App Setup\",\n category: \"storage\",\n steps: [\n \"Go to Dropbox App Console\",\n \"Create new app\",\n \"Choose Scoped access and Full Dropbox\",\n \"Add redirect URI: http://localhost:3000/api/auth/dropbox/callback\",\n \"Copy App Key and Secret to .env\",\n ],\n link: \"https://www.dropbox.com/developers/apps\",\n envVars: [\"DROPBOX_CLIENT_ID\", \"DROPBOX_CLIENT_SECRET\"],\n },\n box: {\n title: \"Box App Setup\",\n category: \"storage\",\n steps: [\n \"Go to Box Developer Console\",\n \"Create new app with OAuth 2.0\",\n \"Add redirect URI: http://localhost:3000/api/auth/box/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.box.com/developers/console\",\n envVars: [\"BOX_CLIENT_ID\", \"BOX_CLIENT_SECRET\"],\n },\n airtable: {\n title: \"Airtable OAuth Setup\",\n category: \"storage\",\n steps: [\n \"Go to Airtable Developer Hub\",\n \"Create new OAuth integration\",\n \"Add redirect URI: http://localhost:3000/api/auth/airtable/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://airtable.com/create/oauth\",\n envVars: [\"AIRTABLE_CLIENT_ID\", \"AIRTABLE_CLIENT_SECRET\"],\n },\n supabase: {\n title: \"Supabase Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Supabase Dashboard\",\n \"Create new project or select existing\",\n \"Go to Settings > API\",\n \"Copy Project URL and anon/service_role keys\",\n \"Add to .env file\",\n ],\n link: \"https://supabase.com/dashboard\",\n envVars: [\"SUPABASE_URL\", \"SUPABASE_ANON_KEY\", \"SUPABASE_SERVICE_ROLE_KEY\"],\n },\n neon: {\n title: \"Neon Database Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Neon Console\",\n \"Create new project\",\n \"Copy connection string from Dashboard\",\n \"Add to .env file\",\n ],\n link: \"https://console.neon.tech/\",\n envVars: [\"DATABASE_URL\"],\n },\n snowflake: {\n title: \"Snowflake Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Snowflake Console\",\n \"Create a service account or use existing credentials\",\n \"Note your account identifier, warehouse, database\",\n \"Add credentials to .env\",\n ],\n link: \"https://app.snowflake.com/\",\n envVars: [\"SNOWFLAKE_ACCOUNT\", \"SNOWFLAKE_USER\", \"SNOWFLAKE_PASSWORD\", \"SNOWFLAKE_WAREHOUSE\"],\n },\n aws: {\n title: \"AWS Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to AWS IAM Console\",\n \"Create new IAM user with programmatic access\",\n \"Attach required policies (S3, Lambda, DynamoDB)\",\n \"Copy Access Key ID and Secret\",\n \"Add to .env file\",\n ],\n link: \"https://console.aws.amazon.com/iam/\",\n envVars: [\"AWS_ACCESS_KEY_ID\", \"AWS_SECRET_ACCESS_KEY\", \"AWS_REGION\"],\n },\n salesforce: {\n title: \"Salesforce Connected App Setup\",\n category: \"sales\",\n steps: [\n \"Go to Salesforce Setup > App Manager\",\n \"Create new Connected App\",\n \"Enable OAuth Settings\",\n \"Add callback URL: http://localhost:3000/api/auth/salesforce/callback\",\n \"Select OAuth scopes: api, refresh_token\",\n \"Copy Consumer Key and Secret to .env\",\n ],\n link: \"https://login.salesforce.com/\",\n envVars: [\"SALESFORCE_CLIENT_ID\", \"SALESFORCE_CLIENT_SECRET\"],\n },\n hubspot: {\n title: \"HubSpot App Setup\",\n category: \"sales\",\n steps: [\n \"Go to HubSpot Developer Portal\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/hubspot/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.hubspot.com/\",\n envVars: [\"HUBSPOT_CLIENT_ID\", \"HUBSPOT_CLIENT_SECRET\"],\n },\n pipedrive: {\n title: \"Pipedrive OAuth Setup\",\n category: \"sales\",\n steps: [\n \"Go to Pipedrive Developer Hub\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/pipedrive/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.pipedrive.com/\",\n envVars: [\"PIPEDRIVE_CLIENT_ID\", \"PIPEDRIVE_CLIENT_SECRET\"],\n },\n zendesk: {\n title: \"Zendesk OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Zendesk Admin > API > OAuth Clients\",\n \"Add new OAuth client\",\n \"Set redirect URI: http://localhost:3000/api/auth/zendesk/callback\",\n \"Copy Client ID and Secret to .env\",\n \"Add your Zendesk subdomain\",\n ],\n link: \"https://support.zendesk.com/hc/en-us/articles/4408845965210\",\n envVars: [\"ZENDESK_CLIENT_ID\", \"ZENDESK_CLIENT_SECRET\", \"ZENDESK_SUBDOMAIN\"],\n },\n intercom: {\n title: \"Intercom OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Intercom Developer Hub\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/intercom/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.intercom.com/\",\n envVars: [\"INTERCOM_CLIENT_ID\", \"INTERCOM_CLIENT_SECRET\"],\n },\n freshdesk: {\n title: \"Freshdesk OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Freshdesk Admin > Apps > Custom Apps\",\n \"Create new OAuth application\",\n \"Add redirect URI: http://localhost:3000/api/auth/freshdesk/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.freshdesk.com/\",\n envVars: [\"FRESHDESK_CLIENT_ID\", \"FRESHDESK_CLIENT_SECRET\", \"FRESHDESK_DOMAIN\"],\n },\n servicenow: {\n title: \"ServiceNow OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to ServiceNow System OAuth > Application Registry\",\n \"Create OAuth API endpoint for external clients\",\n \"Add redirect URL: http://localhost:3000/api/auth/servicenow/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://docs.servicenow.com/\",\n envVars: [\"SERVICENOW_CLIENT_ID\", \"SERVICENOW_CLIENT_SECRET\", \"SERVICENOW_INSTANCE\"],\n },\n stripe: {\n title: \"Stripe Setup\",\n category: \"finance\",\n steps: [\n \"Go to Stripe Dashboard\",\n \"Go to Developers > API keys\",\n \"Copy Publishable and Secret keys\",\n \"For Connect, set up OAuth in Connect settings\",\n \"Add to .env file\",\n ],\n link: \"https://dashboard.stripe.com/apikeys\",\n envVars: [\"STRIPE_SECRET_KEY\", \"STRIPE_PUBLISHABLE_KEY\"],\n },\n quickbooks: {\n title: \"QuickBooks OAuth Setup\",\n category: \"finance\",\n steps: [\n \"Go to Intuit Developer Portal\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/quickbooks/callback\",\n \"Select Accounting scope\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.intuit.com/app/developer/dashboard\",\n envVars: [\"QUICKBOOKS_CLIENT_ID\", \"QUICKBOOKS_CLIENT_SECRET\"],\n },\n xero: {\n title: \"Xero OAuth Setup\",\n category: \"finance\",\n steps: [\n \"Go to Xero Developer Portal\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/xero/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.xero.com/app/manage\",\n envVars: [\"XERO_CLIENT_ID\", \"XERO_CLIENT_SECRET\"],\n },\n mailchimp: {\n title: \"Mailchimp OAuth Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Mailchimp Developer Portal\",\n \"Register new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/mailchimp/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://admin.mailchimp.com/account/oauth2/\",\n envVars: [\"MAILCHIMP_CLIENT_ID\", \"MAILCHIMP_CLIENT_SECRET\"],\n },\n shopify: {\n title: \"Shopify App Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Shopify Partners Dashboard\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/shopify/callback\",\n \"Copy API Key and Secret to .env\",\n ],\n link: \"https://partners.shopify.com/\",\n envVars: [\"SHOPIFY_API_KEY\", \"SHOPIFY_API_SECRET\"],\n },\n twitter: {\n title: \"Twitter/X OAuth Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Twitter Developer Portal\",\n \"Create new project and app\",\n \"Enable OAuth 2.0\",\n \"Add redirect URI: http://localhost:3000/api/auth/twitter/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.twitter.com/en/portal/dashboard\",\n envVars: [\"TWITTER_CLIENT_ID\", \"TWITTER_CLIENT_SECRET\"],\n },\n figma: {\n title: \"Figma OAuth Setup\",\n category: \"design\",\n steps: [\n \"Go to Figma Developer Settings\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/figma/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://www.figma.com/developers/apps\",\n envVars: [\"FIGMA_CLIENT_ID\", \"FIGMA_CLIENT_SECRET\"],\n },\n anthropic: {\n title: \"Anthropic API Setup\",\n category: \"ai\",\n steps: [\"Go to Anthropic Console\", \"Create new API key\", \"Copy API key to .env\"],\n link: \"https://console.anthropic.com/\",\n envVars: [\"ANTHROPIC_API_KEY\"],\n },\n};\n\nexport function filterIntegrations(\n integrations: Integration[],\n searchQuery: string,\n selectedCategory: string | null,\n): Integration[] {\n const query = searchQuery.toLowerCase();\n\n return integrations.filter((integration) => {\n const guide = OAUTH_SETUP_GUIDES[integration.id];\n\n const matchesSearch =\n query === \"\" ||\n integration.name.toLowerCase().includes(query) ||\n integration.id.toLowerCase().includes(query);\n\n const matchesCategory = selectedCategory === null || guide?.category === selectedCategory;\n\n return matchesSearch && matchesCategory;\n });\n}\n\nexport function groupIntegrationsByCategory(\n integrations: Integration[],\n): Record<string, Integration[]> {\n const groups: Record<string, Integration[]> = {};\n\n for (const integration of integrations) {\n const category = OAUTH_SETUP_GUIDES[integration.id]?.category ?? \"other\";\n (groups[category] ??= []).push(integration);\n }\n\n return groups;\n}\n\nexport function buildSetupSteps(\n envChecked: boolean,\n allConnected: boolean,\n markEnvChecked: () => void,\n): SetupStep[] {\n return [\n {\n id: \"env\",\n title: \"Configure Environment Variables\",\n description: \"Add your OAuth credentials to the .env file\",\n completed: envChecked,\n action: markEnvChecked,\n },\n {\n id: \"oauth\",\n title: \"Create OAuth Apps\",\n description: \"Set up OAuth applications for each service\",\n completed: false,\n },\n {\n id: \"connect\",\n title: \"Connect Services\",\n description: \"Authorize your app to access each service\",\n completed: allConnected,\n },\n ];\n}\n\nexport function getTokenStorageStyles(\n tokenStorage: TokenStorageStatus | null,\n): TokenStorageStyles | null {\n if (!tokenStorage) return null;\n\n const isMemory = tokenStorage.mode === \"memory\";\n\n return {\n container: `rounded-2xl p-6 shadow-sm border mb-8 ${\n isMemory\n ? \"bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800\"\n : \"bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800\"\n }`,\n iconWrapper: `w-10 h-10 rounded-full flex items-center justify-center ${\n isMemory ? \"bg-amber-100 dark:bg-amber-900\" : \"bg-green-100 dark:bg-green-900\"\n }`,\n title: `font-semibold ${\n isMemory ? \"text-amber-800 dark:text-amber-200\" : \"text-green-800 dark:text-green-200\"\n }`,\n text: `text-sm mt-1 ${\n isMemory ? \"text-amber-700 dark:text-amber-300\" : \"text-green-700 dark:text-green-300\"\n }`,\n isMemory,\n };\n}\n\nexport function ServiceIcon({ name }: { name: string }): JSX.Element {\n const iconMap: Record<string, JSX.Element> = {\n mail: (\n <svg className=\"w-6 h-6 text-red-500\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n d=\"M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n fill=\"none\"\n />\n </svg>\n ),\n slack: (\n <svg className=\"w-6 h-6\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path\n d=\"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z\"\n fill=\"#E01E5A\"\n />\n <path\n d=\"M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z\"\n fill=\"#36C5F0\"\n />\n <path\n d=\"M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312z\"\n fill=\"#2EB67D\"\n />\n <path\n d=\"M15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z\"\n fill=\"#ECB22E\"\n />\n </svg>\n ),\n calendar: (\n <svg\n className=\"w-6 h-6 text-blue-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"\n />\n </svg>\n ),\n github: (\n <svg className=\"w-6 h-6\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n fillRule=\"evenodd\"\n d=\"M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z\"\n clipRule=\"evenodd\"\n />\n </svg>\n ),\n jira: (\n <svg className=\"w-6 h-6\" viewBox=\"0 0 24 24\">\n <defs>\n <linearGradient id=\"jira-gradient\" x1=\"98.031%\" x2=\"58.888%\" y1=\".161%\" y2=\"40.766%\">\n <stop offset=\"0%\" stopColor=\"#0052CC\" />\n <stop offset=\"100%\" stopColor=\"#2684FF\" />\n </linearGradient>\n </defs>\n <path\n fill=\"url(#jira-gradient)\"\n d=\"M11.571 11.513H0a5.218 5.218 0 005.232 5.215h2.13v2.057A5.215 5.215 0 0012.575 24V12.518a1.005 1.005 0 00-1.005-1.005z\"\n />\n <path\n fill=\"#2684FF\"\n d=\"M17.151 5.97H5.58a5.215 5.215 0 005.215 5.214h2.129v2.058a5.218 5.218 0 005.232 5.215V6.975a1.005 1.005 0 00-1.005-1.005z\"\n />\n <path\n fill=\"#2684FF\"\n d=\"M22.723.426H11.152a5.215 5.215 0 005.215 5.215h2.129v2.057a5.218 5.218 0 005.232 5.215V1.431a1.005 1.005 0 00-1.005-1.005z\"\n />\n </svg>\n ),\n notion: (\n <svg className=\"w-6 h-6\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.98-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466l1.823 1.447zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.84-.046.933-.56.933-1.167V6.354c0-.606-.233-.933-.746-.886l-15.177.887c-.56.046-.747.326-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.746 0-.933-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.14c-.093-.514.28-.886.747-.933l3.222-.186zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.933.653.933 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.448-1.632z\" />\n </svg>\n ),\n default: (\n <svg\n className=\"w-6 h-6 text-neutral-400\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M13 10V3L4 14h7v7l9-11h-7z\"\n />\n </svg>\n ),\n };\n\n return iconMap[name] ?? iconMap.default;\n}\n",
|
|
101
|
+
"app/setup/page-helpers.tsx": "import type { JSX } from \"react\";\n\nexport interface Integration {\n id: string;\n name: string;\n icon: string;\n connected: boolean;\n connectUrl: string;\n}\n\nexport interface SetupStep {\n id: string;\n title: string;\n description: string;\n completed: boolean;\n action?: () => void;\n link?: string;\n}\n\ninterface SetupGuide {\n title: string;\n steps: string[];\n link: string;\n envVars: string[];\n category: string;\n}\n\nexport interface TokenStorageStatus {\n mode: \"memory\" | \"database\" | \"kv\" | \"redis\" | \"custom\";\n encrypted: boolean;\n autoGenerated?: boolean;\n}\n\nexport type TokenStorageStyles = {\n container: string;\n iconWrapper: string;\n title: string;\n text: string;\n isMemory: boolean;\n};\n\nexport const CATEGORIES = [\n { id: \"google\", name: \"Google Services\", icon: \"google\" },\n { id: \"microsoft\", name: \"Microsoft Services\", icon: \"microsoft\" },\n { id: \"atlassian\", name: \"Atlassian\", icon: \"atlassian\" },\n { id: \"communication\", name: \"Communication\", icon: \"chat\" },\n { id: \"development\", name: \"Development\", icon: \"code\" },\n { id: \"productivity\", name: \"Productivity\", icon: \"tasks\" },\n { id: \"storage\", name: \"Storage\", icon: \"folder\" },\n { id: \"infrastructure\", name: \"Infrastructure\", icon: \"server\" },\n { id: \"sales\", name: \"Sales & CRM\", icon: \"users\" },\n { id: \"support\", name: \"Support\", icon: \"headset\" },\n { id: \"finance\", name: \"Finance\", icon: \"dollar\" },\n { id: \"marketing\", name: \"Marketing\", icon: \"megaphone\" },\n { id: \"design\", name: \"Design\", icon: \"palette\" },\n { id: \"ai\", name: \"AI Providers\", icon: \"brain\" },\n] as const;\n\nexport const OAUTH_SETUP_GUIDES: Record<string, SetupGuide> = {\n gmail: {\n title: \"Google OAuth Setup (Gmail)\",\n category: \"google\",\n steps: [\n \"Go to Google Cloud Console\",\n \"Create a new project or select existing one\",\n \"Enable Gmail API in APIs & Services > Library\",\n \"Go to APIs & Services > Credentials\",\n \"Create OAuth 2.0 credentials (Web application)\",\n \"Add redirect URI: http://localhost:3000/api/auth/gmail/callback\",\n \"Copy Client ID and Secret to your .env file\",\n ],\n link: \"https://console.cloud.google.com/apis/credentials\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n calendar: {\n title: \"Google Calendar Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials as Gmail\",\n \"Enable Calendar API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/calendar/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/calendar-json.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n drive: {\n title: \"Google Drive Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Drive API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/drive/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/drive.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n sheets: {\n title: \"Google Sheets Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Sheets API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/sheets/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/sheets.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n \"docs-google\": {\n title: \"Google Docs Setup\",\n category: \"google\",\n steps: [\n \"Uses same Google OAuth credentials\",\n \"Enable Docs API in Google Cloud Console\",\n \"Add redirect URI: http://localhost:3000/api/auth/docs-google/callback\",\n ],\n link: \"https://console.cloud.google.com/apis/library/docs.googleapis.com\",\n envVars: [\"GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_SECRET\"],\n },\n outlook: {\n title: \"Microsoft Outlook Setup\",\n category: \"microsoft\",\n steps: [\n \"Go to Azure Portal > Azure Active Directory\",\n \"Click App registrations > New registration\",\n \"Set redirect URI: http://localhost:3000/api/auth/outlook/callback\",\n \"Go to API permissions > Add Microsoft Graph permissions\",\n \"Add: Mail.Read, Mail.Send, Mail.ReadWrite\",\n \"Go to Certificates & secrets > New client secret\",\n \"Copy Application ID and Secret to .env\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n teams: {\n title: \"Microsoft Teams Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials as Outlook\",\n \"Add Teams permissions: Chat.Read, Chat.ReadWrite, Channel.ReadBasic.All\",\n \"Add redirect URI: http://localhost:3000/api/auth/teams/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n onedrive: {\n title: \"Microsoft OneDrive Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials\",\n \"Add permissions: Files.Read, Files.ReadWrite\",\n \"Add redirect URI: http://localhost:3000/api/auth/onedrive/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n sharepoint: {\n title: \"Microsoft SharePoint Setup\",\n category: \"microsoft\",\n steps: [\n \"Uses same Microsoft OAuth credentials\",\n \"Add permissions: Sites.Read.All, Sites.ReadWrite.All\",\n \"Add redirect URI: http://localhost:3000/api/auth/sharepoint/callback\",\n ],\n link: \"https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade\",\n envVars: [\"MICROSOFT_CLIENT_ID\", \"MICROSOFT_CLIENT_SECRET\"],\n },\n jira: {\n title: \"Atlassian Jira Setup\",\n category: \"atlassian\",\n steps: [\n \"Go to Atlassian Developer Console\",\n \"Click Create > OAuth 2.0 integration\",\n \"Add Jira API scopes: read:jira-work, write:jira-work\",\n \"Set callback URL: http://localhost:3000/api/auth/jira/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.atlassian.com/console/myapps/\",\n envVars: [\"ATLASSIAN_CLIENT_ID\", \"ATLASSIAN_CLIENT_SECRET\"],\n },\n confluence: {\n title: \"Atlassian Confluence Setup\",\n category: \"atlassian\",\n steps: [\n \"Uses same Atlassian OAuth credentials as Jira\",\n \"Add Confluence scopes: read:confluence-content.all, write:confluence-content\",\n \"Add callback URL: http://localhost:3000/api/auth/confluence/callback\",\n ],\n link: \"https://developer.atlassian.com/console/myapps/\",\n envVars: [\"ATLASSIAN_CLIENT_ID\", \"ATLASSIAN_CLIENT_SECRET\"],\n },\n bitbucket: {\n title: \"Atlassian Bitbucket Setup\",\n category: \"atlassian\",\n steps: [\n \"Go to Bitbucket Settings > OAuth consumers\",\n \"Click Add consumer\",\n \"Set callback URL: http://localhost:3000/api/auth/bitbucket/callback\",\n \"Add permissions: repository:read, repository:write\",\n \"Copy Key and Secret to .env\",\n ],\n link: \"https://bitbucket.org/account/settings/app-passwords/\",\n envVars: [\"BITBUCKET_CLIENT_ID\", \"BITBUCKET_CLIENT_SECRET\"],\n },\n slack: {\n title: \"Slack App Setup\",\n category: \"communication\",\n steps: [\n \"Go to Slack API Apps page\",\n \"Click Create New App > From scratch\",\n \"Go to OAuth & Permissions\",\n \"Add scopes: channels:history, channels:read, chat:write, groups:history, groups:read, im:history, im:read, mpim:history, mpim:read, users:read\",\n \"Add redirect URL: http://localhost:3000/api/auth/slack/callback\",\n \"Install to Workspace\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://api.slack.com/apps\",\n envVars: [\"SLACK_CLIENT_ID\", \"SLACK_CLIENT_SECRET\"],\n },\n discord: {\n title: \"Discord App Setup\",\n category: \"communication\",\n steps: [\n \"Go to Discord Developer Portal\",\n \"Click New Application\",\n \"Go to OAuth2 section\",\n \"Add redirect: http://localhost:3000/api/auth/discord/callback\",\n \"Copy Client ID and Secret to .env\",\n \"Add bot permissions as needed\",\n ],\n link: \"https://discord.com/developers/applications\",\n envVars: [\"DISCORD_CLIENT_ID\", \"DISCORD_CLIENT_SECRET\"],\n },\n zoom: {\n title: \"Zoom App Setup\",\n category: \"communication\",\n steps: [\n \"Go to Zoom App Marketplace\",\n \"Click Develop > Build App\",\n \"Choose OAuth app type\",\n \"Add redirect URL: http://localhost:3000/api/auth/zoom/callback\",\n \"Add scopes: meeting:read, meeting:write, user:read\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://marketplace.zoom.us/develop/create\",\n envVars: [\"ZOOM_CLIENT_ID\", \"ZOOM_CLIENT_SECRET\"],\n },\n webex: {\n title: \"Webex Integration Setup\",\n category: \"communication\",\n steps: [\n \"Go to Webex Developer Portal\",\n \"Create a new integration\",\n \"Add redirect URI: http://localhost:3000/api/auth/webex/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.webex.com/my-apps\",\n envVars: [\"WEBEX_CLIENT_ID\", \"WEBEX_CLIENT_SECRET\"],\n },\n twilio: {\n title: \"Twilio Setup\",\n category: \"communication\",\n steps: [\n \"Go to Twilio Console\",\n \"Copy Account SID and Auth Token\",\n \"Get a phone number for SMS\",\n \"Add credentials to .env\",\n ],\n link: \"https://console.twilio.com/\",\n envVars: [\"TWILIO_ACCOUNT_SID\", \"TWILIO_AUTH_TOKEN\", \"TWILIO_PHONE_NUMBER\"],\n },\n github: {\n title: \"GitHub OAuth App Setup\",\n category: \"development\",\n steps: [\n \"Go to GitHub Developer Settings\",\n \"Click OAuth Apps > New OAuth App\",\n \"Set Homepage URL: http://localhost:3000\",\n \"Set callback URL: http://localhost:3000/api/auth/github/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://github.com/settings/developers\",\n envVars: [\"GITHUB_CLIENT_ID\", \"GITHUB_CLIENT_SECRET\"],\n },\n gitlab: {\n title: \"GitLab OAuth Setup\",\n category: \"development\",\n steps: [\n \"Go to GitLab User Settings > Applications\",\n \"Create new application\",\n \"Add redirect URI: http://localhost:3000/api/auth/gitlab/callback\",\n \"Select scopes: api, read_user, read_repository\",\n \"Copy Application ID and Secret to .env\",\n ],\n link: \"https://gitlab.com/-/profile/applications\",\n envVars: [\"GITLAB_CLIENT_ID\", \"GITLAB_CLIENT_SECRET\"],\n },\n sentry: {\n title: \"Sentry Setup\",\n category: \"development\",\n steps: [\n \"Go to Sentry Settings > Developer Settings\",\n \"Create new integration\",\n \"Add redirect URL: http://localhost:3000/api/auth/sentry/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://sentry.io/settings/developer-settings/\",\n envVars: [\"SENTRY_CLIENT_ID\", \"SENTRY_CLIENT_SECRET\"],\n },\n posthog: {\n title: \"PostHog Setup\",\n category: \"development\",\n steps: [\"Go to PostHog Project Settings\", \"Copy your Project API Key\", \"Add to .env file\"],\n link: \"https://app.posthog.com/project/settings\",\n envVars: [\"POSTHOG_API_KEY\", \"POSTHOG_HOST\"],\n },\n mixpanel: {\n title: \"Mixpanel Setup\",\n category: \"development\",\n steps: [\n \"Go to Mixpanel Project Settings\",\n \"Copy your Project Token\",\n \"For API access, create a Service Account\",\n \"Add credentials to .env\",\n ],\n link: \"https://mixpanel.com/settings/project\",\n envVars: [\"MIXPANEL_TOKEN\", \"MIXPANEL_API_SECRET\"],\n },\n notion: {\n title: \"Notion Integration Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Notion Integrations page\",\n \"Click New integration\",\n \"Name your integration and select workspace\",\n \"Copy the Internal Integration Token\",\n \"Share desired pages/databases with your integration\",\n \"Add token to .env\",\n ],\n link: \"https://www.notion.so/my-integrations\",\n envVars: [\"NOTION_API_KEY\"],\n },\n linear: {\n title: \"Linear OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Linear Settings > API\",\n \"Create new OAuth application\",\n \"Add redirect URI: http://localhost:3000/api/auth/linear/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://linear.app/settings/api\",\n envVars: [\"LINEAR_CLIENT_ID\", \"LINEAR_CLIENT_SECRET\"],\n },\n asana: {\n title: \"Asana OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Asana Developer Console\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/asana/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.asana.com/0/developer-console\",\n envVars: [\"ASANA_CLIENT_ID\", \"ASANA_CLIENT_SECRET\"],\n },\n trello: {\n title: \"Trello Power-Up Setup\",\n category: \"productivity\",\n steps: [\n \"Go to Trello Power-Ups Admin\",\n \"Create new Power-Up\",\n \"Add redirect URI: http://localhost:3000/api/auth/trello/callback\",\n \"Copy API Key and Secret to .env\",\n ],\n link: \"https://trello.com/power-ups/admin\",\n envVars: [\"TRELLO_API_KEY\", \"TRELLO_API_SECRET\"],\n },\n monday: {\n title: \"Monday.com App Setup\",\n category: \"productivity\",\n steps: [\n \"Go to monday.com Developers\",\n \"Create new app\",\n \"Add OAuth redirect: http://localhost:3000/api/auth/monday/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://monday.com/developers/apps\",\n envVars: [\"MONDAY_CLIENT_ID\", \"MONDAY_CLIENT_SECRET\"],\n },\n clickup: {\n title: \"ClickUp OAuth Setup\",\n category: \"productivity\",\n steps: [\n \"Go to ClickUp Settings > Apps\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/clickup/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.clickup.com/settings/apps\",\n envVars: [\"CLICKUP_CLIENT_ID\", \"CLICKUP_CLIENT_SECRET\"],\n },\n dropbox: {\n title: \"Dropbox App Setup\",\n category: \"storage\",\n steps: [\n \"Go to Dropbox App Console\",\n \"Create new app\",\n \"Choose Scoped access and Full Dropbox\",\n \"Add redirect URI: http://localhost:3000/api/auth/dropbox/callback\",\n \"Copy App Key and Secret to .env\",\n ],\n link: \"https://www.dropbox.com/developers/apps\",\n envVars: [\"DROPBOX_CLIENT_ID\", \"DROPBOX_CLIENT_SECRET\"],\n },\n box: {\n title: \"Box App Setup\",\n category: \"storage\",\n steps: [\n \"Go to Box Developer Console\",\n \"Create new app with OAuth 2.0\",\n \"Add redirect URI: http://localhost:3000/api/auth/box/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://app.box.com/developers/console\",\n envVars: [\"BOX_CLIENT_ID\", \"BOX_CLIENT_SECRET\"],\n },\n airtable: {\n title: \"Airtable OAuth Setup\",\n category: \"storage\",\n steps: [\n \"Go to Airtable Developer Hub\",\n \"Create new OAuth integration\",\n \"Add redirect URI: http://localhost:3000/api/auth/airtable/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://airtable.com/create/oauth\",\n envVars: [\"AIRTABLE_CLIENT_ID\", \"AIRTABLE_CLIENT_SECRET\"],\n },\n supabase: {\n title: \"Supabase Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Supabase Dashboard\",\n \"Create new project or select existing\",\n \"Go to Settings > API\",\n \"Copy Project URL and anon/service_role keys\",\n \"Add to .env file\",\n ],\n link: \"https://supabase.com/dashboard\",\n envVars: [\"SUPABASE_URL\", \"SUPABASE_ANON_KEY\", \"SUPABASE_SERVICE_ROLE_KEY\"],\n },\n neon: {\n title: \"Neon Database Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Neon Console\",\n \"Create new project\",\n \"Copy connection string from Dashboard\",\n \"Add to .env file\",\n ],\n link: \"https://console.neon.tech/\",\n envVars: [\"DATABASE_URL\"],\n },\n snowflake: {\n title: \"Snowflake Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to Snowflake Console\",\n \"Create a service account or use existing credentials\",\n \"Note your account identifier, warehouse, database\",\n \"Add credentials to .env\",\n ],\n link: \"https://app.snowflake.com/\",\n envVars: [\"SNOWFLAKE_ACCOUNT\", \"SNOWFLAKE_USER\", \"SNOWFLAKE_PASSWORD\", \"SNOWFLAKE_WAREHOUSE\"],\n },\n aws: {\n title: \"AWS Setup\",\n category: \"infrastructure\",\n steps: [\n \"Go to AWS IAM Console\",\n \"Create new IAM user with programmatic access\",\n \"Attach required policies (S3, Lambda, DynamoDB)\",\n \"Copy Access Key ID and Secret\",\n \"Add to .env file\",\n ],\n link: \"https://console.aws.amazon.com/iam/\",\n envVars: [\"AWS_ACCESS_KEY_ID\", \"AWS_SECRET_ACCESS_KEY\", \"AWS_REGION\"],\n },\n salesforce: {\n title: \"Salesforce Connected App Setup\",\n category: \"sales\",\n steps: [\n \"Go to Salesforce Setup > App Manager\",\n \"Create new Connected App\",\n \"Enable OAuth Settings\",\n \"Add callback URL: http://localhost:3000/api/auth/salesforce/callback\",\n \"Select OAuth scopes: api, refresh_token\",\n \"Copy Consumer Key and Secret to .env\",\n ],\n link: \"https://login.salesforce.com/\",\n envVars: [\"SALESFORCE_CLIENT_ID\", \"SALESFORCE_CLIENT_SECRET\"],\n },\n hubspot: {\n title: \"HubSpot App Setup\",\n category: \"sales\",\n steps: [\n \"Go to HubSpot Developer Portal\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/hubspot/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.hubspot.com/\",\n envVars: [\"HUBSPOT_CLIENT_ID\", \"HUBSPOT_CLIENT_SECRET\"],\n },\n pipedrive: {\n title: \"Pipedrive OAuth Setup\",\n category: \"sales\",\n steps: [\n \"Go to Pipedrive Developer Hub\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/pipedrive/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.pipedrive.com/\",\n envVars: [\"PIPEDRIVE_CLIENT_ID\", \"PIPEDRIVE_CLIENT_SECRET\"],\n },\n zendesk: {\n title: \"Zendesk OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Zendesk Admin > API > OAuth Clients\",\n \"Add new OAuth client\",\n \"Set redirect URI: http://localhost:3000/api/auth/zendesk/callback\",\n \"Copy Client ID and Secret to .env\",\n \"Add your Zendesk subdomain\",\n ],\n link: \"https://support.zendesk.com/hc/en-us/articles/4408845965210\",\n envVars: [\"ZENDESK_CLIENT_ID\", \"ZENDESK_CLIENT_SECRET\", \"ZENDESK_SUBDOMAIN\"],\n },\n intercom: {\n title: \"Intercom OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Intercom Developer Hub\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/intercom/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.intercom.com/\",\n envVars: [\"INTERCOM_CLIENT_ID\", \"INTERCOM_CLIENT_SECRET\"],\n },\n freshdesk: {\n title: \"Freshdesk OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to Freshdesk Admin > Apps > Custom Apps\",\n \"Create new OAuth application\",\n \"Add redirect URI: http://localhost:3000/api/auth/freshdesk/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developers.freshdesk.com/\",\n envVars: [\"FRESHDESK_CLIENT_ID\", \"FRESHDESK_CLIENT_SECRET\", \"FRESHDESK_DOMAIN\"],\n },\n servicenow: {\n title: \"ServiceNow OAuth Setup\",\n category: \"support\",\n steps: [\n \"Go to ServiceNow System OAuth > Application Registry\",\n \"Create OAuth API endpoint for external clients\",\n \"Add redirect URL: http://localhost:3000/api/auth/servicenow/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://docs.servicenow.com/\",\n envVars: [\"SERVICENOW_CLIENT_ID\", \"SERVICENOW_CLIENT_SECRET\", \"SERVICENOW_INSTANCE\"],\n },\n stripe: {\n title: \"Stripe Setup\",\n category: \"finance\",\n steps: [\n \"Go to Stripe Dashboard\",\n \"Go to Developers > API keys\",\n \"Copy Publishable and Secret keys\",\n \"For Connect, set up OAuth in Connect settings\",\n \"Add to .env file\",\n ],\n link: \"https://dashboard.stripe.com/apikeys\",\n envVars: [\"STRIPE_SECRET_KEY\", \"STRIPE_PUBLISHABLE_KEY\"],\n },\n quickbooks: {\n title: \"QuickBooks OAuth Setup\",\n category: \"finance\",\n steps: [\n \"Go to Intuit Developer Portal\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/quickbooks/callback\",\n \"Select Accounting scope\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.intuit.com/app/developer/dashboard\",\n envVars: [\"QUICKBOOKS_CLIENT_ID\", \"QUICKBOOKS_CLIENT_SECRET\"],\n },\n xero: {\n title: \"Xero OAuth Setup\",\n category: \"finance\",\n steps: [\n \"Go to Xero Developer Portal\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/xero/callback\",\n \"Select required scopes\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.xero.com/app/manage\",\n envVars: [\"XERO_CLIENT_ID\", \"XERO_CLIENT_SECRET\"],\n },\n mailchimp: {\n title: \"Mailchimp OAuth Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Mailchimp Developer Portal\",\n \"Register new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/mailchimp/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://admin.mailchimp.com/account/oauth2/\",\n envVars: [\"MAILCHIMP_CLIENT_ID\", \"MAILCHIMP_CLIENT_SECRET\"],\n },\n shopify: {\n title: \"Shopify App Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Shopify Partners Dashboard\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/shopify/callback\",\n \"Copy API Key and Secret to .env\",\n ],\n link: \"https://partners.shopify.com/\",\n envVars: [\"SHOPIFY_API_KEY\", \"SHOPIFY_API_SECRET\"],\n },\n twitter: {\n title: \"Twitter/X OAuth Setup\",\n category: \"marketing\",\n steps: [\n \"Go to Twitter Developer Portal\",\n \"Create new project and app\",\n \"Enable OAuth 2.0\",\n \"Add redirect URI: http://localhost:3000/api/auth/twitter/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://developer.twitter.com/en/portal/dashboard\",\n envVars: [\"TWITTER_CLIENT_ID\", \"TWITTER_CLIENT_SECRET\"],\n },\n figma: {\n title: \"Figma OAuth Setup\",\n category: \"design\",\n steps: [\n \"Go to Figma Developer Settings\",\n \"Create new app\",\n \"Add redirect URI: http://localhost:3000/api/auth/figma/callback\",\n \"Copy Client ID and Secret to .env\",\n ],\n link: \"https://www.figma.com/developers/apps\",\n envVars: [\"FIGMA_CLIENT_ID\", \"FIGMA_CLIENT_SECRET\"],\n },\n anthropic: {\n title: \"Anthropic API Setup\",\n category: \"ai\",\n steps: [\"Go to Anthropic Console\", \"Create new API key\", \"Copy API key to .env\"],\n link: \"https://console.anthropic.com/\",\n envVars: [\"ANTHROPIC_API_KEY\"],\n },\n};\n\nexport function filterIntegrations(\n integrations: Integration[],\n searchQuery: string,\n selectedCategory: string | null,\n): Integration[] {\n const query = searchQuery.toLowerCase();\n\n return integrations.filter((integration) => {\n const guide = OAUTH_SETUP_GUIDES[integration.id];\n\n const matchesSearch =\n query === \"\" ||\n integration.name.toLowerCase().includes(query) ||\n integration.id.toLowerCase().includes(query);\n\n const matchesCategory = selectedCategory === null || guide?.category === selectedCategory;\n\n return matchesSearch && matchesCategory;\n });\n}\n\nexport function groupIntegrationsByCategory(\n integrations: Integration[],\n): Record<string, Integration[]> {\n const groups: Record<string, Integration[]> = {};\n\n for (const integration of integrations) {\n const category = OAUTH_SETUP_GUIDES[integration.id]?.category ?? \"other\";\n (groups[category] ??= []).push(integration);\n }\n\n return groups;\n}\n\nexport function buildSetupSteps(\n envChecked: boolean,\n allConnected: boolean,\n markEnvChecked: () => void,\n): SetupStep[] {\n return [\n {\n id: \"env\",\n title: \"Configure Environment Variables\",\n description: \"Add your OAuth credentials to the .env file\",\n completed: envChecked,\n action: markEnvChecked,\n },\n {\n id: \"oauth\",\n title: \"Create OAuth Apps\",\n description: \"Set up OAuth applications for each service\",\n completed: false,\n },\n {\n id: \"connect\",\n title: \"Connect Services\",\n description: \"Authorize your app to access each service\",\n completed: allConnected,\n },\n ];\n}\n\nexport function getTokenStorageStyles(\n tokenStorage: TokenStorageStatus | null,\n): TokenStorageStyles | null {\n if (!tokenStorage) return null;\n\n const isMemory = tokenStorage.mode === \"memory\";\n\n return {\n container: `rounded-2xl p-6 shadow-sm border mb-8 ${\n isMemory\n ? \"bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800\"\n : \"bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800\"\n }`,\n iconWrapper: `w-10 h-10 rounded-full flex items-center justify-center ${\n isMemory ? \"bg-amber-100 dark:bg-amber-900\" : \"bg-green-100 dark:bg-green-900\"\n }`,\n title: `font-semibold ${\n isMemory ? \"text-amber-800 dark:text-amber-200\" : \"text-green-800 dark:text-green-200\"\n }`,\n text: `text-sm mt-1 ${\n isMemory ? \"text-amber-700 dark:text-amber-300\" : \"text-green-700 dark:text-green-300\"\n }`,\n isMemory,\n };\n}\n\nexport function ServiceIcon({ name }: { name: string }): JSX.Element {\n const iconMap: Record<string, JSX.Element> = {\n mail: (\n <svg className=\"w-6 h-6 text-red-500\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n d=\"M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n fill=\"none\"\n />\n </svg>\n ),\n slack: (\n <svg className=\"w-6 h-6\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path\n d=\"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z\"\n fill=\"#E01E5A\"\n />\n <path\n d=\"M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z\"\n fill=\"#36C5F0\"\n />\n <path\n d=\"M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312z\"\n fill=\"#2EB67D\"\n />\n <path\n d=\"M15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z\"\n fill=\"#ECB22E\"\n />\n </svg>\n ),\n calendar: (\n <svg\n className=\"w-6 h-6 text-blue-500\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"\n />\n </svg>\n ),\n github: (\n <svg className=\"w-6 h-6\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n fillRule=\"evenodd\"\n d=\"M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z\"\n clipRule=\"evenodd\"\n />\n </svg>\n ),\n jira: (\n <svg className=\"w-6 h-6\" viewBox=\"0 0 24 24\">\n <defs>\n <linearGradient id=\"jira-gradient\" x1=\"98.031%\" x2=\"58.888%\" y1=\".161%\" y2=\"40.766%\">\n <stop offset=\"0%\" stopColor=\"#0052CC\" />\n <stop offset=\"100%\" stopColor=\"#2684FF\" />\n </linearGradient>\n </defs>\n <path\n fill=\"url(#jira-gradient)\"\n d=\"M11.571 11.513H0a5.218 5.218 0 005.232 5.215h2.13v2.057A5.215 5.215 0 0012.575 24V12.518a1.005 1.005 0 00-1.005-1.005z\"\n />\n <path\n fill=\"#2684FF\"\n d=\"M17.151 5.97H5.58a5.215 5.215 0 005.215 5.214h2.129v2.058a5.218 5.218 0 005.232 5.215V6.975a1.005 1.005 0 00-1.005-1.005z\"\n />\n <path\n fill=\"#2684FF\"\n d=\"M22.723.426H11.152a5.215 5.215 0 005.215 5.215h2.129v2.057a5.218 5.218 0 005.232 5.215V1.431a1.005 1.005 0 00-1.005-1.005z\"\n />\n </svg>\n ),\n notion: (\n <svg className=\"w-6 h-6\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.98-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466l1.823 1.447zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.84-.046.933-.56.933-1.167V6.354c0-.606-.233-.933-.746-.886l-15.177.887c-.56.046-.747.326-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.746 0-.933-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.14c-.093-.514.28-.886.747-.933l3.222-.186zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.933.653.933 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.448-1.632z\" />\n </svg>\n ),\n default: (\n <svg\n className=\"w-6 h-6 text-neutral-400\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M13 10V3L4 14h7v7l9-11h-7z\"\n />\n </svg>\n ),\n };\n\n return iconMap[name] ?? iconMap.default;\n}\n",
|
|
102
102
|
"app/setup/page.tsx": "\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport {\n buildSetupSteps,\n CATEGORIES,\n filterIntegrations,\n getTokenStorageStyles,\n groupIntegrationsByCategory,\n type Integration,\n OAUTH_SETUP_GUIDES,\n ServiceIcon,\n type TokenStorageStatus,\n} from \"./page-helpers\";\n\nexport default function SetupPage(): React.JSX.Element {\n const [integrations, setIntegrations] = useState<Integration[]>([]);\n const [loading, setLoading] = useState(true);\n const [expandedGuide, setExpandedGuide] = useState<string | null>(null);\n const [envChecked, setEnvChecked] = useState(false);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const [selectedCategory, setSelectedCategory] = useState<string | null>(null);\n const [tokenStorage, setTokenStorage] = useState<TokenStorageStatus | null>(null);\n\n useEffect(() => {\n void fetchStatus();\n void fetchTokenStorage();\n }, []);\n\n async function fetchStatus(): Promise<void> {\n try {\n const res = await fetch(\"/api/integrations/status\");\n if (!res.ok) {\n console.error(\"Failed to fetch integration status:\", res.status);\n setIntegrations([]);\n return;\n }\n\n const data = await res.json();\n setIntegrations(data.integrations ?? []);\n } catch (error) {\n console.error(\"Failed to fetch integration status:\", error);\n setIntegrations([]);\n } finally {\n setLoading(false);\n }\n }\n\n async function fetchTokenStorage(): Promise<void> {\n const fallback: TokenStorageStatus = { mode: \"memory\", encrypted: false };\n\n try {\n const res = await fetch(\"/api/integrations/token-storage\");\n if (!res.ok) {\n setTokenStorage(fallback);\n return;\n }\n const data = await res.json();\n setTokenStorage(data);\n } catch {\n setTokenStorage(fallback);\n }\n }\n\n const filteredIntegrations = useMemo(\n () => filterIntegrations(integrations, searchQuery, selectedCategory),\n [integrations, searchQuery, selectedCategory],\n );\n\n const groupedIntegrations = useMemo(\n () => groupIntegrationsByCategory(filteredIntegrations),\n [filteredIntegrations],\n );\n\n const connectedCount = integrations.filter((i) => i.connected).length;\n const totalCount = integrations.length;\n const progress = totalCount > 0 ? (connectedCount / totalCount) * 100 : 0;\n\n const allConnected = connectedCount === totalCount && totalCount > 0;\n\n const setupSteps = useMemo(\n () => buildSetupSteps(envChecked, allConnected, () => setEnvChecked(true)),\n [allConnected, envChecked],\n );\n\n const tokenStorageStyles = useMemo(() => getTokenStorageStyles(tokenStorage), [tokenStorage]);\n\n return (\n <div className=\"min-h-screen bg-neutral-50 dark:bg-neutral-900\">\n <div className=\"max-w-4xl mx-auto px-4 py-12\">\n <div className=\"text-center mb-12\">\n <h1 className=\"text-4xl font-bold text-neutral-900 dark:text-white mb-4\">\n Setup Your AI Agent\n </h1>\n <p className=\"text-lg text-neutral-600 dark:text-neutral-400\">\n Connect your services to enable AI-powered automation\n </p>\n </div>\n\n <div className=\"bg-white dark:bg-neutral-800 rounded-2xl p-6 shadow-sm border border-neutral-200 dark:border-neutral-700 mb-8\">\n <div className=\"flex items-center justify-between mb-2\">\n <span className=\"text-sm font-medium text-neutral-600 dark:text-neutral-400\">\n Setup Progress\n </span>\n <span className=\"text-sm font-medium text-neutral-900 dark:text-white\">\n {connectedCount} / {totalCount} services connected\n </span>\n </div>\n <div className=\"w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-3\">\n <div\n className=\"bg-gradient-to-r from-green-500 to-emerald-500 h-3 rounded-full transition-all duration-500\"\n style={{ width: `${progress}%` }}\n />\n </div>\n </div>\n\n {tokenStorage && tokenStorageStyles && (\n <div className={tokenStorageStyles.container}>\n <div className=\"flex items-start gap-4\">\n <div className={tokenStorageStyles.iconWrapper}>\n {tokenStorageStyles.isMemory ? (\n <svg\n className=\"w-5 h-5 text-amber-600 dark:text-amber-400\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\"\n />\n </svg>\n ) : (\n <svg\n className=\"w-5 h-5 text-green-600 dark:text-green-400\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z\"\n />\n </svg>\n )}\n </div>\n\n <div className=\"flex-1\">\n <h3 className={tokenStorageStyles.title}>\n Token Storage:{\" \"}\n {tokenStorageStyles.isMemory\n ? \"Development Mode\"\n : `${tokenStorage.mode.charAt(0).toUpperCase()}${tokenStorage.mode.slice(\n 1,\n )} Storage`}\n </h3>\n\n <p className={tokenStorageStyles.text}>\n {tokenStorageStyles.isMemory ? (\n <>Tokens are stored in memory and will be lost on restart.</>\n ) : (\n <>Tokens are persisted to {tokenStorage.mode} storage.</>\n )}\n </p>\n\n <div className=\"mt-2 flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400\">\n <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z\"\n />\n </svg>\n <span>Encryption enabled {tokenStorage.autoGenerated && \"(auto-generated key)\"}</span>\n </div>\n\n {tokenStorageStyles.isMemory && (\n <div className=\"mt-4 pt-4 border-t border-amber-200 dark:border-amber-800\">\n <p className=\"text-sm font-medium text-amber-800 dark:text-amber-200 mb-3\">\n For production, add one of these to your{\" \"}\n <code className=\"px-1 py-0.5 bg-amber-100 dark:bg-amber-900 rounded text-xs\">\n .env\n </code>\n :\n </p>\n <div className=\"grid gap-2\">\n <a\n href=\"https://upstash.com/docs/redis/overall/getstarted\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-green-200 dark:border-green-700 hover:border-green-400 dark:hover:border-green-500 transition-colors group\"\n >\n <div>\n <span className=\"font-medium text-neutral-900 dark:text-white\">\n Upstash\n </span>\n <span className=\"text-green-600 dark:text-green-400 text-xs ml-2 font-medium\">\n Recommended\n </span>\n <span className=\"text-neutral-500 dark:text-neutral-400 text-sm ml-2\">\n Serverless Redis, scales horizontally\n </span>\n </div>\n <code className=\"text-xs bg-neutral-100 dark:bg-neutral-700 px-2 py-1 rounded text-neutral-600 dark:text-neutral-300\">\n REDIS_URL\n </code>\n </a>\n\n <a\n href=\"https://docs.turso.tech/quickstart\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-400 dark:hover:border-amber-500 transition-colors group\"\n >\n <div>\n <span className=\"font-medium text-neutral-900 dark:text-white\">\n Turso / libSQL\n </span>\n <span className=\"text-neutral-500 dark:text-neutral-400 text-sm ml-2\">\n Edge SQLite, fast reads globally\n </span>\n </div>\n <code className=\"text-xs bg-neutral-100 dark:bg-neutral-700 px-2 py-1 rounded text-neutral-600 dark:text-neutral-300\">\n DATABASE_URL\n </code>\n </a>\n\n <a\n href=\"https://vercel.com/docs/storage/vercel-kv/quickstart\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-400 dark:hover:border-amber-500 transition-colors group\"\n >\n <div>\n <span className=\"font-medium text-neutral-900 dark:text-white\">\n Vercel KV\n </span>\n <span className=\"text-neutral-500 dark:text-neutral-400 text-sm ml-2\">\n Built-in if using Vercel\n </span>\n </div>\n <code className=\"text-xs bg-neutral-100 dark:bg-neutral-700 px-2 py-1 rounded text-neutral-600 dark:text-neutral-300\">\n KV_REST_API_URL\n </code>\n </a>\n\n <a\n href=\"https://neon.tech/docs/get-started-with-neon/connect-neon\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-400 dark:hover:border-amber-500 transition-colors group\"\n >\n <div>\n <span className=\"font-medium text-neutral-900 dark:text-white\">Neon</span>\n <span className=\"text-neutral-500 dark:text-neutral-400 text-sm ml-2\">\n Serverless Postgres\n </span>\n </div>\n <code className=\"text-xs bg-neutral-100 dark:bg-neutral-700 px-2 py-1 rounded text-neutral-600 dark:text-neutral-300\">\n DATABASE_URL\n </code>\n </a>\n\n <a\n href=\"https://www.sqlite.org/index.html\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-400 dark:hover:border-amber-500 transition-colors group\"\n >\n <div>\n <span className=\"font-medium text-neutral-900 dark:text-white\">\n SQLite\n </span>\n <span className=\"text-neutral-500 dark:text-neutral-400 text-sm ml-2\">\n Local file, single instance only\n </span>\n </div>\n <code className=\"text-xs bg-neutral-100 dark:bg-neutral-700 px-2 py-1 rounded text-neutral-600 dark:text-neutral-300\">\n DATABASE_URL=file:./data.db\n </code>\n </a>\n </div>\n </div>\n )}\n </div>\n </div>\n </div>\n )}\n\n <div className=\"bg-white dark:bg-neutral-800 rounded-2xl shadow-sm border border-neutral-200 dark:border-neutral-700 mb-8 overflow-hidden\">\n <div className=\"p-6 border-b border-neutral-200 dark:border-neutral-700\">\n <h2 className=\"text-xl font-semibold text-neutral-900 dark:text-white\">\n Quick Start Guide\n </h2>\n </div>\n <div className=\"divide-y divide-neutral-200 dark:divide-neutral-700\">\n {setupSteps.map((step, index) => (\n <div key={step.id} className=\"p-6 flex items-start gap-4\">\n <div\n className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${\n step.completed\n ? \"bg-green-500 text-white\"\n : \"bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400\"\n }`}\n >\n {step.completed ? (\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M5 13l4 4L19 7\"\n />\n </svg>\n ) : (\n <span className=\"font-semibold\">{index + 1}</span>\n )}\n </div>\n <div className=\"flex-1\">\n <h3 className=\"font-semibold text-neutral-900 dark:text-white\">{step.title}</h3>\n <p className=\"text-sm text-neutral-600 dark:text-neutral-400 mt-1\">\n {step.description}\n </p>\n </div>\n </div>\n ))}\n </div>\n </div>\n\n <div className=\"bg-white dark:bg-neutral-800 rounded-2xl shadow-sm border border-neutral-200 dark:border-neutral-700 overflow-hidden\">\n <div className=\"p-6 border-b border-neutral-200 dark:border-neutral-700\">\n <h2 className=\"text-xl font-semibold text-neutral-900 dark:text-white\">\n Service Connections\n </h2>\n <p className=\"text-sm text-neutral-600 dark:text-neutral-400 mt-1\">\n Click on a service to see setup instructions or connect\n </p>\n\n <div className=\"mt-4\">\n <input\n type=\"text\"\n placeholder=\"Search services...\"\n value={searchQuery}\n onChange={(e) => setSearchQuery(e.target.value)}\n className=\"w-full px-4 py-2 bg-neutral-100 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl text-neutral-900 dark:text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500\"\n />\n </div>\n\n <div className=\"mt-4 flex flex-wrap gap-2\">\n <button\n type=\"button\"\n onClick={() => setSelectedCategory(null)}\n className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${\n selectedCategory === null\n ? \"bg-neutral-900 dark:bg-white text-white dark:text-neutral-900\"\n : \"bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600\"\n }`}\n >\n All\n </button>\n\n {CATEGORIES.map((category) => (\n <button\n key={category.id}\n type=\"button\"\n onClick={() =>\n setSelectedCategory(selectedCategory === category.id ? null : category.id)\n }\n className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${\n selectedCategory === category.id\n ? \"bg-neutral-900 dark:bg-white text-white dark:text-neutral-900\"\n : \"bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600\"\n }`}\n >\n {category.name}\n </button>\n ))}\n </div>\n </div>\n\n {loading ? (\n <div className=\"p-12 text-center text-neutral-500\">Loading...</div>\n ) : filteredIntegrations.length === 0 ? (\n <div className=\"p-12 text-center text-neutral-500\">\n No services found matching your search\n </div>\n ) : (\n <div>\n {CATEGORIES.filter((cat) => groupedIntegrations[cat.id]?.length > 0).map(\n (category) => (\n <div key={category.id}>\n <div className=\"px-6 py-3 bg-neutral-50 dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-700\">\n <h3 className=\"text-sm font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider\">\n {category.name}\n </h3>\n </div>\n\n <div className=\"divide-y divide-neutral-200 dark:divide-neutral-700\">\n {groupedIntegrations[category.id]?.map((integration) => {\n const guide = OAUTH_SETUP_GUIDES[integration.id];\n const isExpanded = expandedGuide === integration.id;\n\n return (\n <div key={integration.id}>\n <div className=\"p-6 flex items-center justify-between\">\n <div className=\"flex items-center gap-4\">\n <div className=\"w-12 h-12 bg-neutral-100 dark:bg-neutral-700 rounded-xl flex items-center justify-center\">\n <ServiceIcon name={integration.icon} />\n </div>\n <div>\n <h3 className=\"font-semibold text-neutral-900 dark:text-white\">\n {integration.name}\n </h3>\n <p\n className={`text-sm ${\n integration.connected\n ? \"text-green-600 dark:text-green-400\"\n : \"text-neutral-500\"\n }`}\n >\n {integration.connected ? \"Connected\" : \"Not connected\"}\n </p>\n </div>\n </div>\n\n <div className=\"flex items-center gap-3\">\n {guide && (\n <button\n type=\"button\"\n onClick={() =>\n setExpandedGuide(isExpanded ? null : integration.id)\n }\n className=\"px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white\"\n >\n {isExpanded ? \"Hide Guide\" : \"Setup Guide\"}\n </button>\n )}\n\n {integration.connected ? (\n <span className=\"inline-flex items-center gap-1.5 px-4 py-2 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 rounded-xl text-sm font-medium\">\n <span className=\"w-2 h-2 bg-green-500 rounded-full\" />\n Connected\n </span>\n ) : (\n <a\n href={integration.connectUrl}\n className=\"px-4 py-2 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl text-sm font-medium hover:opacity-90 transition-opacity\"\n >\n Connect\n </a>\n )}\n </div>\n </div>\n\n {isExpanded && guide && (\n <div className=\"px-6 pb-6\">\n <div className=\"bg-neutral-50 dark:bg-neutral-900 rounded-xl p-6 border border-neutral-200 dark:border-neutral-700\">\n <h4 className=\"font-semibold text-neutral-900 dark:text-white mb-4\">\n {guide.title}\n </h4>\n\n <ol className=\"space-y-3 mb-6\">\n {guide.steps.map((step, i) => (\n <li key={i} className=\"flex items-start gap-3\">\n <span className=\"w-6 h-6 bg-neutral-200 dark:bg-neutral-700 rounded-full flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400 flex-shrink-0\">\n {i + 1}\n </span>\n <span className=\"text-neutral-700 dark:text-neutral-300\">\n {step}\n </span>\n </li>\n ))}\n </ol>\n\n <div className=\"mb-4 p-4 bg-neutral-100 dark:bg-neutral-800 rounded-lg\">\n <h5 className=\"text-sm font-semibold text-neutral-700 dark:text-neutral-300 mb-2\">\n Required Environment Variables:\n </h5>\n <pre className=\"text-sm text-neutral-600 dark:text-neutral-400 font-mono whitespace-pre-wrap\">\n {guide.envVars.map((v) => `${v}=your_value`).join(\"\\n\")}\n </pre>\n </div>\n\n <a\n href={guide.link}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 text-sm font-medium hover:underline\"\n >\n Open Developer Console\n <svg\n className=\"w-4 h-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14\"\n />\n </svg>\n </a>\n </div>\n </div>\n )}\n </div>\n );\n })}\n </div>\n </div>\n ),\n )}\n </div>\n )}\n </div>\n\n {allConnected && (\n <div className=\"mt-8 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-2xl p-6 border border-green-200 dark:border-green-800 text-center\">\n <div className=\"text-4xl mb-4\">🎉</div>\n <h3 className=\"text-xl font-semibold text-green-800 dark:text-green-200 mb-2\">\n All Services Connected!\n </h3>\n <p className=\"text-green-700 dark:text-green-300 mb-4\">\n Your AI agent is ready to use. Start chatting to automate your workflows.\n </p>\n <a\n href=\"/\"\n className=\"inline-flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-xl font-medium hover:bg-green-700 transition-colors\"\n >\n Start Using Your Agent\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M13 7l5 5m0 0l-5 5m5-5H6\"\n />\n </svg>\n </a>\n </div>\n )}\n </div>\n </div>\n );\n}\n",
|
|
103
103
|
"lib/oauth-memory-store.ts": "import { MemoryTokenStore } from \"veryfront/oauth\";\n\nexport const oauthMemoryTokenStore = new MemoryTokenStore();\n",
|
|
104
104
|
"lib/oauth.ts": "import { type OAuthToken, tokenStore } from \"./token-store.ts\";\n\nexport interface OAuthProvider {\n name: string;\n authorizationUrl: string;\n tokenUrl: string;\n clientId: string;\n clientSecret: string;\n scopes: string[];\n callbackPath: string;\n}\n\nfunction getExpiresAt(expiresIn: unknown): number | undefined {\n if (typeof expiresIn !== \"number\" || expiresIn <= 0) return undefined;\n return Date.now() + expiresIn * 1000;\n}\n\nasync function postTokenRequest(\n provider: OAuthProvider,\n body: Record<string, string>,\n errorPrefix: string,\n): Promise<any> {\n const response = await fetch(provider.tokenUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams(body),\n });\n\n if (response.ok) return response.json();\n\n const error = await response.text();\n throw new Error(`${errorPrefix}: ${response.status} - ${error}`);\n}\n\nexport function getAuthorizationUrl(\n provider: OAuthProvider,\n state: string,\n redirectUri: string,\n): string {\n const params = new URLSearchParams({\n client_id: provider.clientId,\n redirect_uri: redirectUri,\n response_type: \"code\",\n scope: provider.scopes.join(\" \"),\n state,\n access_type: \"offline\",\n prompt: \"consent\",\n });\n\n return `${provider.authorizationUrl}?${params.toString()}`;\n}\n\nexport async function exchangeCodeForTokens(\n provider: OAuthProvider,\n code: string,\n redirectUri: string,\n): Promise<OAuthToken> {\n const data = await postTokenRequest(\n provider,\n {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n code,\n grant_type: \"authorization_code\",\n redirect_uri: redirectUri,\n },\n \"Token exchange failed\",\n );\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function refreshAccessToken(\n provider: OAuthProvider,\n refreshToken: string,\n): Promise<OAuthToken> {\n const data = await postTokenRequest(\n provider,\n {\n client_id: provider.clientId,\n client_secret: provider.clientSecret,\n refresh_token: refreshToken,\n grant_type: \"refresh_token\",\n },\n \"Token refresh failed\",\n );\n\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token ?? refreshToken,\n expiresAt: getExpiresAt(data.expires_in),\n tokenType: data.token_type ?? \"Bearer\",\n scope: data.scope,\n };\n}\n\nexport async function getValidToken(\n provider: OAuthProvider,\n userId: string,\n service: string,\n): Promise<string | null> {\n const token = await tokenStore.getToken(userId, service);\n if (!token) return null;\n\n const isExpired = token.expiresAt\n ? token.expiresAt < Date.now() + 5 * 60 * 1000\n : false;\n\n if (!isExpired || !token.refreshToken) return token.accessToken;\n\n try {\n const newToken = await refreshAccessToken(provider, token.refreshToken);\n await tokenStore.setToken(userId, service, newToken);\n return newToken.accessToken;\n } catch {\n await tokenStore.revokeToken(userId, service);\n return null;\n }\n}\n",
|
|
105
105
|
"lib/token-store-examples.ts": "/****\n * Production Token Store Examples\n *\n * Copy-paste implementations for different storage backends.\n * Each example includes encryption support via TOKEN_ENCRYPTION_KEY.\n *\n * @module\n */\n\nimport { createTokenStore, tokenStore, type TokenStore } from \"./token-store.ts\";\n\n// ============================================================================\n// Vercel KV Store\n// ============================================================================\n\n/**\n * Token store using Vercel KV (Redis-compatible)\n *\n * Required environment variables:\n * - KV_REST_API_URL\n * - KV_REST_API_TOKEN\n * - TOKEN_ENCRYPTION_KEY (recommended)\n *\n * @example\n * ```typescript\n * // lib/token-store.ts\n * import { createVercelKVStore } from './token-store-examples';\n * export const tokenStore = createVercelKVStore();\n * ```\n */\nexport function createVercelKVStore(): TokenStore {\n type VercelKV = typeof import(\"@vercel/kv\");\n let kvPromise: Promise<VercelKV> | null = null;\n\n async function getKV(): Promise<VercelKV[\"kv\"]> {\n kvPromise ??= import(\"@vercel/kv\");\n return (await kvPromise).kv;\n }\n\n return createTokenStore({\n async get(key: string): Promise<string | null> {\n const kv = await getKV();\n return kv.get<string>(key);\n },\n async set(key: string, value: string): Promise<void> {\n const kv = await getKV();\n await kv.set(key, value);\n },\n async delete(key: string): Promise<void> {\n const kv = await getKV();\n await kv.del(key);\n },\n });\n}\n\n// ============================================================================\n// Redis Store\n// ============================================================================\n\n/**\n * Token store using Redis\n *\n * Required environment variables:\n * - REDIS_URL (e.g., redis://localhost:6379)\n * - TOKEN_ENCRYPTION_KEY (recommended)\n *\n * @example\n * ```typescript\n * // lib/token-store.ts\n * import { createRedisStore } from './token-store-examples';\n * export const tokenStore = createRedisStore();\n * ```\n */\nexport function createRedisStore(): TokenStore {\n let clientPromise: Promise<ReturnType<(typeof import(\"redis\"))[\"createClient\"]>> | null = null;\n\n async function getClient(): Promise<ReturnType<(typeof import(\"redis\"))[\"createClient\"]>> {\n clientPromise ??= (async () => {\n const { createClient } = await import(\"redis\");\n const client = createClient({ url: process.env.REDIS_URL });\n await client.connect();\n return client;\n })();\n\n return clientPromise;\n }\n\n return createTokenStore({\n async get(key: string): Promise<string | null> {\n const client = await getClient();\n return client.get(key);\n },\n async set(key: string, value: string): Promise<void> {\n const client = await getClient();\n await client.set(key, value);\n },\n async delete(key: string): Promise<void> {\n const client = await getClient();\n await client.del(key);\n },\n });\n}\n\n// ============================================================================\n// PostgreSQL Store\n// ============================================================================\n\n/**\n * Token store using PostgreSQL\n *\n * Required environment variables:\n * - DATABASE_URL (e.g., postgres://user:pass@host:5432/db)\n * - TOKEN_ENCRYPTION_KEY (recommended)\n *\n * Required table (create with migration):\n * ```sql\n * CREATE TABLE oauth_tokens (\n * key VARCHAR(255) PRIMARY KEY,\n * value TEXT NOT NULL,\n * created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n * updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n * );\n * CREATE INDEX idx_oauth_tokens_key ON oauth_tokens(key);\n * ```\n *\n * @example\n * ```typescript\n * // lib/token-store.ts\n * import { createPostgresStore } from './token-store-examples';\n * export const tokenStore = createPostgresStore();\n * ```\n */\nexport function createPostgresStore(): TokenStore {\n let poolPromise: Promise<import(\"pg\").Pool> | null = null;\n\n async function getPool(): Promise<import(\"pg\").Pool> {\n poolPromise ??= (async () => {\n const { Pool } = await import(\"pg\");\n return new Pool({ connectionString: process.env.DATABASE_URL });\n })();\n\n return poolPromise;\n }\n\n return createTokenStore({\n async get(key: string): Promise<string | null> {\n const pool = await getPool();\n const result = await pool.query(\"SELECT value FROM oauth_tokens WHERE key = $1\", [key]);\n return result.rows[0]?.value ?? null;\n },\n async set(key: string, value: string): Promise<void> {\n const pool = await getPool();\n await pool.query(\n `INSERT INTO oauth_tokens (key, value, updated_at)\n VALUES ($1, $2, NOW())\n ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,\n [key, value],\n );\n },\n async delete(key: string): Promise<void> {\n const pool = await getPool();\n await pool.query(\"DELETE FROM oauth_tokens WHERE key = $1\", [key]);\n },\n });\n}\n\n// ============================================================================\n// SQLite Store (for edge/serverless with D1, Turso, etc.)\n// ============================================================================\n\n/**\n * Token store using SQLite (Cloudflare D1, Turso, better-sqlite3)\n *\n * Required table:\n * ```sql\n * CREATE TABLE oauth_tokens (\n * key TEXT PRIMARY KEY,\n * value TEXT NOT NULL,\n * updated_at INTEGER DEFAULT (strftime('%s', 'now'))\n * );\n * ```\n *\n * @param db - SQLite database instance (D1Database, Connection, or Database)\n *\n * @example With Cloudflare D1\n * ```typescript\n * // In your API route\n * export async function GET(request: Request, { env }) {\n * const tokenStore = createSQLiteStore(env.DB);\n * // ...\n * }\n * ```\n *\n * @example With Turso\n * ```typescript\n * import { createClient } from '@libsql/client';\n * const db = createClient({ url: process.env.TURSO_URL, authToken: process.env.TURSO_AUTH_TOKEN });\n * export const tokenStore = createSQLiteStore(db);\n * ```\n */\nexport function createSQLiteStore(db: {\n prepare(sql: string): {\n bind(...args: unknown[]): { first(): Promise<{ value?: string } | null>; run(): Promise<void> };\n };\n}): TokenStore {\n return createTokenStore({\n async get(key: string): Promise<string | null> {\n const result = await db.prepare(\"SELECT value FROM oauth_tokens WHERE key = ?\").bind(key).first();\n return result?.value ?? null;\n },\n async set(key: string, value: string): Promise<void> {\n await db\n .prepare(\n `INSERT OR REPLACE INTO oauth_tokens (key, value, updated_at)\n VALUES (?, ?, strftime('%s', 'now'))`,\n )\n .bind(key, value)\n .run();\n },\n async delete(key: string): Promise<void> {\n await db.prepare(\"DELETE FROM oauth_tokens WHERE key = ?\").bind(key).run();\n },\n });\n}\n\n// ============================================================================\n// Cloudflare Workers KV Store\n// ============================================================================\n\n/**\n * Token store using Cloudflare Workers KV\n *\n * @param kv - KV namespace binding from worker environment\n *\n * @example\n * ```typescript\n * // In your worker\n * export default {\n * async fetch(request, env) {\n * const tokenStore = createWorkersKVStore(env.OAUTH_TOKENS);\n * // ...\n * }\n * };\n * ```\n */\nexport function createWorkersKVStore(kv: {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n delete(key: string): Promise<void>;\n}): TokenStore {\n return createTokenStore({\n get(key: string): Promise<string | null> {\n return kv.get(key);\n },\n set(key: string, value: string): Promise<void> {\n return kv.put(key, value);\n },\n delete(key: string): Promise<void> {\n return kv.delete(key);\n },\n });\n}\n\n// ============================================================================\n// Prisma Store\n// ============================================================================\n\n/**\n * Token store using Prisma ORM\n *\n * Required Prisma schema:\n * ```prisma\n * model OAuthToken {\n * key String @id\n * value String\n * updatedAt DateTime @updatedAt\n * }\n * ```\n *\n * @example\n * ```typescript\n * import { PrismaClient } from '@prisma/client';\n * const prisma = new PrismaClient();\n * export const tokenStore = createPrismaStore(prisma);\n * ```\n */\nexport function createPrismaStore(prisma: {\n oAuthToken: {\n findUnique(args: { where: { key: string } }): Promise<{ value: string } | null>;\n upsert(args: {\n where: { key: string };\n update: { value: string };\n create: { key: string; value: string };\n }): Promise<unknown>;\n delete(args: { where: { key: string } }): Promise<unknown>;\n };\n}): TokenStore {\n return createTokenStore({\n async get(key: string): Promise<string | null> {\n const record = await prisma.oAuthToken.findUnique({ where: { key } });\n return record?.value ?? null;\n },\n async set(key: string, value: string): Promise<void> {\n await prisma.oAuthToken.upsert({\n where: { key },\n update: { value },\n create: { key, value },\n });\n },\n async delete(key: string): Promise<void> {\n try {\n await prisma.oAuthToken.delete({ where: { key } });\n } catch {\n // Ignore if not found\n }\n },\n });\n}\n\n// ============================================================================\n// Drizzle ORM Store\n// ============================================================================\n\n/**\n * Token store using Drizzle ORM\n *\n * Required schema:\n * ```typescript\n * import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';\n *\n * export const oauthTokens = pgTable('oauth_tokens', {\n * key: text('key').primaryKey(),\n * value: text('value').notNull(),\n * updatedAt: timestamp('updated_at').defaultNow(),\n * });\n * ```\n *\n * @example\n * ```typescript\n * import { drizzle } from 'drizzle-orm/postgres-js';\n * import postgres from 'postgres';\n * import { oauthTokens } from './schema';\n *\n * const client = postgres(process.env.DATABASE_URL!);\n * const db = drizzle(client);\n * export const tokenStore = createDrizzleStore(db, oauthTokens);\n * ```\n */\nexport function createDrizzleStore<T extends { key: unknown; value: unknown }>(\n db: {\n select(): {\n from(table: T): { where(condition: unknown): { get(): Promise<{ value: string } | undefined> } };\n };\n insert(table: T): {\n values(data: { key: string; value: string }): {\n onConflictDoUpdate(args: { target: unknown; set: { value: string } }): { execute(): Promise<void> };\n };\n };\n delete(table: T): { where(condition: unknown): { execute(): Promise<void> } };\n },\n table: T & { key: unknown; value: unknown },\n eq: (col: unknown, val: unknown) => unknown,\n): TokenStore {\n return createTokenStore({\n async get(key: string): Promise<string | null> {\n const result = await db.select().from(table).where(eq(table.key, key)).get();\n return result?.value ?? null;\n },\n async set(key: string, value: string): Promise<void> {\n await db\n .insert(table)\n .values({ key, value })\n .onConflictDoUpdate({ target: table.key, set: { value } })\n .execute();\n },\n async delete(key: string): Promise<void> {\n await db.delete(table).where(eq(table.key, key)).execute();\n },\n });\n}\n\n// ============================================================================\n// Auto-Select Store (Recommended)\n// ============================================================================\n\n/**\n * Automatically selects the appropriate token store based on environment\n *\n * Detection order:\n * 1. DATABASE_URL -> PostgreSQL\n * 2. KV_REST_API_URL -> Vercel KV\n * 3. REDIS_URL -> Redis\n * 4. Fallback -> In-memory (development only)\n *\n * @example\n * ```typescript\n * // lib/token-store.ts\n * import { createAutoStore } from './token-store-examples';\n * export const tokenStore = createAutoStore();\n * ```\n */\nexport function createAutoStore(): TokenStore {\n const env = process.env;\n\n if (env.DATABASE_URL) {\n console.log(\"[Token Store] Using PostgreSQL storage\");\n return createPostgresStore();\n }\n\n if (env.KV_REST_API_URL && env.KV_REST_API_TOKEN) {\n console.log(\"[Token Store] Using Vercel KV storage\");\n return createVercelKVStore();\n }\n\n if (env.REDIS_URL) {\n console.log(\"[Token Store] Using Redis storage\");\n return createRedisStore();\n }\n\n console.warn(\n \"[Token Store] No production storage configured. \" +\n \"Using in-memory storage (tokens will be lost on restart). \" +\n \"Set DATABASE_URL, KV_REST_API_URL, or REDIS_URL for production.\",\n );\n\n return tokenStore;\n}\n",
|
|
106
106
|
"lib/token-store.ts": "/********************************************************************************\n * OAuth Token Store\n *\n * Manages OAuth tokens for connected services.\n *\n * ## Storage Modes\n *\n * **Development (default)**: In-memory storage - tokens are lost on restart.\n * **Production**: Configure via environment variables:\n * - DATABASE_URL: Uses database storage (Postgres, SQLite, MySQL)\n * - KV_REST_API_URL + KV_REST_API_TOKEN: Uses Vercel KV\n * - REDIS_URL: Uses Redis\n * - TOKEN_ENCRYPTION_KEY: Enables AES-256-GCM encryption (recommended)\n *\n * ## Security\n *\n * Tokens contain sensitive OAuth credentials. In production:\n * 1. Always use encrypted storage (set TOKEN_ENCRYPTION_KEY)\n * 2. Use HTTPS for all connections\n * 3. Implement proper access control\n * 4. Rotate encryption keys periodically\n *\n * @see lib/token-store-examples.ts for complete production implementations\n ********************************************************************************/\n\nexport interface OAuthToken {\n accessToken: string;\n refreshToken?: string;\n expiresAt?: number;\n tokenType?: string;\n scope?: string;\n}\n\nexport interface TokenStore {\n getToken(userId: string, service: string): Promise<OAuthToken | null>;\n setToken(userId: string, service: string, token: OAuthToken): Promise<void>;\n revokeToken(userId: string, service: string): Promise<void>;\n isConnected(userId: string, service: string): Promise<boolean>;\n}\n\n/** Token store configuration for production backends */\nexport interface TokenStoreConfig {\n get: (key: string) => Promise<string | null>;\n set: (key: string, value: string) => Promise<void>;\n delete: (key: string) => Promise<void>;\n}\n\nconst AUTO_KEY_STORAGE = \"__veryfront_auto_encryption_key__\";\nconst TOKENS_KEY = \"__veryfront_oauth_tokens__\";\n\nconst globalStore = globalThis as Record<string, unknown>;\n\n// ============================================================================\n// Encryption Utilities\n// ============================================================================\n\nexport async function encryptToken(token: OAuthToken): Promise<string> {\n const key = getEncryptionKey();\n if (!key) return JSON.stringify(token);\n\n const data = new TextEncoder().encode(JSON.stringify(token));\n const iv = crypto.getRandomValues(new Uint8Array(12));\n const rawKey = new Uint8Array(key).buffer;\n\n const cryptoKey = await crypto.subtle.importKey(\"raw\", rawKey, \"AES-GCM\", false, [\"encrypt\"]);\n const encrypted = await crypto.subtle.encrypt({ name: \"AES-GCM\", iv }, cryptoKey, data);\n\n const combined = new Uint8Array(iv.length + encrypted.byteLength);\n combined.set(iv);\n combined.set(new Uint8Array(encrypted), iv.length);\n\n return `encrypted:${btoa(String.fromCharCode(...combined))}`;\n}\n\nexport async function decryptToken(encrypted: string): Promise<OAuthToken | null> {\n if (!encrypted.startsWith(\"encrypted:\")) {\n try {\n return JSON.parse(encrypted) as OAuthToken;\n } catch {\n return null;\n }\n }\n\n const key = getEncryptionKey();\n if (!key) {\n console.error(\"[Token Store] Cannot decrypt: TOKEN_ENCRYPTION_KEY not set\");\n return null;\n }\n\n try {\n const base64 = encrypted.slice(\"encrypted:\".length);\n const combined = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));\n\n const iv = combined.slice(0, 12);\n const ciphertext = combined.slice(12);\n const rawKey = new Uint8Array(key).buffer;\n\n const cryptoKey = await crypto.subtle.importKey(\"raw\", rawKey, \"AES-GCM\", false, [\"decrypt\"]);\n const decrypted = await crypto.subtle.decrypt({ name: \"AES-GCM\", iv }, cryptoKey, ciphertext);\n\n return JSON.parse(new TextDecoder().decode(decrypted)) as OAuthToken;\n } catch {\n console.error(\"[Token Store] Decryption failed\");\n return null;\n }\n}\n\nexport function generateEncryptionKey(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32));\n return Array.from(bytes)\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\nfunction getEnvVar(name: string): string | undefined {\n if (typeof process !== \"undefined\") return process.env?.[name];\n return (globalThis as { Deno?: { env?: { get?: (key: string) => string | undefined } } }).Deno\n ?.env?.get?.(name);\n}\n\nfunction hexToKeyBytes(keyHex: string): Uint8Array | null {\n if (keyHex.length !== 64) {\n console.error(\"[Token Store] TOKEN_ENCRYPTION_KEY must be 64 hex characters (32 bytes)\");\n return null;\n }\n\n const key = new Uint8Array(32);\n for (let i = 0; i < 32; i++) {\n key[i] = parseInt(keyHex.slice(i * 2, i * 2 + 2), 16);\n }\n return key;\n}\n\n/** Get encryption key from environment or auto-generate for development */\nfunction getEncryptionKey(): Uint8Array | null {\n const keyHex = getEnvVar(\"TOKEN_ENCRYPTION_KEY\");\n if (keyHex) return hexToKeyBytes(keyHex);\n\n if (!globalStore[AUTO_KEY_STORAGE]) {\n globalStore[AUTO_KEY_STORAGE] = generateEncryptionKey();\n }\n\n return hexToKeyBytes(globalStore[AUTO_KEY_STORAGE] as string);\n}\n\n// ============================================================================\n// Storage Mode Detection\n// ============================================================================\n\nexport type StorageMode = \"memory\" | \"database\" | \"kv\" | \"redis\" | \"custom\";\n\nexport function getStorageMode(): StorageMode {\n const env = typeof process !== \"undefined\"\n ? process.env\n : (globalThis as { Deno?: { env?: { toObject?: () => Record<string, string> } } }).Deno?.env\n ?.toObject?.() ?? {};\n\n if (env.DATABASE_URL) return \"database\";\n if (env.KV_REST_API_URL) return \"kv\";\n if (env.REDIS_URL) return \"redis\";\n return \"memory\";\n}\n\nexport function isEncryptionEnabled(): boolean {\n return getEncryptionKey() !== null;\n}\n\nfunction isProductionRuntime(): boolean {\n return getEnvVar(\"NODE_ENV\") === \"production\";\n}\n\n// ============================================================================\n// In-Memory Store (Development)\n// ============================================================================\n\nconst tokens = (globalStore[TOKENS_KEY] as Map<string, OAuthToken> | undefined) ??\n new Map<string, OAuthToken>();\nglobalStore[TOKENS_KEY] = tokens;\n\nfunction getKey(userId: string, service: string): string {\n return `${userId}:${service}`;\n}\n\nasync function isConnected(\n store: Pick<TokenStore, \"getToken\">,\n userId: string,\n service: string,\n): Promise<boolean> {\n const token = await store.getToken(userId, service);\n return !!token && (!token.expiresAt || token.expiresAt > Date.now());\n}\n\nconst inMemoryStore: TokenStore = {\n async getToken(userId: string, service: string): Promise<OAuthToken | null> {\n return tokens.get(getKey(userId, service)) ?? null;\n },\n\n async setToken(userId: string, service: string, token: OAuthToken): Promise<void> {\n tokens.set(getKey(userId, service), token);\n },\n\n async revokeToken(userId: string, service: string): Promise<void> {\n tokens.delete(getKey(userId, service));\n },\n\n async isConnected(userId: string, service: string): Promise<boolean> {\n return isConnected(this, userId, service);\n },\n};\n\n// ============================================================================\n// Token Store Factory\n// ============================================================================\n\nexport function createTokenStore(config: TokenStoreConfig): TokenStore {\n return {\n async getToken(userId: string, service: string): Promise<OAuthToken | null> {\n const data = await config.get(getKey(userId, service));\n if (!data) return null;\n return decryptToken(data);\n },\n\n async setToken(userId: string, service: string, token: OAuthToken): Promise<void> {\n await config.set(getKey(userId, service), await encryptToken(token));\n },\n\n async revokeToken(userId: string, service: string): Promise<void> {\n await config.delete(getKey(userId, service));\n },\n\n async isConnected(userId: string, service: string): Promise<boolean> {\n return isConnected(this, userId, service);\n },\n };\n}\n\n// ============================================================================\n// Default Export (Auto-detects environment)\n// ============================================================================\n\nexport function createDefaultTokenStore(): TokenStore {\n if (isProductionRuntime()) {\n throw new Error(\n \"In-memory token storage is not allowed in production. \" +\n \"Configure DATABASE_URL, KV_REST_API_URL, or REDIS_URL and wire a durable store from \" +\n \"lib/token-store-examples.ts.\",\n );\n }\n\n // The starter keeps the development store explicit. Production adapters in\n // token-store-examples.ts require provider-specific clients and credentials.\n return inMemoryStore;\n}\n\nlet defaultTokenStore: TokenStore | null = null;\n\nfunction getDefaultTokenStore(): TokenStore {\n defaultTokenStore ??= createDefaultTokenStore();\n return defaultTokenStore;\n}\n\nexport const tokenStore: TokenStore = {\n getToken(userId: string, service: string): Promise<OAuthToken | null> {\n return getDefaultTokenStore().getToken(userId, service);\n },\n\n setToken(userId: string, service: string, token: OAuthToken): Promise<void> {\n return getDefaultTokenStore().setToken(userId, service, token);\n },\n\n revokeToken(userId: string, service: string): Promise<void> {\n return getDefaultTokenStore().revokeToken(userId, service);\n },\n\n isConnected(userId: string, service: string): Promise<boolean> {\n return getDefaultTokenStore().isConnected(userId, service);\n },\n};\n\nif (\n !isProductionRuntime() &&\n getStorageMode() === \"memory\"\n) {\n console.warn(\n \"[Token Store] Using in-memory storage (development mode). \" +\n \"Tokens will be lost on restart. \" +\n \"Set DATABASE_URL, KV_REST_API_URL, or REDIS_URL for production.\",\n );\n}\n",
|
|
107
107
|
"lib/user-id.ts": "import type { ToolExecutionContext } from \"veryfront/tool\";\n\nfunction isProductionRuntime(): boolean {\n return Deno.env.get(\"NODE_ENV\") === \"production\";\n}\n\nfunction devUserId(): string {\n return Deno.env.get(\"VERYFRONT_DEV_USER_ID\") ?? \"dev-user\";\n}\n\nfunction requireUserId(value: string | null | undefined): string {\n if (typeof value === \"string\" && value.length > 0) {\n return value;\n }\n\n if (!isProductionRuntime()) {\n return devUserId();\n }\n\n throw new Error(\n \"Authenticated user id is required in production. \" +\n \"Pass the authenticated user's id from your session, JWT, or auth provider.\",\n );\n}\n\nexport function requireUserIdFromRequest(request: Request): string {\n return requireUserId(\n request.headers.get(\"x-veryfront-user-id\") ?? request.headers.get(\"x-user-id\"),\n );\n}\n\nexport function requireUserIdFromContext(context?: ToolExecutionContext): string {\n return requireUserId(context?.endUserId ?? context?.userId);\n}\n",
|
|
108
|
-
"SETUP.md": "# Integration Setup Guide\n\nThis guide helps you set up credentials for all 50+ service integrations available in Veryfront.\n\n## Quick Start\n\n```bash\n# Create a new project with integrations\nveryfront init my-app --with ai --integrations slack,github,notion\n\n# Start development\ncd my-app\nveryfront dev\n```\n\nVisit `http://localhost:3000/api/auth/{service}` to connect each service.\n\n---\n\n## Table of Contents\n\n- [Google Services](#google-services) (Gmail, Calendar, Drive, Docs, Sheets)\n- [Microsoft Services](#microsoft-services) (Outlook, Teams, SharePoint, OneDrive)\n- [Atlassian Services](#atlassian-services) (Jira, Confluence)\n- [Communication](#communication) (Slack, Discord, Twilio, Zoom, Webex)\n- [Project Management](#project-management) (Asana, Monday, Trello, ClickUp, Linear, Notion)\n- [Developer Tools](#developer-tools) (GitHub, GitLab, Bitbucket, Figma, Sentry, PostHog)\n- [CRM & Sales](#crm--sales) (Salesforce, HubSpot, Pipedrive, Intercom, Zendesk, Freshdesk)\n- [Databases](#databases) (Supabase, Neon, Airtable, Snowflake)\n- [Cloud & Storage](#cloud--storage) (AWS, Dropbox, Box)\n- [Finance](#finance) (Stripe, QuickBooks, Xero)\n- [Marketing](#marketing) (Mailchimp, Twitter)\n- [E-commerce](#e-commerce) (Shopify)\n- [AI & Analytics](#ai--analytics) (Anthropic, Mixpanel)\n\n---\n\n## Google Services\n\n**Gmail, Calendar, Drive, Docs, Sheets** all use the same Google OAuth credentials.\n\n### Setup Steps\n\n1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)\n2. Create a new project or select existing\n3. Enable required APIs:\n - Gmail API\n - Google Calendar API\n - Google Drive API\n - Google Docs API\n - Google Sheets API\n4. Go to **OAuth consent screen**:\n - User Type: External (or Internal for Workspace)\n - Add scopes for each API you need\n5. Go to **Credentials** > **Create Credentials** > **OAuth client ID**:\n - Application type: Web application\n - Authorized redirect URIs:\n ```\n http://localhost:3000/api/auth/gmail/callback\n http://localhost:3000/api/auth/calendar/callback\n http://localhost:3000/api/auth/drive/callback\n http://localhost:3000/api/auth/docs-google/callback\n http://localhost:3000/api/auth/sheets/callback\n ```\n\n### Environment Variables\n\n```env\nGOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com\nGOOGLE_CLIENT_SECRET=your-client-secret\n```\n\n### Required Scopes by Service\n\n| Service | Scopes |\n| -------- | -------------------------------------------------------------------------------------------------------------------------------- |\n| Gmail | `gmail.readonly`, `gmail.send`, `gmail.modify`, `gmail.labels`, `gmail.compose`, `https://mail.google.com/` for permanent delete |\n| Calendar | `calendar.readonly`, `calendar.events` |\n| Drive | `drive.readonly`, `drive.file` |\n| Docs | `documents.readonly`, `documents` |\n| Sheets | `spreadsheets.readonly`, `spreadsheets` |\n\n---\n\n## Microsoft Services\n\n**Outlook, Teams, SharePoint, OneDrive** use Microsoft OAuth (Azure AD).\n\n### Setup Steps\n\n1. Go to [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade)\n2. Click **New registration**:\n - Name: Your app name\n - Supported account types: Accounts in any organizational directory\n - Redirect URI: Web, `http://localhost:3000/api/auth/outlook/callback`\n3. After creation, go to **Certificates & secrets**:\n - Create a new client secret\n4. Go to **API permissions**:\n - Add Microsoft Graph permissions\n\n### Environment Variables\n\n```env\nMICROSOFT_CLIENT_ID=your-application-client-id\nMICROSOFT_CLIENT_SECRET=your-client-secret\nMICROSOFT_TENANT_ID=common\n```\n\n### Required Scopes by Service\n\n| Service | Scopes |\n| ---------- | ------------------------------------------------------------- |\n| Outlook | `Mail.Read`, `Mail.Send`, `Calendars.ReadWrite` |\n| Teams | `Team.ReadBasic.All`, `Chat.ReadWrite`, `ChannelMessage.Send` |\n| SharePoint | `Sites.Read.All`, `Files.ReadWrite.All` |\n| OneDrive | `Files.Read`, `Files.ReadWrite` |\n\n---\n\n## Atlassian Services\n\n**Jira and Confluence** use Atlassian OAuth 2.0 (3LO).\n\n### Setup Steps\n\n1. Go to [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/)\n2. Click **Create** > **OAuth 2.0 integration**\n3. Configure:\n - Name: Your app name\n - Callback URL: `http://localhost:3000/api/auth/jira/callback`\n4. Add required scopes in **Permissions**\n5. Get your Cloud ID: Visit `https://your-domain.atlassian.net/_edge/tenant_info`\n\n### Environment Variables\n\n```env\nATLASSIAN_CLIENT_ID=your-client-id\nATLASSIAN_CLIENT_SECRET=your-client-secret\nATLASSIAN_CLOUD_ID=your-cloud-id\n```\n\n### Required Scopes\n\n| Service | Scopes |\n| ---------- | --------------------------------------------------------- |\n| Jira | `read:jira-work`, `write:jira-work`, `read:jira-user` |\n| Confluence | `read:confluence-content.all`, `write:confluence-content` |\n\n---\n\n## Communication\n\n### Slack\n\n1. Go to [Slack API Apps](https://api.slack.com/apps)\n2. Click **Create New App** > **From scratch**\n3. Go to **OAuth & Permissions**:\n - Add redirect URL: `http://localhost:3000/api/auth/slack/callback`\n - Add scopes: `channels:read`, `chat:write`, `users:read`, `im:write`\n4. **Install to Workspace**\n\n```env\nSLACK_CLIENT_ID=your-client-id\nSLACK_CLIENT_SECRET=your-client-secret\n```\n\n### Discord\n\n1. Go to [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create **New Application**\n3. Go to **OAuth2**:\n - Add redirect: `http://localhost:3000/api/auth/discord/callback`\n - Scopes: `identify`, `guilds`, `messages.read`\n\n```env\nDISCORD_CLIENT_ID=your-client-id\nDISCORD_CLIENT_SECRET=your-client-secret\n```\n\n### Twilio (SMS/WhatsApp)\n\n1. Go to [Twilio Console](https://console.twilio.com/)\n2. Get Account SID and Auth Token from dashboard\n3. Get or buy a phone number for sending\n\n```env\nTWILIO_ACCOUNT_SID=your-account-sid\nTWILIO_AUTH_TOKEN=your-auth-token\nTWILIO_PHONE_NUMBER=+1234567890\n```\n\n### Zoom\n\n1. Go to [Zoom App Marketplace](https://marketplace.zoom.us/develop/create)\n2. Create **OAuth App**\n3. Configure redirect: `http://localhost:3000/api/auth/zoom/callback`\n4. Add scopes: `meeting:read`, `meeting:write`, `user:read`\n\n```env\nZOOM_CLIENT_ID=your-client-id\nZOOM_CLIENT_SECRET=your-client-secret\n```\n\n### Webex\n\n1. Go to [Webex for Developers](https://developer.webex.com/my-apps)\n2. Create new integration\n3. Redirect URI: `http://localhost:3000/api/auth/webex/callback`\n4. Scopes: `spark:messages_read`, `spark:messages_write`, `spark:rooms_read`\n\n```env\nWEBEX_CLIENT_ID=your-client-id\nWEBEX_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Project Management\n\n### Asana\n\n1. Go to [Asana Developer Console](https://app.asana.com/0/developer-console)\n2. Create new app\n3. Set redirect URL: `http://localhost:3000/api/auth/asana/callback`\n\n```env\nASANA_CLIENT_ID=your-client-id\nASANA_CLIENT_SECRET=your-client-secret\n```\n\n### Monday.com\n\n1. Go to [Monday Apps](https://auth.monday.com/oauth2/authorize)\n2. Create new app in your account's Developer section\n3. Configure OAuth with redirect: `http://localhost:3000/api/auth/monday/callback`\n\n```env\nMONDAY_CLIENT_ID=your-client-id\nMONDAY_CLIENT_SECRET=your-client-secret\n```\n\n### Trello\n\n1. Go to [Trello Power-Ups Admin](https://trello.com/power-ups/admin)\n2. Create new Power-Up\n3. Configure OAuth redirect: `http://localhost:3000/api/auth/trello/callback`\n\n```env\nTRELLO_API_KEY=your-api-key\nTRELLO_API_SECRET=your-api-secret\n```\n\n### ClickUp\n\n1. Go to [ClickUp API Settings](https://app.clickup.com/settings/apps)\n2. Create new app\n3. Redirect URL: `http://localhost:3000/api/auth/clickup/callback`\n\n```env\nCLICKUP_CLIENT_ID=your-client-id\nCLICKUP_CLIENT_SECRET=your-client-secret\n```\n\n### Linear\n\n1. Go to [Linear Settings > API](https://linear.app/settings/api)\n2. Create OAuth application\n3. Callback URL: `http://localhost:3000/api/auth/linear/callback`\n\n```env\nLINEAR_CLIENT_ID=your-client-id\nLINEAR_CLIENT_SECRET=your-client-secret\n```\n\n### Notion\n\n1. Go to [Notion Integrations](https://www.notion.so/my-integrations)\n2. Create new **public** integration (for OAuth)\n3. Set redirect URI: `http://localhost:3000/api/auth/notion/callback`\n4. **Important**: Share pages with your integration\n\n```env\nNOTION_CLIENT_ID=your-oauth-client-id\nNOTION_CLIENT_SECRET=your-oauth-client-secret\n```\n\n---\n\n## Developer Tools\n\n### GitHub\n\n1. Go to [GitHub Developer Settings](https://github.com/settings/developers)\n2. Create **New OAuth App**\n3. Authorization callback: `http://localhost:3000/api/auth/github/callback`\n\n```env\nGITHUB_CLIENT_ID=your-client-id\nGITHUB_CLIENT_SECRET=your-client-secret\n```\n\n### GitLab\n\n1. Go to [GitLab Applications](https://gitlab.com/-/profile/applications)\n2. Create new application\n3. Redirect URI: `http://localhost:3000/api/auth/gitlab/callback`\n4. Scopes: `read_user`, `read_api`, `read_repository`\n\n```env\nGITLAB_CLIENT_ID=your-application-id\nGITLAB_CLIENT_SECRET=your-secret\n```\n\n### Bitbucket\n\n1. Go to [Bitbucket App Passwords](https://bitbucket.org/account/settings/app-passwords/) or create OAuth consumer\n2. For OAuth: Workspace settings > OAuth consumers\n3. Callback URL: `http://localhost:3000/api/auth/bitbucket/callback`\n\n```env\nBITBUCKET_CLIENT_ID=your-client-id\nBITBUCKET_CLIENT_SECRET=your-client-secret\n```\n\n### Figma\n\n1. Go to [Figma Developers](https://www.figma.com/developers/apps)\n2. Create new app\n3. Callback URL: `http://localhost:3000/api/auth/figma/callback`\n\n```env\nFIGMA_CLIENT_ID=your-client-id\nFIGMA_CLIENT_SECRET=your-client-secret\n```\n\n### Sentry\n\n1. Go to [Sentry Developer Settings](https://sentry.io/settings/developer-settings/)\n2. Create new public integration\n3. Redirect URL: `http://localhost:3000/api/auth/sentry/callback`\n\n```env\nSENTRY_CLIENT_ID=your-client-id\nSENTRY_CLIENT_SECRET=your-client-secret\n```\n\n### PostHog\n\nUses API key authentication (no OAuth).\n\n1. Go to your PostHog project settings\n2. Create a personal API key\n\n```env\nPOSTHOG_API_KEY=phx_your-api-key\nPOSTHOG_HOST=https://app.posthog.com\n```\n\n---\n\n## CRM & Sales\n\n### Salesforce\n\n1. Go to [Salesforce Setup](https://login.salesforce.com/) > App Manager\n2. Create **New Connected App**\n3. Enable OAuth, add callback: `http://localhost:3000/api/auth/salesforce/callback`\n4. Required scopes: `api`, `refresh_token`\n\n```env\nSALESFORCE_CLIENT_ID=your-consumer-key\nSALESFORCE_CLIENT_SECRET=your-consumer-secret\n```\n\n### HubSpot\n\n1. Go to [HubSpot Developers](https://developers.hubspot.com/)\n2. Create app in your developer account\n3. Configure OAuth redirect: `http://localhost:3000/api/auth/hubspot/callback`\n4. Select required scopes\n\n```env\nHUBSPOT_CLIENT_ID=your-client-id\nHUBSPOT_CLIENT_SECRET=your-client-secret\n```\n\n### Pipedrive\n\n1. Go to [Pipedrive Marketplace Manager](https://developers.pipedrive.com/)\n2. Create new app\n3. OAuth redirect: `http://localhost:3000/api/auth/pipedrive/callback`\n\n```env\nPIPEDRIVE_CLIENT_ID=your-client-id\nPIPEDRIVE_CLIENT_SECRET=your-client-secret\n```\n\n### Intercom\n\n1. Go to [Intercom Developer Hub](https://developers.intercom.com/)\n2. Create new app\n3. Configure OAuth: `http://localhost:3000/api/auth/intercom/callback`\n\n```env\nINTERCOM_CLIENT_ID=your-client-id\nINTERCOM_CLIENT_SECRET=your-client-secret\n```\n\n### Zendesk\n\n1. Go to Admin Center > Apps and integrations > APIs > Zendesk API\n2. Create OAuth client\n3. Redirect URL: `http://localhost:3000/api/auth/zendesk/callback`\n\n```env\nZENDESK_CLIENT_ID=your-client-id\nZENDESK_CLIENT_SECRET=your-client-secret\nZENDESK_SUBDOMAIN=your-subdomain\n```\n\n### Freshdesk\n\nUses API key authentication.\n\n1. Go to Profile Settings in Freshdesk\n2. Find your API Key\n\n```env\nFRESHDESK_API_KEY=your-api-key\nFRESHDESK_DOMAIN=your-domain.freshdesk.com\n```\n\n---\n\n## Databases\n\n### Supabase\n\nUses API key (no OAuth needed).\n\n1. Go to your Supabase project dashboard\n2. Go to Settings > API\n3. Copy the `anon` or `service_role` key\n\n```env\nSUPABASE_URL=https://your-project.supabase.co\nSUPABASE_ANON_KEY=your-anon-key\nSUPABASE_SERVICE_ROLE_KEY=your-service-role-key\n```\n\n### Neon\n\nUses API key authentication.\n\n1. Go to [Neon Console](https://console.neon.tech/)\n2. Create API key in Account Settings\n\n```env\nNEON_API_KEY=your-api-key\nNEON_PROJECT_ID=your-project-id\n```\n\n### Airtable\n\n1. Go to [Airtable Account](https://airtable.com/account)\n2. Create personal access token or OAuth app\n3. For OAuth: [Airtable OAuth](https://airtable.com/create/oauth)\n\n```env\nAIRTABLE_API_KEY=your-api-key\n# Or for OAuth:\nAIRTABLE_CLIENT_ID=your-client-id\nAIRTABLE_CLIENT_SECRET=your-client-secret\n```\n\n### Snowflake\n\nUses account credentials (key-pair or password).\n\n1. Get your Snowflake account identifier\n2. Create a user with appropriate permissions\n3. (Optional) Set up key-pair authentication\n\n```env\nSNOWFLAKE_ACCOUNT=your-account-identifier\nSNOWFLAKE_USERNAME=your-username\nSNOWFLAKE_PASSWORD=your-password\nSNOWFLAKE_WAREHOUSE=your-warehouse\nSNOWFLAKE_DATABASE=your-database\n```\n\n---\n\n## Cloud & Storage\n\n### AWS\n\nUses IAM credentials.\n\n1. Go to [AWS IAM Console](https://console.aws.amazon.com/iam/)\n2. Create a new IAM user with programmatic access\n3. Attach policies for services you need (S3, EC2, Lambda, etc.)\n\n```env\nAWS_ACCESS_KEY_ID=your-access-key\nAWS_SECRET_ACCESS_KEY=your-secret-key\nAWS_REGION=us-east-1\n```\n\n### Dropbox\n\n1. Go to [Dropbox App Console](https://www.dropbox.com/developers/apps)\n2. Create app with Full Dropbox or App folder access\n3. OAuth2 redirect: `http://localhost:3000/api/auth/dropbox/callback`\n\n```env\nDROPBOX_CLIENT_ID=your-app-key\nDROPBOX_CLIENT_SECRET=your-app-secret\n```\n\n### Box\n\n1. Go to [Box Developer Console](https://app.box.com/developers/console)\n2. Create new app with OAuth 2.0\n3. Redirect URI: `http://localhost:3000/api/auth/box/callback`\n\n```env\nBOX_CLIENT_ID=your-client-id\nBOX_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Finance\n\n### Stripe\n\nUses API key (no OAuth for basic usage).\n\n1. Go to [Stripe Dashboard](https://dashboard.stripe.com/apikeys)\n2. Get your secret key (use test key for development)\n\n```env\nSTRIPE_SECRET_KEY=sk_test_your-secret-key\nSTRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key\n```\n\n### QuickBooks\n\n1. Go to [Intuit Developer](https://developer.intuit.com/)\n2. Create app and get OAuth credentials\n3. Redirect URI: `http://localhost:3000/api/auth/quickbooks/callback`\n\n```env\nQUICKBOOKS_CLIENT_ID=your-client-id\nQUICKBOOKS_CLIENT_SECRET=your-client-secret\n```\n\n### Xero\n\n1. Go to [Xero Developer](https://developer.xero.com/app/manage)\n2. Create app\n3. Redirect URI: `http://localhost:3000/api/auth/xero/callback`\n\n```env\nXERO_CLIENT_ID=your-client-id\nXERO_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Marketing\n\n### Mailchimp\n\n1. Go to [Mailchimp Account API Keys](https://us1.admin.mailchimp.com/account/api/)\n2. For OAuth: Register app at [Mailchimp OAuth](https://admin.mailchimp.com/account/oauth2/)\n3. Redirect: `http://localhost:3000/api/auth/mailchimp/callback`\n\n```env\nMAILCHIMP_CLIENT_ID=your-client-id\nMAILCHIMP_CLIENT_SECRET=your-client-secret\n# Or API key:\nMAILCHIMP_API_KEY=your-api-key-us1\n```\n\n### Twitter/X\n\n1. Go to [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard)\n2. Create project and app\n3. Enable OAuth 2.0\n4. Callback URL: `http://localhost:3000/api/auth/twitter/callback`\n\n```env\nTWITTER_CLIENT_ID=your-client-id\nTWITTER_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## E-commerce\n\n### Shopify\n\n1. Go to [Shopify Partners](https://partners.shopify.com/)\n2. Create new app\n3. App URL and redirect: `http://localhost:3000/api/auth/shopify/callback`\n\n```env\nSHOPIFY_CLIENT_ID=your-api-key\nSHOPIFY_CLIENT_SECRET=your-api-secret\nSHOPIFY_SHOP_NAME=your-store.myshopify.com\n```\n\n---\n\n## AI & Analytics\n\n### Anthropic (Admin API)\n\nFor organization management and usage tracking.\n\n1. Go to [Anthropic Console](https://console.anthropic.com/)\n2. Create Admin API key (requires admin access)\n\n```env\nANTHROPIC_ADMIN_API_KEY=your-admin-api-key\n```\n\n### Mixpanel\n\nUses API key/secret for data export.\n\n1. Go to [Mixpanel Project Settings](https://mixpanel.com/settings/project)\n2. Get Project Token for tracking\n3. Get API Secret for data export\n\n```env\nMIXPANEL_PROJECT_TOKEN=your-project-token\nMIXPANEL_API_SECRET=your-api-secret\n```\n\n---\n\n## Testing Your Setup\n\nAfter configuring credentials:\n\n```bash\n# Start the dev server\nveryfront dev\n\n# Test each integration by visiting:\n# http://localhost:3000/api/auth/{service}\n\n# Check connection status\ncurl http://localhost:3000/api/connections\n```\n\n## Troubleshooting\n\n### Common Issues\n\n| Error | Solution |\n| ---------------------- | -------------------------------------------------------------- |\n| \"Invalid redirect URI\" | Ensure callback URL matches exactly (including trailing slash) |\n| \"Invalid client\" | Check CLIENT_ID is correct and app is published |\n| \"Access denied\" | Verify all required scopes are added |\n| \"Token expired\" | Implement refresh token flow or re-authenticate |\n\n### Debug Mode\n\nEnable debug logging:\n\n```bash\nDEBUG=veryfront:oauth veryfront dev\n```\n\n### Token Storage\n\nBy default, tokens are stored in memory. For production:\n\n1. Implement `TokenStore` interface in `lib/token-store.ts`\n2. Use Redis, database, or encrypted file storage\n3. Handle token refresh automatically\n\n## Production Checklist\n\n- [ ] Update all redirect URIs to production domain\n- [ ] Implement persistent token storage\n- [ ] Set up token encryption\n- [ ] Configure rate limiting\n- [ ] Add error monitoring (Sentry)\n- [ ] Test OAuth flows end-to-end\n- [ ] Review and minimize required scopes\n\n## Need Help?\n\n- Run `veryfront doctor` to diagnose issues\n- Check the [Veryfront Documentation](https://veryfront.com/docs)\n- Join our [Discord community](https://discord.gg/veryfront)\n"
|
|
108
|
+
"SETUP.md": "# Integration Setup Guide\n\nThis guide helps you set up credentials for all 50+ service integrations available in Veryfront.\n\n## Quick Start\n\n```bash\n# Create a new project with integrations\nveryfront init my-app --with ai --integrations slack,github,notion\n\n# Start development\ncd my-app\nveryfront dev\n```\n\nVisit `http://localhost:3000/api/auth/{service}` to connect each service.\n\n---\n\n## Table of Contents\n\n- [Google Services](#google-services) (Gmail, Calendar, Drive, Docs, Sheets)\n- [Microsoft Services](#microsoft-services) (Outlook, Teams, SharePoint, OneDrive)\n- [Atlassian Services](#atlassian-services) (Jira, Confluence)\n- [Communication](#communication) (Slack, Discord, Twilio, Zoom, Webex)\n- [Project Management](#project-management) (Asana, Monday, Trello, ClickUp, Linear, Notion)\n- [Developer Tools](#developer-tools) (GitHub, GitLab, Bitbucket, Figma, Sentry, PostHog)\n- [CRM & Sales](#crm--sales) (Salesforce, HubSpot, Pipedrive, Intercom, Zendesk, Freshdesk)\n- [Databases](#databases) (Supabase, Neon, Airtable, Snowflake)\n- [Cloud & Storage](#cloud--storage) (AWS, Dropbox, Box)\n- [Finance](#finance) (Stripe, QuickBooks, Xero)\n- [Marketing](#marketing) (Mailchimp, Twitter)\n- [E-commerce](#e-commerce) (Shopify)\n- [AI & Analytics](#ai--analytics) (Anthropic, Mixpanel)\n\n---\n\n## Google Services\n\n**Gmail, Calendar, Drive, Docs, Sheets** all use the same Google OAuth credentials.\n\n### Setup Steps\n\n1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)\n2. Create a new project or select existing\n3. Enable required APIs:\n - Gmail API\n - Google Calendar API\n - Google Drive API\n - Google Docs API\n - Google Sheets API\n4. Go to **OAuth consent screen**:\n - User Type: External (or Internal for Workspace)\n - Add scopes for each API you need\n5. Go to **Credentials** > **Create Credentials** > **OAuth client ID**:\n - Application type: Web application\n - Authorized redirect URIs:\n ```\n http://localhost:3000/api/auth/gmail/callback\n http://localhost:3000/api/auth/calendar/callback\n http://localhost:3000/api/auth/drive/callback\n http://localhost:3000/api/auth/docs-google/callback\n http://localhost:3000/api/auth/sheets/callback\n ```\n\n### Environment Variables\n\n```env\nGOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com\nGOOGLE_CLIENT_SECRET=your-client-secret\n```\n\n### Required Scopes by Service\n\n| Service | Scopes |\n| -------- | -------------------------------------------------------------------------------------------------------------------------------- |\n| Gmail | `gmail.readonly`, `gmail.send`, `gmail.modify`, `gmail.labels`, `gmail.compose`, `https://mail.google.com/` for permanent delete |\n| Calendar | `calendar.readonly`, `calendar.events` |\n| Drive | `drive.readonly`, `drive.file` |\n| Docs | `documents.readonly`, `documents` |\n| Sheets | `spreadsheets.readonly`, `spreadsheets` |\n\n---\n\n## Microsoft Services\n\n**Outlook, Teams, SharePoint, OneDrive** use Microsoft OAuth (Azure AD).\n\n### Setup Steps\n\n1. Go to [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade)\n2. Click **New registration**:\n - Name: Your app name\n - Supported account types: Accounts in any organizational directory\n - Redirect URI: Web, `http://localhost:3000/api/auth/outlook/callback`\n3. After creation, go to **Certificates & secrets**:\n - Create a new client secret\n4. Go to **API permissions**:\n - Add Microsoft Graph permissions\n\n### Environment Variables\n\n```env\nMICROSOFT_CLIENT_ID=your-application-client-id\nMICROSOFT_CLIENT_SECRET=your-client-secret\nMICROSOFT_TENANT_ID=common\n```\n\n### Required Scopes by Service\n\n| Service | Scopes |\n| ---------- | ------------------------------------------------------------- |\n| Outlook | `Mail.Read`, `Mail.Send`, `Calendars.ReadWrite` |\n| Teams | `Team.ReadBasic.All`, `Chat.ReadWrite`, `ChannelMessage.Send` |\n| SharePoint | `Sites.Read.All`, `Files.ReadWrite.All` |\n| OneDrive | `Files.Read`, `Files.ReadWrite` |\n\n---\n\n## Atlassian Services\n\n**Jira and Confluence** use Atlassian OAuth 2.0 (3LO).\n\n### Setup Steps\n\n1. Go to [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/)\n2. Click **Create** > **OAuth 2.0 integration**\n3. Configure:\n - Name: Your app name\n - Callback URL: `http://localhost:3000/api/auth/jira/callback`\n4. Add required scopes in **Permissions**\n5. Get your Cloud ID: Visit `https://your-domain.atlassian.net/_edge/tenant_info`\n\n### Environment Variables\n\n```env\nATLASSIAN_CLIENT_ID=your-client-id\nATLASSIAN_CLIENT_SECRET=your-client-secret\nATLASSIAN_CLOUD_ID=your-cloud-id\n```\n\n### Required Scopes\n\n| Service | Scopes |\n| ---------- | --------------------------------------------------------- |\n| Jira | `read:jira-work`, `write:jira-work`, `read:jira-user` |\n| Confluence | `read:confluence-content.all`, `write:confluence-content` |\n\n---\n\n## Communication\n\n### Slack\n\n1. Go to [Slack API Apps](https://api.slack.com/apps)\n2. Click **Create New App** > **From scratch**\n3. Go to **OAuth & Permissions**:\n - Add redirect URL: `http://localhost:3000/api/auth/slack/callback`\n - Add scopes: `channels:history`, `channels:read`, `chat:write`, `groups:history`, `groups:read`, `im:history`, `im:read`, `mpim:history`, `mpim:read`, `users:read`\n4. **Install to Workspace**\n\n```env\nSLACK_CLIENT_ID=your-client-id\nSLACK_CLIENT_SECRET=your-client-secret\n```\n\n### Discord\n\n1. Go to [Discord Developer Portal](https://discord.com/developers/applications)\n2. Create **New Application**\n3. Go to **OAuth2**:\n - Add redirect: `http://localhost:3000/api/auth/discord/callback`\n - Scopes: `identify`, `guilds`, `messages.read`\n\n```env\nDISCORD_CLIENT_ID=your-client-id\nDISCORD_CLIENT_SECRET=your-client-secret\n```\n\n### Twilio (SMS/WhatsApp)\n\n1. Go to [Twilio Console](https://console.twilio.com/)\n2. Get Account SID and Auth Token from dashboard\n3. Get or buy a phone number for sending\n\n```env\nTWILIO_ACCOUNT_SID=your-account-sid\nTWILIO_AUTH_TOKEN=your-auth-token\nTWILIO_PHONE_NUMBER=+1234567890\n```\n\n### Zoom\n\n1. Go to [Zoom App Marketplace](https://marketplace.zoom.us/develop/create)\n2. Create **OAuth App**\n3. Configure redirect: `http://localhost:3000/api/auth/zoom/callback`\n4. Add scopes: `meeting:read`, `meeting:write`, `user:read`\n\n```env\nZOOM_CLIENT_ID=your-client-id\nZOOM_CLIENT_SECRET=your-client-secret\n```\n\n### Webex\n\n1. Go to [Webex for Developers](https://developer.webex.com/my-apps)\n2. Create new integration\n3. Redirect URI: `http://localhost:3000/api/auth/webex/callback`\n4. Scopes: `spark:messages_read`, `spark:messages_write`, `spark:rooms_read`\n\n```env\nWEBEX_CLIENT_ID=your-client-id\nWEBEX_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Project Management\n\n### Asana\n\n1. Go to [Asana Developer Console](https://app.asana.com/0/developer-console)\n2. Create new app\n3. Set redirect URL: `http://localhost:3000/api/auth/asana/callback`\n\n```env\nASANA_CLIENT_ID=your-client-id\nASANA_CLIENT_SECRET=your-client-secret\n```\n\n### Monday.com\n\n1. Go to [Monday Apps](https://auth.monday.com/oauth2/authorize)\n2. Create new app in your account's Developer section\n3. Configure OAuth with redirect: `http://localhost:3000/api/auth/monday/callback`\n\n```env\nMONDAY_CLIENT_ID=your-client-id\nMONDAY_CLIENT_SECRET=your-client-secret\n```\n\n### Trello\n\n1. Go to [Trello Power-Ups Admin](https://trello.com/power-ups/admin)\n2. Create new Power-Up\n3. Configure OAuth redirect: `http://localhost:3000/api/auth/trello/callback`\n\n```env\nTRELLO_API_KEY=your-api-key\nTRELLO_API_SECRET=your-api-secret\n```\n\n### ClickUp\n\n1. Go to [ClickUp API Settings](https://app.clickup.com/settings/apps)\n2. Create new app\n3. Redirect URL: `http://localhost:3000/api/auth/clickup/callback`\n\n```env\nCLICKUP_CLIENT_ID=your-client-id\nCLICKUP_CLIENT_SECRET=your-client-secret\n```\n\n### Linear\n\n1. Go to [Linear Settings > API](https://linear.app/settings/api)\n2. Create OAuth application\n3. Callback URL: `http://localhost:3000/api/auth/linear/callback`\n\n```env\nLINEAR_CLIENT_ID=your-client-id\nLINEAR_CLIENT_SECRET=your-client-secret\n```\n\n### Notion\n\n1. Go to [Notion Integrations](https://www.notion.so/my-integrations)\n2. Create new **public** integration (for OAuth)\n3. Set redirect URI: `http://localhost:3000/api/auth/notion/callback`\n4. **Important**: Share pages with your integration\n\n```env\nNOTION_CLIENT_ID=your-oauth-client-id\nNOTION_CLIENT_SECRET=your-oauth-client-secret\n```\n\n---\n\n## Developer Tools\n\n### GitHub\n\n1. Go to [GitHub Developer Settings](https://github.com/settings/developers)\n2. Create **New OAuth App**\n3. Authorization callback: `http://localhost:3000/api/auth/github/callback`\n\n```env\nGITHUB_CLIENT_ID=your-client-id\nGITHUB_CLIENT_SECRET=your-client-secret\n```\n\n### GitLab\n\n1. Go to [GitLab Applications](https://gitlab.com/-/profile/applications)\n2. Create new application\n3. Redirect URI: `http://localhost:3000/api/auth/gitlab/callback`\n4. Scopes: `read_user`, `read_api`, `read_repository`\n\n```env\nGITLAB_CLIENT_ID=your-application-id\nGITLAB_CLIENT_SECRET=your-secret\n```\n\n### Bitbucket\n\n1. Go to [Bitbucket App Passwords](https://bitbucket.org/account/settings/app-passwords/) or create OAuth consumer\n2. For OAuth: Workspace settings > OAuth consumers\n3. Callback URL: `http://localhost:3000/api/auth/bitbucket/callback`\n\n```env\nBITBUCKET_CLIENT_ID=your-client-id\nBITBUCKET_CLIENT_SECRET=your-client-secret\n```\n\n### Figma\n\n1. Go to [Figma Developers](https://www.figma.com/developers/apps)\n2. Create new app\n3. Callback URL: `http://localhost:3000/api/auth/figma/callback`\n\n```env\nFIGMA_CLIENT_ID=your-client-id\nFIGMA_CLIENT_SECRET=your-client-secret\n```\n\n### Sentry\n\n1. Go to [Sentry Developer Settings](https://sentry.io/settings/developer-settings/)\n2. Create new public integration\n3. Redirect URL: `http://localhost:3000/api/auth/sentry/callback`\n\n```env\nSENTRY_CLIENT_ID=your-client-id\nSENTRY_CLIENT_SECRET=your-client-secret\n```\n\n### PostHog\n\nUses API key authentication (no OAuth).\n\n1. Go to your PostHog project settings\n2. Create a personal API key\n\n```env\nPOSTHOG_API_KEY=phx_your-api-key\nPOSTHOG_HOST=https://app.posthog.com\n```\n\n---\n\n## CRM & Sales\n\n### Salesforce\n\n1. Go to [Salesforce Setup](https://login.salesforce.com/) > App Manager\n2. Create **New Connected App**\n3. Enable OAuth, add callback: `http://localhost:3000/api/auth/salesforce/callback`\n4. Required scopes: `api`, `refresh_token`\n\n```env\nSALESFORCE_CLIENT_ID=your-consumer-key\nSALESFORCE_CLIENT_SECRET=your-consumer-secret\n```\n\n### HubSpot\n\n1. Go to [HubSpot Developers](https://developers.hubspot.com/)\n2. Create app in your developer account\n3. Configure OAuth redirect: `http://localhost:3000/api/auth/hubspot/callback`\n4. Select required scopes\n\n```env\nHUBSPOT_CLIENT_ID=your-client-id\nHUBSPOT_CLIENT_SECRET=your-client-secret\n```\n\n### Pipedrive\n\n1. Go to [Pipedrive Marketplace Manager](https://developers.pipedrive.com/)\n2. Create new app\n3. OAuth redirect: `http://localhost:3000/api/auth/pipedrive/callback`\n\n```env\nPIPEDRIVE_CLIENT_ID=your-client-id\nPIPEDRIVE_CLIENT_SECRET=your-client-secret\n```\n\n### Intercom\n\n1. Go to [Intercom Developer Hub](https://developers.intercom.com/)\n2. Create new app\n3. Configure OAuth: `http://localhost:3000/api/auth/intercom/callback`\n\n```env\nINTERCOM_CLIENT_ID=your-client-id\nINTERCOM_CLIENT_SECRET=your-client-secret\n```\n\n### Zendesk\n\n1. Go to Admin Center > Apps and integrations > APIs > Zendesk API\n2. Create OAuth client\n3. Redirect URL: `http://localhost:3000/api/auth/zendesk/callback`\n\n```env\nZENDESK_CLIENT_ID=your-client-id\nZENDESK_CLIENT_SECRET=your-client-secret\nZENDESK_SUBDOMAIN=your-subdomain\n```\n\n### Freshdesk\n\nUses API key authentication.\n\n1. Go to Profile Settings in Freshdesk\n2. Find your API Key\n\n```env\nFRESHDESK_API_KEY=your-api-key\nFRESHDESK_DOMAIN=your-domain.freshdesk.com\n```\n\n---\n\n## Databases\n\n### Supabase\n\nUses API key (no OAuth needed).\n\n1. Go to your Supabase project dashboard\n2. Go to Settings > API\n3. Copy the `anon` or `service_role` key\n\n```env\nSUPABASE_URL=https://your-project.supabase.co\nSUPABASE_ANON_KEY=your-anon-key\nSUPABASE_SERVICE_ROLE_KEY=your-service-role-key\n```\n\n### Neon\n\nUses API key authentication.\n\n1. Go to [Neon Console](https://console.neon.tech/)\n2. Create API key in Account Settings\n\n```env\nNEON_API_KEY=your-api-key\nNEON_PROJECT_ID=your-project-id\n```\n\n### Airtable\n\n1. Go to [Airtable Account](https://airtable.com/account)\n2. Create personal access token or OAuth app\n3. For OAuth: [Airtable OAuth](https://airtable.com/create/oauth)\n\n```env\nAIRTABLE_API_KEY=your-api-key\n# Or for OAuth:\nAIRTABLE_CLIENT_ID=your-client-id\nAIRTABLE_CLIENT_SECRET=your-client-secret\n```\n\n### Snowflake\n\nUses account credentials (key-pair or password).\n\n1. Get your Snowflake account identifier\n2. Create a user with appropriate permissions\n3. (Optional) Set up key-pair authentication\n\n```env\nSNOWFLAKE_ACCOUNT=your-account-identifier\nSNOWFLAKE_USERNAME=your-username\nSNOWFLAKE_PASSWORD=your-password\nSNOWFLAKE_WAREHOUSE=your-warehouse\nSNOWFLAKE_DATABASE=your-database\n```\n\n---\n\n## Cloud & Storage\n\n### AWS\n\nUses IAM credentials.\n\n1. Go to [AWS IAM Console](https://console.aws.amazon.com/iam/)\n2. Create a new IAM user with programmatic access\n3. Attach policies for services you need (S3, EC2, Lambda, etc.)\n\n```env\nAWS_ACCESS_KEY_ID=your-access-key\nAWS_SECRET_ACCESS_KEY=your-secret-key\nAWS_REGION=us-east-1\n```\n\n### Dropbox\n\n1. Go to [Dropbox App Console](https://www.dropbox.com/developers/apps)\n2. Create app with Full Dropbox or App folder access\n3. OAuth2 redirect: `http://localhost:3000/api/auth/dropbox/callback`\n\n```env\nDROPBOX_CLIENT_ID=your-app-key\nDROPBOX_CLIENT_SECRET=your-app-secret\n```\n\n### Box\n\n1. Go to [Box Developer Console](https://app.box.com/developers/console)\n2. Create new app with OAuth 2.0\n3. Redirect URI: `http://localhost:3000/api/auth/box/callback`\n\n```env\nBOX_CLIENT_ID=your-client-id\nBOX_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Finance\n\n### Stripe\n\nUses API key (no OAuth for basic usage).\n\n1. Go to [Stripe Dashboard](https://dashboard.stripe.com/apikeys)\n2. Get your secret key (use test key for development)\n\n```env\nSTRIPE_SECRET_KEY=sk_test_your-secret-key\nSTRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key\n```\n\n### QuickBooks\n\n1. Go to [Intuit Developer](https://developer.intuit.com/)\n2. Create app and get OAuth credentials\n3. Redirect URI: `http://localhost:3000/api/auth/quickbooks/callback`\n\n```env\nQUICKBOOKS_CLIENT_ID=your-client-id\nQUICKBOOKS_CLIENT_SECRET=your-client-secret\n```\n\n### Xero\n\n1. Go to [Xero Developer](https://developer.xero.com/app/manage)\n2. Create app\n3. Redirect URI: `http://localhost:3000/api/auth/xero/callback`\n\n```env\nXERO_CLIENT_ID=your-client-id\nXERO_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## Marketing\n\n### Mailchimp\n\n1. Go to [Mailchimp Account API Keys](https://us1.admin.mailchimp.com/account/api/)\n2. For OAuth: Register app at [Mailchimp OAuth](https://admin.mailchimp.com/account/oauth2/)\n3. Redirect: `http://localhost:3000/api/auth/mailchimp/callback`\n\n```env\nMAILCHIMP_CLIENT_ID=your-client-id\nMAILCHIMP_CLIENT_SECRET=your-client-secret\n# Or API key:\nMAILCHIMP_API_KEY=your-api-key-us1\n```\n\n### Twitter/X\n\n1. Go to [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard)\n2. Create project and app\n3. Enable OAuth 2.0\n4. Callback URL: `http://localhost:3000/api/auth/twitter/callback`\n\n```env\nTWITTER_CLIENT_ID=your-client-id\nTWITTER_CLIENT_SECRET=your-client-secret\n```\n\n---\n\n## E-commerce\n\n### Shopify\n\n1. Go to [Shopify Partners](https://partners.shopify.com/)\n2. Create new app\n3. App URL and redirect: `http://localhost:3000/api/auth/shopify/callback`\n\n```env\nSHOPIFY_CLIENT_ID=your-api-key\nSHOPIFY_CLIENT_SECRET=your-api-secret\nSHOPIFY_SHOP_NAME=your-store.myshopify.com\n```\n\n---\n\n## AI & Analytics\n\n### Anthropic (Admin API)\n\nFor organization management and usage tracking.\n\n1. Go to [Anthropic Console](https://console.anthropic.com/)\n2. Create Admin API key (requires admin access)\n\n```env\nANTHROPIC_ADMIN_API_KEY=your-admin-api-key\n```\n\n### Mixpanel\n\nUses API key/secret for data export.\n\n1. Go to [Mixpanel Project Settings](https://mixpanel.com/settings/project)\n2. Get Project Token for tracking\n3. Get API Secret for data export\n\n```env\nMIXPANEL_PROJECT_TOKEN=your-project-token\nMIXPANEL_API_SECRET=your-api-secret\n```\n\n---\n\n## Testing Your Setup\n\nAfter configuring credentials:\n\n```bash\n# Start the dev server\nveryfront dev\n\n# Test each integration by visiting:\n# http://localhost:3000/api/auth/{service}\n\n# Check connection status\ncurl http://localhost:3000/api/connections\n```\n\n## Troubleshooting\n\n### Common Issues\n\n| Error | Solution |\n| ---------------------- | -------------------------------------------------------------- |\n| \"Invalid redirect URI\" | Ensure callback URL matches exactly (including trailing slash) |\n| \"Invalid client\" | Check CLIENT_ID is correct and app is published |\n| \"Access denied\" | Verify all required scopes are added |\n| \"Token expired\" | Implement refresh token flow or re-authenticate |\n\n### Debug Mode\n\nEnable debug logging:\n\n```bash\nDEBUG=veryfront:oauth veryfront dev\n```\n\n### Token Storage\n\nBy default, tokens are stored in memory. For production:\n\n1. Implement `TokenStore` interface in `lib/token-store.ts`\n2. Use Redis, database, or encrypted file storage\n3. Handle token refresh automatically\n\n## Production Checklist\n\n- [ ] Update all redirect URIs to production domain\n- [ ] Implement persistent token storage\n- [ ] Set up token encryption\n- [ ] Configure rate limiting\n- [ ] Add error monitoring (Sentry)\n- [ ] Test OAuth flows end-to-end\n- [ ] Review and minimize required scopes\n\n## Need Help?\n\n- Run `veryfront doctor` to diagnose issues\n- Check the [Veryfront Documentation](https://veryfront.com/docs)\n- Join our [Discord community](https://discord.gg/veryfront)\n"
|
|
109
109
|
}
|
|
110
110
|
},
|
|
111
111
|
"integration:airtable": {
|
|
112
112
|
"files": {
|
|
113
113
|
"app/api/auth/airtable/callback/route.ts": "import { airtableConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(airtableConfig, { tokenStore: hybridTokenStore });\n",
|
|
114
114
|
"app/api/auth/airtable/route.ts": "import { airtableConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(airtableConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
|
|
115
|
-
"lib/airtable-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst AIRTABLE_BASE_URL = \"https://api.airtable.com/v0\";\nconst AIRTABLE_META_BASE_URL = \"https://api.airtable.com/v0/meta\";\n\ninterface AirtableResponse<T> {\n records?: T[];\n offset?: string;\n}\n\ninterface AirtableBase {\n id: string;\n name: string;\n permissionLevel: string;\n}\n\ninterface AirtableBaseSchema {\n tables: Array<{\n id: string;\n name: string;\n primaryFieldId: string;\n fields: Array<{\n id: string;\n name: string;\n type: string;\n options?: Record<string, unknown>;\n }>;\n views: Array<{\n id: string;\n name: string;\n type: string;\n }>;\n }>;\n}\n\nexport interface AirtableRecord {\n id: string;\n createdTime: string;\n fields: Record<string, unknown>;\n}\n\nfunction getTokenOrThrow(): string {\n const token = getAccessToken();\n if (token) return token;\n throw new Error(\"Not authenticated with Airtable. Please connect your account.\");\n}\n\nasync function apiFetch<T>(\n baseUrl: string,\n endpoint: string,\n options: RequestInit,\n errorPrefix: string,\n): Promise<T> {\n const token = getTokenOrThrow();\n\n const response = await fetch(`${baseUrl}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(\n `${errorPrefix}: ${response.status} ${error?.error?.message ?? response.statusText}`,\n );\n }\n\n return response.json() as Promise<T>;\n}\n\nfunction airtableFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiFetch<T>(AIRTABLE_BASE_URL, endpoint, options, \"Airtable API error\");\n}\n\nfunction metaFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiFetch<T>(AIRTABLE_META_BASE_URL, endpoint, options, \"Airtable Meta API error\");\n}\n\nexport async function listBases(): Promise<AirtableBase[]> {\n const response = await metaFetch<{ bases: AirtableBase[] }>(\"/bases\");\n return response.bases ?? [];\n}\n\nexport function getBase(baseId: string): Promise<AirtableBaseSchema> {\n return metaFetch<AirtableBaseSchema>(`/bases/${baseId}/tables`);\n}\n\nexport async function listRecords(\n baseId: string,\n tableIdOrName: string,\n options?: {\n fields?: string[];\n filterByFormula?: string;\n maxRecords?: number;\n pageSize?: number;\n sort?: Array<{ field: string; direction: \"asc\" | \"desc\" }>;\n view?: string;\n offset?: string;\n },\n): Promise<{ records: AirtableRecord[]; offset?: string }> {\n const params = new URLSearchParams();\n\n options?.fields?.forEach((field) => params.append(\"fields[]\", field));\n if (options?.filterByFormula) params.append(\"filterByFormula\", options.filterByFormula);\n if (options?.maxRecords) params.append(\"maxRecords\", String(options.maxRecords));\n if (options?.pageSize) params.append(\"pageSize\", String(options.pageSize));\n options?.sort?.forEach((s, i) => {\n params.append(`sort[${i}][field]`, s.field);\n params.append(`sort[${i}][direction]`, s.direction);\n });\n if (options?.view) params.append(\"view\", options.view);\n if (options?.offset) params.append(\"offset\", options.offset);\n\n const queryString = params.toString();\n const endpoint = `/${baseId}/${encodeURIComponent(tableIdOrName)}${\n queryString ? `?${queryString}` : \"\"\n }`;\n\n const response = await airtableFetch<AirtableResponse<AirtableRecord>>(endpoint);\n\n return { records: response.records ?? [], offset: response.offset };\n}\n\nexport function getRecord(\n baseId: string,\n tableIdOrName: string,\n recordId: string,\n): Promise<AirtableRecord> {\n return airtableFetch<AirtableRecord>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`,\n );\n}\n\nexport function createRecord(\n baseId: string,\n tableIdOrName: string,\n fields: Record<string, unknown>,\n): Promise<AirtableRecord> {\n return airtableFetch<AirtableRecord>(`/${baseId}/${encodeURIComponent(tableIdOrName)}`, {\n method: \"POST\",\n body: JSON.stringify({ fields }),\n });\n}\n\nexport async function createRecords(\n baseId: string,\n tableIdOrName: string,\n records: Array<{ fields: Record<string, unknown> }>,\n): Promise<AirtableRecord[]> {\n const response = await airtableFetch<{ records: AirtableRecord[] }>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}`,\n {\n method: \"POST\",\n body: JSON.stringify({ records }),\n },\n );\n\n return response.records;\n}\n\nexport function updateRecord(\n baseId: string,\n tableIdOrName: string,\n recordId: string,\n fields: Record<string, unknown>,\n options?: { destructive?: boolean },\n): Promise<AirtableRecord> {\n return airtableFetch<AirtableRecord>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`,\n {\n method: options?.destructive ? \"PUT\" : \"PATCH\",\n body: JSON.stringify({ fields }),\n },\n );\n}\n\nexport function deleteRecord(\n baseId: string,\n tableIdOrName: string,\n recordId: string,\n): Promise<{ id: string; deleted: boolean }> {\n return airtableFetch<{ id: string; deleted: boolean }>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`,\n { method: \"DELETE\" },\n );\n}\n\nexport function formatFieldValue(value: unknown): string {\n if (value == null) return \"\";\n if (Array.isArray(value)) return value.map((v) => formatFieldValue(v)).join(\", \");\n if (typeof value === \"object\") return JSON.stringify(value);\n return String(value);\n}\n",
|
|
116
|
-
"tools/create-
|
|
115
|
+
"lib/airtable-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst AIRTABLE_BASE_URL = \"https://api.airtable.com/v0\";\nconst AIRTABLE_META_BASE_URL = \"https://api.airtable.com/v0/meta\";\n\ninterface AirtableResponse<T> {\n records?: T[];\n offset?: string;\n}\n\ninterface AirtableBase {\n id: string;\n name: string;\n permissionLevel: string;\n}\n\ninterface AirtableBaseSchema {\n tables: Array<{\n id: string;\n name: string;\n primaryFieldId: string;\n fields: Array<{\n id: string;\n name: string;\n type: string;\n options?: Record<string, unknown>;\n }>;\n views: Array<{\n id: string;\n name: string;\n type: string;\n }>;\n }>;\n}\n\nexport interface AirtableRecord {\n id: string;\n createdTime: string;\n fields: Record<string, unknown>;\n}\n\nexport interface AirtableFieldDefinition {\n name: string;\n type: string;\n description?: string;\n options?: Record<string, unknown>;\n}\n\nexport interface AirtableTableDefinition {\n id: string;\n name: string;\n primaryFieldId: string;\n fields: AirtableFieldDefinition[];\n views: Array<{\n id: string;\n name: string;\n type: string;\n }>;\n}\n\nfunction getTokenOrThrow(): string {\n const token = getAccessToken();\n if (token) return token;\n throw new Error(\"Not authenticated with Airtable. Please connect your account.\");\n}\n\nasync function apiFetch<T>(\n baseUrl: string,\n endpoint: string,\n options: RequestInit,\n errorPrefix: string,\n): Promise<T> {\n const token = getTokenOrThrow();\n\n const response = await fetch(`${baseUrl}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(\n `${errorPrefix}: ${response.status} ${error?.error?.message ?? response.statusText}`,\n );\n }\n\n return response.json() as Promise<T>;\n}\n\nfunction airtableFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiFetch<T>(AIRTABLE_BASE_URL, endpoint, options, \"Airtable API error\");\n}\n\nfunction metaFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiFetch<T>(AIRTABLE_META_BASE_URL, endpoint, options, \"Airtable Meta API error\");\n}\n\nexport async function listBases(): Promise<AirtableBase[]> {\n const response = await metaFetch<{ bases: AirtableBase[] }>(\"/bases\");\n return response.bases ?? [];\n}\n\nexport function getBase(baseId: string): Promise<AirtableBaseSchema> {\n return metaFetch<AirtableBaseSchema>(`/bases/${baseId}/tables`);\n}\n\nexport async function listRecords(\n baseId: string,\n tableIdOrName: string,\n options?: {\n fields?: string[];\n filterByFormula?: string;\n maxRecords?: number;\n pageSize?: number;\n sort?: Array<{ field: string; direction: \"asc\" | \"desc\" }>;\n view?: string;\n offset?: string;\n },\n): Promise<{ records: AirtableRecord[]; offset?: string }> {\n const params = new URLSearchParams();\n\n options?.fields?.forEach((field) => params.append(\"fields[]\", field));\n if (options?.filterByFormula) params.append(\"filterByFormula\", options.filterByFormula);\n if (options?.maxRecords) params.append(\"maxRecords\", String(options.maxRecords));\n if (options?.pageSize) params.append(\"pageSize\", String(options.pageSize));\n options?.sort?.forEach((s, i) => {\n params.append(`sort[${i}][field]`, s.field);\n params.append(`sort[${i}][direction]`, s.direction);\n });\n if (options?.view) params.append(\"view\", options.view);\n if (options?.offset) params.append(\"offset\", options.offset);\n\n const queryString = params.toString();\n const endpoint = `/${baseId}/${encodeURIComponent(tableIdOrName)}${\n queryString ? `?${queryString}` : \"\"\n }`;\n\n const response = await airtableFetch<AirtableResponse<AirtableRecord>>(endpoint);\n\n return { records: response.records ?? [], offset: response.offset };\n}\n\nexport function getRecord(\n baseId: string,\n tableIdOrName: string,\n recordId: string,\n): Promise<AirtableRecord> {\n return airtableFetch<AirtableRecord>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`,\n );\n}\n\nexport function createRecord(\n baseId: string,\n tableIdOrName: string,\n fields: Record<string, unknown>,\n options?: { typecast?: boolean },\n): Promise<AirtableRecord> {\n return airtableFetch<AirtableRecord>(`/${baseId}/${encodeURIComponent(tableIdOrName)}`, {\n method: \"POST\",\n body: JSON.stringify({ fields, typecast: options?.typecast }),\n });\n}\n\nexport async function createRecords(\n baseId: string,\n tableIdOrName: string,\n records: Array<{ fields: Record<string, unknown> }>,\n options?: { typecast?: boolean },\n): Promise<AirtableRecord[]> {\n const response = await airtableFetch<{ records: AirtableRecord[] }>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}`,\n {\n method: \"POST\",\n body: JSON.stringify({ records, typecast: options?.typecast }),\n },\n );\n\n return response.records;\n}\n\nexport function updateRecord(\n baseId: string,\n tableIdOrName: string,\n recordId: string,\n fields: Record<string, unknown>,\n options?: { destructive?: boolean; typecast?: boolean },\n): Promise<AirtableRecord> {\n return airtableFetch<AirtableRecord>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`,\n {\n method: options?.destructive ? \"PUT\" : \"PATCH\",\n body: JSON.stringify({ fields, typecast: options?.typecast }),\n },\n );\n}\n\nexport function deleteRecord(\n baseId: string,\n tableIdOrName: string,\n recordId: string,\n): Promise<{ id: string; deleted: boolean }> {\n return airtableFetch<{ id: string; deleted: boolean }>(\n `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`,\n { method: \"DELETE\" },\n );\n}\n\nexport function createTable(\n baseId: string,\n name: string,\n fields: AirtableFieldDefinition[],\n options?: { description?: string },\n): Promise<AirtableTableDefinition> {\n return metaFetch<AirtableTableDefinition>(`/bases/${baseId}/tables`, {\n method: \"POST\",\n body: JSON.stringify({ name, description: options?.description, fields }),\n });\n}\n\nexport function updateTable(\n baseId: string,\n tableId: string,\n updates: { name?: string; description?: string },\n): Promise<AirtableTableDefinition> {\n return metaFetch<AirtableTableDefinition>(`/bases/${baseId}/tables/${tableId}`, {\n method: \"PATCH\",\n body: JSON.stringify(updates),\n });\n}\n\nexport function createField(\n baseId: string,\n tableId: string,\n field: AirtableFieldDefinition,\n): Promise<AirtableFieldDefinition & { id: string }> {\n return metaFetch<AirtableFieldDefinition & { id: string }>(\n `/bases/${baseId}/tables/${tableId}/fields`,\n {\n method: \"POST\",\n body: JSON.stringify(field),\n },\n );\n}\n\nexport function formatFieldValue(value: unknown): string {\n if (value == null) return \"\";\n if (Array.isArray(value)) return value.map((v) => formatFieldValue(v)).join(\", \");\n if (typeof value === \"object\") return JSON.stringify(value);\n return String(value);\n}\n",
|
|
116
|
+
"tools/create-field.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createField } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"create-field\",\n description:\n \"Create a new field in an Airtable table. Requires schema write permissions.\",\n inputSchema: defineSchema((v) =>\n v.object({\n baseId: v.string().describe(\n 'The ID of the Airtable base (starts with \"app\")',\n ),\n tableId: v.string().describe(\n 'The ID of the Airtable table (starts with \"tbl\")',\n ),\n name: v.string().describe(\"Field name\"),\n type: v.string().describe(\n 'Airtable field type, such as \"singleLineText\"',\n ),\n description: v.string().optional().describe(\"Optional field description\"),\n options: v.record(v.string(), v.unknown()).optional().describe(\n \"Field type-specific options\",\n ),\n })\n )(),\n execute: async ({ baseId, tableId, name, type, description, options }) =>\n createField(baseId, tableId, { name, type, description, options }),\n});\n",
|
|
117
|
+
"tools/create-record.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createRecord } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"create-record\",\n description:\n \"Create a new record in an Airtable table. Provide field names and values as an object. Returns the created record with its ID.\",\n inputSchema: defineSchema((v) => v.object({\n baseId: v.string().describe('The ID of the Airtable base (starts with \"app\")'),\n tableIdOrName: v.string().describe(\"The ID or name of the table\"),\n fields: v\n .record(v.string(), v.unknown())\n .describe(\n 'Object with field names as keys and their values. Field names must match exactly. Example: { \"Name\": \"John Doe\", \"Email\": \"john@example.com\", \"Status\": \"Active\" }',\n ),\n typecast: v.boolean().optional().describe(\"Allow Airtable to typecast field values\"),\n }))(),\n async execute({ baseId, tableIdOrName, fields, typecast }) {\n const record = await createRecord(baseId, tableIdOrName, fields, { typecast });\n\n return { id: record.id, createdTime: record.createdTime, fields: record.fields };\n },\n});\n",
|
|
118
|
+
"tools/create-records.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createRecords } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"create-records\",\n description:\n \"Create multiple records in an Airtable table. Provide an array of record objects with field values. Returns created records with IDs.\",\n inputSchema: defineSchema((v) =>\n v.object({\n baseId: v.string().describe(\n 'The ID of the Airtable base (starts with \"app\")',\n ),\n tableIdOrName: v.string().describe(\"The ID or name of the table\"),\n records: v\n .array(v.object({ fields: v.record(v.string(), v.unknown()) }))\n .min(1)\n .max(10)\n .describe(\n 'Array of 1-10 records to create. Example: [{ fields: { \"Name\": \"Jane\" } }]',\n ),\n typecast: v.boolean().optional().describe(\n \"Allow Airtable to typecast field values\",\n ),\n })\n )(),\n async execute({ baseId, tableIdOrName, records, typecast }) {\n const createdRecords = await createRecords(baseId, tableIdOrName, records, {\n typecast,\n });\n\n return createdRecords.map((record) => ({\n id: record.id,\n createdTime: record.createdTime,\n fields: record.fields,\n }));\n },\n});\n",
|
|
119
|
+
"tools/create-table.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createTable } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"create-table\",\n description:\n \"Create a new table in an Airtable base. Requires schema write permissions and at least one field definition.\",\n inputSchema: defineSchema((v) =>\n v.object({\n baseId: v.string().describe(\n 'The ID of the Airtable base (starts with \"app\")',\n ),\n name: v.string().describe(\"Name for the new table\"),\n description: v.string().optional().describe(\"Optional table description\"),\n fields: v\n .array(v.object({\n name: v.string().describe(\"Field name\"),\n type: v.string().describe(\n 'Airtable field type, such as \"singleLineText\"',\n ),\n description: v.string().optional().describe(\n \"Optional field description\",\n ),\n options: v.record(v.string(), v.unknown()).optional().describe(\n \"Field type-specific options\",\n ),\n }))\n .min(1)\n .describe(\n 'At least one initial field definition. Example: [{ \"name\": \"Name\", \"type\": \"singleLineText\" }]',\n ),\n })\n )(),\n execute: async ({ baseId, name, description, fields }) =>\n createTable(baseId, name, fields, { description }),\n});\n",
|
|
120
|
+
"tools/delete-record.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { deleteRecord } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"delete-record\",\n description:\n \"Delete an Airtable record from a table. Returns Airtable's deletion confirmation.\",\n inputSchema: defineSchema((v) =>\n v.object({\n baseId: v.string().describe(\n 'The ID of the Airtable base (starts with \"app\")',\n ),\n tableIdOrName: v.string().describe(\"The ID or name of the table\"),\n recordId: v.string().describe(\n 'The ID of the record to delete (starts with \"rec\")',\n ),\n })\n )(),\n execute: async ({ baseId, tableIdOrName, recordId }) =>\n deleteRecord(baseId, tableIdOrName, recordId),\n});\n",
|
|
117
121
|
"tools/get-base.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getBase } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"get-base\",\n description:\n \"Get the schema and structure of an Airtable base, including all tables, fields, and views. Useful for understanding the data model before querying or creating records.\",\n inputSchema: defineSchema((v) => v.object({\n baseId: v.string().describe('The ID of the Airtable base (starts with \"app\")'),\n }))(),\n async execute({ baseId }) {\n const { tables } = await getBase(baseId);\n\n return {\n tables: tables.map((table) => ({\n id: table.id,\n name: table.name,\n primaryFieldId: table.primaryFieldId,\n fields: table.fields.map((field) => ({\n id: field.id,\n name: field.name,\n type: field.type,\n options: field.options,\n })),\n views: table.views.map((view) => ({\n id: view.id,\n name: view.name,\n type: view.type,\n })),\n })),\n };\n },\n});\n",
|
|
118
122
|
"tools/get-record.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getRecord } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"get-record\",\n description:\n \"Get a specific record from an Airtable table by its ID. Returns the full record with all field values.\",\n inputSchema: defineSchema((v) => v.object({\n baseId: v.string().describe('The ID of the Airtable base (starts with \"app\")'),\n tableIdOrName: v.string().describe(\"The ID or name of the table\"),\n recordId: v.string().describe('The ID of the record to retrieve (starts with \"rec\")'),\n }))(),\n execute: async ({ baseId, tableIdOrName, recordId }) =>\n getRecord(baseId, tableIdOrName, recordId),\n});\n",
|
|
119
123
|
"tools/list-bases.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listBases } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"list-bases\",\n description:\n \"List all accessible Airtable bases in the connected account. Returns base IDs, names, and permission levels.\",\n inputSchema: defineSchema((v) => v.object({}))(),\n async execute() {\n return listBases();\n },\n});\n",
|
|
120
|
-
"tools/list-records.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listRecords } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"list-records\",\n description:\n \"List records from an Airtable table. Supports filtering with formulas, sorting, and limiting results. Returns record IDs, creation times, and all field values.\",\n inputSchema: defineSchema((v) => v.object({\n baseId: v.string().describe('The ID of the Airtable base (starts with \"app\")'),\n tableIdOrName: v.string().describe(\"The ID or name of the table\"),\n fields: v\n .array(v.string())\n .optional()\n .describe(\"Specific field names to return (returns all fields if not specified)\"),\n filterByFormula: v\n .string()\n .optional()\n .describe('Airtable formula to filter records (e.g., \"{Status} = \\'Done\\'\")'),\n maxRecords: v.number().min(1).max(100).optional().describe(\"Maximum number of records to return\"),\n sort: v\n .array(\n v.object({\n field: v.string().describe(\"Field name to sort by\"),\n direction: v.enum([\"asc\", \"desc\"]).describe(\"Sort direction\"),\n }),\n )\n .optional()\n .describe(\"Array of sort specifications\"),\n view: v.string().optional().describe(\"Name of a view to use for filtering and sorting\"),\n }))(),\n async execute({ baseId, tableIdOrName, fields, filterByFormula, maxRecords, sort, view }) {\n const { records, offset } = await listRecords(baseId, tableIdOrName, {\n fields,\n filterByFormula,\n maxRecords,\n pageSize: maxRecords,\n sort,\n view,\n });\n\n return {\n records: records.map((record) => ({\n id: record.id,\n createdTime: record.createdTime,\n fields: record.fields,\n })),\n count: records.length,\n hasMore: Boolean(offset),\n };\n },\n});\n"
|
|
124
|
+
"tools/list-records.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listRecords } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"list-records\",\n description:\n \"List records from an Airtable table. Supports filtering with formulas, sorting, and limiting results. Returns record IDs, creation times, and all field values.\",\n inputSchema: defineSchema((v) => v.object({\n baseId: v.string().describe('The ID of the Airtable base (starts with \"app\")'),\n tableIdOrName: v.string().describe(\"The ID or name of the table\"),\n fields: v\n .array(v.string())\n .optional()\n .describe(\"Specific field names to return (returns all fields if not specified)\"),\n filterByFormula: v\n .string()\n .optional()\n .describe('Airtable formula to filter records (e.g., \"{Status} = \\'Done\\'\")'),\n maxRecords: v.number().min(1).max(100).optional().describe(\"Maximum number of records to return\"),\n sort: v\n .array(\n v.object({\n field: v.string().describe(\"Field name to sort by\"),\n direction: v.enum([\"asc\", \"desc\"]).describe(\"Sort direction\"),\n }),\n )\n .optional()\n .describe(\"Array of sort specifications\"),\n view: v.string().optional().describe(\"Name of a view to use for filtering and sorting\"),\n }))(),\n async execute({ baseId, tableIdOrName, fields, filterByFormula, maxRecords, sort, view }) {\n const { records, offset } = await listRecords(baseId, tableIdOrName, {\n fields,\n filterByFormula,\n maxRecords,\n pageSize: maxRecords,\n sort,\n view,\n });\n\n return {\n records: records.map((record) => ({\n id: record.id,\n createdTime: record.createdTime,\n fields: record.fields,\n })),\n count: records.length,\n hasMore: Boolean(offset),\n };\n },\n});\n",
|
|
125
|
+
"tools/update-record.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { updateRecord } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"update-record\",\n description:\n \"Update fields on an existing Airtable record. Returns the updated record with all visible fields.\",\n inputSchema: defineSchema((v) =>\n v.object({\n baseId: v.string().describe(\n 'The ID of the Airtable base (starts with \"app\")',\n ),\n tableIdOrName: v.string().describe(\"The ID or name of the table\"),\n recordId: v.string().describe(\n 'The ID of the record to update (starts with \"rec\")',\n ),\n fields: v\n .record(v.string(), v.unknown())\n .describe(\n 'Field values to update. Field names must match exactly. Example: { \"Status\": \"Done\" }',\n ),\n typecast: v.boolean().optional().describe(\n \"Allow Airtable to typecast field values\",\n ),\n })\n )(),\n async execute({ baseId, tableIdOrName, recordId, fields, typecast }) {\n const record = await updateRecord(baseId, tableIdOrName, recordId, fields, {\n typecast,\n });\n\n return {\n id: record.id,\n createdTime: record.createdTime,\n fields: record.fields,\n };\n },\n});\n",
|
|
126
|
+
"tools/update-table.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { updateTable } from \"../../lib/airtable-client.ts\";\n\nexport default tool({\n id: \"update-table\",\n description:\n \"Update Airtable table metadata, such as name or description. Uses the table ID for stable updates.\",\n inputSchema: defineSchema((v) =>\n v.object({\n baseId: v.string().describe(\n 'The ID of the Airtable base (starts with \"app\")',\n ),\n tableId: v.string().describe(\n 'The ID of the Airtable table (starts with \"tbl\")',\n ),\n name: v.string().optional().describe(\"New table name\"),\n description: v.string().optional().describe(\"New table description\"),\n })\n )(),\n execute: async ({ baseId, tableId, name, description }) =>\n updateTable(baseId, tableId, { name, description }),\n});\n"
|
|
121
127
|
}
|
|
122
128
|
},
|
|
123
129
|
"integration:anthropic": {
|
|
@@ -136,11 +142,16 @@ export default {
|
|
|
136
142
|
".env.example": "# Asana OAuth Configuration\n# Get your credentials from https://app.asana.com/0/developer-console\nASANA_CLIENT_ID=your-client-id\nASANA_CLIENT_SECRET=your-client-secret\n",
|
|
137
143
|
"app/api/auth/asana/callback/route.ts": "import { asanaConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(asanaConfig, { tokenStore: hybridTokenStore });\n",
|
|
138
144
|
"app/api/auth/asana/route.ts": "import { asanaConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(asanaConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
|
|
139
|
-
"lib/asana-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst ASANA_BASE_URL = \"https://app.asana.com/api/1.0\";\n\ninterface AsanaResponse<T> {\n data: T;\n next_page?: { offset: string } | null;\n}\n\ninterface AsanaTask {\n gid: string;\n name: string;\n notes: string;\n completed: boolean;\n due_on: string | null;\n assignee: { gid: string; name: string } | null;\n projects: Array<{ gid: string; name: string }>;\n created_at: string;\n modified_at: string;\n}\n\ninterface AsanaProject {\n gid: string;\n name: string;\n notes: string;\n workspace: { gid: string; name: string };\n created_at: string;\n modified_at: string;\n}\n\ninterface AsanaWorkspace {\n gid: string;\n name: string;\n}\n\nasync function asanaFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Asana. Please connect your account.\");\n }\n\n const response = await fetch(`${ASANA_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (response.ok) {\n return response.json();\n }\n\n let error: unknown = {};\n try {\n error = await response.json();\n } catch {\n // ignore JSON parse errors\n }\n\n const message =\n (error as { errors?: Array<{ message?: string }> })?.errors?.[0]?.message ?? response.statusText;\n\n throw new Error(`Asana API error: ${response.status} ${message}`);\n}\n\nexport async function listWorkspaces(): Promise<AsanaWorkspace[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaWorkspace[]>>(\"/workspaces\");\n return data;\n}\n\nexport async function listProjects(workspaceGid: string): Promise<AsanaProject[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaProject[]>>(\n `/workspaces/${workspaceGid}/projects?opt_fields=name,notes,created_at,modified_at`,\n );\n return data;\n}\n\nexport async function listTasks(options: {\n projectGid?: string;\n assigneeGid?: string;\n workspaceGid?: string;\n completedSince?: string;\n}): Promise<AsanaTask[]> {\n const params = new URLSearchParams({\n opt_fields: \"name,notes,completed,due_on,assignee.name,projects.name,created_at,modified_at\",\n });\n\n if (options.completedSince) {\n params.set(\"completed_since\", options.completedSince);\n }\n\n let endpoint = \"/tasks\";\n if (options.projectGid) {\n endpoint = `/projects/${options.projectGid}/tasks`;\n } else if (options.assigneeGid && options.workspaceGid) {\n params.set(\"assignee\", options.assigneeGid);\n params.set(\"workspace\", options.workspaceGid);\n }\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask[]>>(`${endpoint}?${params}`);\n return data;\n}\n\nexport async function getTask(taskGid: string): Promise<AsanaTask> {\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(\n `/tasks/${taskGid}?opt_fields=name,notes,completed,due_on,assignee.name,projects.name,created_at,modified_at`,\n );\n return data;\n}\n\nexport async function createTask(options: {\n projectGid: string;\n name: string;\n notes?: string;\n dueOn?: string;\n assigneeGid?: string;\n}): Promise<AsanaTask> {\n const body: Record<string, unknown> = {\n name: options.name,\n projects: [options.projectGid],\n };\n\n if (options.notes) body.notes = options.notes;\n if (options.dueOn) body.due_on = options.dueOn;\n if (options.assigneeGid) body.assignee = options.assigneeGid;\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(\"/tasks\", {\n method: \"POST\",\n body: JSON.stringify({ data: body }),\n });\n\n return data;\n}\n\nexport async function updateTask(\n taskGid: string,\n updates: {\n name?: string;\n notes?: string;\n completed?: boolean;\n dueOn?: string;\n assigneeGid?: string;\n },\n): Promise<AsanaTask> {\n const body: Record<string, unknown> = {};\n\n if (updates.name !== undefined) body.name = updates.name;\n if (updates.notes !== undefined) body.notes = updates.notes;\n if (updates.completed !== undefined) body.completed = updates.completed;\n if (updates.dueOn !== undefined) body.due_on = updates.dueOn;\n if (updates.assigneeGid !== undefined) body.assignee = updates.assigneeGid;\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(`/tasks/${taskGid}`, {\n method: \"PUT\",\n body: JSON.stringify({ data: body }),\n });\n\n return data;\n}\n\nexport async function getMe(): Promise<{ gid: string; name: string; email: string }> {\n const { data } = await asanaFetch<AsanaResponse<{ gid: string; name: string; email: string }>>(\n \"/users/me\",\n );\n return data;\n}\n",
|
|
145
|
+
"lib/asana-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst ASANA_BASE_URL = \"https://app.asana.com/api/1.0\";\n\ninterface AsanaResponse<T> {\n data: T;\n next_page?: { offset: string } | null;\n}\n\ninterface AsanaTask {\n gid: string;\n name: string;\n notes: string;\n completed: boolean;\n due_on: string | null;\n assignee: { gid: string; name: string } | null;\n projects: Array<{ gid: string; name: string }>;\n created_at: string;\n modified_at: string;\n}\n\ninterface AsanaProject {\n gid: string;\n name: string;\n notes: string;\n workspace: { gid: string; name: string };\n created_at: string;\n modified_at: string;\n}\n\ninterface AsanaWorkspace {\n gid: string;\n name: string;\n}\n\nasync function asanaFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Asana. Please connect your account.\");\n }\n\n const response = await fetch(`${ASANA_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (response.ok) {\n return response.json();\n }\n\n let error: unknown = {};\n try {\n error = await response.json();\n } catch {\n // ignore JSON parse errors\n }\n\n const message =\n (error as { errors?: Array<{ message?: string }> })?.errors?.[0]?.message ?? response.statusText;\n\n throw new Error(`Asana API error: ${response.status} ${message}`);\n}\n\nexport async function listWorkspaces(): Promise<AsanaWorkspace[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaWorkspace[]>>(\"/workspaces\");\n return data;\n}\n\nexport async function listProjects(workspaceGid: string): Promise<AsanaProject[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaProject[]>>(\n `/workspaces/${workspaceGid}/projects?opt_fields=name,notes,created_at,modified_at`,\n );\n return data;\n}\n\nexport async function listTasks(options: {\n projectGid?: string;\n assigneeGid?: string;\n workspaceGid?: string;\n completedSince?: string;\n}): Promise<AsanaTask[]> {\n const params = new URLSearchParams({\n opt_fields: \"name,notes,completed,due_on,assignee.name,projects.name,created_at,modified_at\",\n });\n\n if (options.completedSince) {\n params.set(\"completed_since\", options.completedSince);\n }\n\n let endpoint = \"/tasks\";\n if (options.projectGid) {\n endpoint = `/projects/${options.projectGid}/tasks`;\n } else if (options.assigneeGid && options.workspaceGid) {\n params.set(\"assignee\", options.assigneeGid);\n params.set(\"workspace\", options.workspaceGid);\n }\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask[]>>(`${endpoint}?${params}`);\n return data;\n}\n\nexport async function getTask(taskGid: string): Promise<AsanaTask> {\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(\n `/tasks/${taskGid}?opt_fields=name,notes,completed,due_on,assignee.name,projects.name,created_at,modified_at`,\n );\n return data;\n}\n\nexport async function createTask(options: {\n projectGid: string;\n name: string;\n notes?: string;\n dueOn?: string;\n assigneeGid?: string;\n}): Promise<AsanaTask> {\n const body: Record<string, unknown> = {\n name: options.name,\n projects: [options.projectGid],\n };\n\n if (options.notes) body.notes = options.notes;\n if (options.dueOn) body.due_on = options.dueOn;\n if (options.assigneeGid) body.assignee = options.assigneeGid;\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(\"/tasks\", {\n method: \"POST\",\n body: JSON.stringify({ data: body }),\n });\n\n return data;\n}\n\nexport async function updateTask(\n taskGid: string,\n updates: {\n name?: string;\n notes?: string;\n completed?: boolean;\n dueOn?: string;\n assigneeGid?: string;\n },\n): Promise<AsanaTask> {\n const body: Record<string, unknown> = {};\n\n if (updates.name !== undefined) body.name = updates.name;\n if (updates.notes !== undefined) body.notes = updates.notes;\n if (updates.completed !== undefined) body.completed = updates.completed;\n if (updates.dueOn !== undefined) body.due_on = updates.dueOn;\n if (updates.assigneeGid !== undefined) body.assignee = updates.assigneeGid;\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(`/tasks/${taskGid}`, {\n method: \"PUT\",\n body: JSON.stringify({ data: body }),\n });\n\n return data;\n}\n\nexport async function getMe(): Promise<{ gid: string; name: string; email: string }> {\n const { data } = await asanaFetch<AsanaResponse<{ gid: string; name: string; email: string }>>(\n \"/users/me\",\n );\n return data;\n}\n\ninterface AsanaUser {\n gid: string;\n name: string;\n email?: string;\n}\n\ninterface AsanaTeam {\n gid: string;\n name: string;\n description?: string;\n}\n\ninterface AsanaStory {\n gid: string;\n type: string;\n text?: string;\n created_at: string;\n created_by?: { gid: string; name: string };\n}\n\nexport async function listUsers(options: {\n workspaceGid: string;\n teamGid?: string;\n}): Promise<AsanaUser[]> {\n const params = new URLSearchParams({\n workspace: options.workspaceGid,\n opt_fields: \"gid,name,email\",\n });\n\n if (options.teamGid) params.set(\"team\", options.teamGid);\n\n const { data } = await asanaFetch<AsanaResponse<AsanaUser[]>>(`/users?${params}`);\n return data;\n}\n\nexport async function listTeams(workspaceGid: string): Promise<AsanaTeam[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaTeam[]>>(\n `/workspaces/${workspaceGid}/teams?opt_fields=gid,name,description`,\n );\n return data;\n}\n\nexport async function addTaskComment(options: {\n taskGid: string;\n text: string;\n}): Promise<AsanaStory> {\n const { data } = await asanaFetch<AsanaResponse<AsanaStory>>(\n `/tasks/${options.taskGid}/stories`,\n {\n method: \"POST\",\n body: JSON.stringify({ data: { text: options.text } }),\n },\n );\n return data;\n}\n\nexport async function listTaskComments(taskGid: string): Promise<AsanaStory[]> {\n const params = new URLSearchParams({\n opt_fields: \"gid,type,text,created_at,created_by.name\",\n });\n const { data } = await asanaFetch<AsanaResponse<AsanaStory[]>>(\n `/tasks/${taskGid}/stories?${params}`,\n );\n return data.filter((story) => story.type === \"comment\");\n}\n",
|
|
146
|
+
"tools/add-task-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { addTaskComment } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"add-task-comment\",\n description: \"Add a comment to an Asana task.\",\n inputSchema: defineSchema((v) => v.object({\n taskGid: v.string().describe(\"Asana task GID\"),\n text: v.string().min(1).describe(\"Comment text\"),\n }))(),\n async execute({ taskGid, text }) {\n const story = await addTaskComment({ taskGid, text });\n return {\n gid: story.gid,\n type: story.type,\n text: story.text,\n createdAt: story.created_at,\n createdBy: story.created_by?.name,\n };\n },\n});\n",
|
|
140
147
|
"tools/create-task.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createTask } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"create-task\",\n description: \"Create a new task in an Asana project.\",\n inputSchema: defineSchema((v) => v.object({\n projectGid: v.string().describe(\"The GID of the project to create the task in\"),\n name: v.string().describe(\"The name/title of the task\"),\n notes: v.string().optional().describe(\"Description or notes for the task\"),\n dueOn: v.string().optional().describe(\"Due date in YYYY-MM-DD format\"),\n assigneeGid: v.string().optional().describe(\"GID of the user to assign the task to\"),\n }))(),\n async execute({ projectGid, name, notes, dueOn, assigneeGid }) {\n const task = await createTask({\n projectGid,\n name,\n notes,\n dueOn,\n assigneeGid,\n });\n\n return {\n success: true,\n task: {\n gid: task.gid,\n name: task.name,\n dueOn: task.due_on,\n assignee: task.assignee?.name,\n },\n };\n },\n});\n",
|
|
141
148
|
"tools/get-task.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getTask } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"get-task\",\n description: \"Get details of a specific Asana task by its GID.\",\n inputSchema: defineSchema((v) => v.object({\n taskGid: v.string().describe(\"The GID of the task to retrieve\"),\n }))(),\n async execute({ taskGid }) {\n const task = await getTask(taskGid);\n\n return {\n gid: task.gid,\n name: task.name,\n notes: task.notes,\n completed: task.completed,\n dueOn: task.due_on,\n assignee: task.assignee?.name,\n projects: task.projects.map(({ gid, name }) => ({ gid, name })),\n createdAt: task.created_at,\n modifiedAt: task.modified_at,\n };\n },\n});\n",
|
|
142
149
|
"tools/list-projects.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listProjects, listWorkspaces } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-projects\",\n description: \"List all projects in the Asana workspace.\",\n inputSchema: defineSchema((v) => v.object({\n limit: v\n .number()\n .min(1)\n .max(50)\n .default(20)\n .describe(\"Maximum number of projects to return\"),\n }))(),\n async execute({ limit }) {\n const [workspace] = await listWorkspaces();\n\n if (!workspace) {\n return { projects: [], message: \"No workspaces found\" };\n }\n\n const projects = await listProjects(workspace.gid);\n\n return projects.slice(0, limit).map(({ gid, name, notes, created_at }) => ({\n gid,\n name,\n notes,\n createdAt: created_at,\n }));\n },\n});\n",
|
|
150
|
+
"tools/list-task-comments.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listTaskComments } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-task-comments\",\n description: \"List comment stories for an Asana task.\",\n inputSchema: defineSchema((v) => v.object({\n taskGid: v.string().describe(\"Asana task GID\"),\n }))(),\n async execute({ taskGid }) {\n const comments = await listTaskComments(taskGid);\n return comments.map((story) => ({\n gid: story.gid,\n text: story.text,\n createdAt: story.created_at,\n createdBy: story.created_by?.name,\n }));\n },\n});\n",
|
|
143
151
|
"tools/list-tasks.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getMe, listTasks, listWorkspaces } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-tasks\",\n description:\n \"List tasks from Asana. Can filter by project or get tasks assigned to the current user.\",\n inputSchema: defineSchema((v) => v.object({\n projectGid: v.string().optional().describe(\"Project GID to list tasks from\"),\n assignedToMe: v\n .boolean()\n .default(false)\n .describe(\"List tasks assigned to the current user\"),\n includeCompleted: v.boolean().default(false).describe(\"Include completed tasks\"),\n limit: v.number().min(1).max(50).default(20).describe(\"Maximum number of tasks to return\"),\n }))(),\n async execute({ projectGid, assignedToMe, includeCompleted, limit }) {\n const completedSince = includeCompleted ? undefined : \"now\";\n\n if (!assignedToMe && !projectGid) {\n return {\n tasks: [],\n message: \"Please specify either a projectGid or set assignedToMe to true\",\n };\n }\n\n let tasks;\n\n if (assignedToMe) {\n const me = await getMe();\n const workspaces = await listWorkspaces();\n const workspaceGid = workspaces[0]?.gid;\n\n if (!workspaceGid) {\n return { tasks: [], message: \"No workspaces found\" };\n }\n\n tasks = await listTasks({\n assigneeGid: me.gid,\n workspaceGid,\n completedSince,\n });\n } else {\n tasks = await listTasks({\n projectGid,\n completedSince,\n });\n }\n\n return tasks.slice(0, limit).map((task) => ({\n gid: task.gid,\n name: task.name,\n completed: task.completed,\n dueOn: task.due_on,\n assignee: task.assignee?.name,\n projects: task.projects.map((p) => p.name),\n }));\n },\n});\n",
|
|
152
|
+
"tools/list-teams.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listTeams } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-teams\",\n description: \"List teams in an Asana workspace.\",\n inputSchema: defineSchema((v) => v.object({\n workspaceGid: v.string().describe(\"Asana workspace GID\"),\n }))(),\n async execute({ workspaceGid }) {\n const teams = await listTeams(workspaceGid);\n return teams.map(({ gid, name, description }) => ({ gid, name, description }));\n },\n});\n",
|
|
153
|
+
"tools/list-users.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listUsers } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-users\",\n description: \"List users in an Asana workspace.\",\n inputSchema: defineSchema((v) => v.object({\n workspaceGid: v.string().describe(\"Asana workspace GID\"),\n teamGid: v.string().optional().describe(\"Optional Asana team GID\"),\n }))(),\n async execute({ workspaceGid, teamGid }) {\n const users = await listUsers({ workspaceGid, teamGid });\n return users.map(({ gid, name, email }) => ({ gid, name, email }));\n },\n});\n",
|
|
154
|
+
"tools/list-workspaces.ts": "import { tool } from \"veryfront/tool\";\nimport { listWorkspaces } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-workspaces\",\n description: \"List Asana workspaces accessible to the authenticated user.\",\n async execute() {\n const workspaces = await listWorkspaces();\n return workspaces.map(({ gid, name }) => ({ gid, name }));\n },\n});\n",
|
|
144
155
|
"tools/update-task.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { updateTask } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"update-task\",\n description: \"Update an existing Asana task.\",\n inputSchema: defineSchema((v) => v.object({\n taskGid: v.string().describe(\"The GID of the task to update\"),\n name: v.string().optional().describe(\"New name/title for the task\"),\n notes: v.string().optional().describe(\"New description or notes\"),\n completed: v.boolean().optional().describe(\"Mark the task as completed or not\"),\n dueOn: v.string().optional().describe(\"New due date in YYYY-MM-DD format\"),\n assigneeGid: v.string().optional().describe(\"GID of the user to reassign the task to\"),\n }))(),\n async execute({ taskGid, ...updates }) {\n const task = await updateTask(taskGid, updates);\n\n return {\n success: true,\n task: {\n gid: task.gid,\n name: task.name,\n completed: task.completed,\n dueOn: task.due_on,\n assignee: task.assignee?.name,\n },\n };\n },\n});\n"
|
|
145
156
|
}
|
|
146
157
|
},
|
|
@@ -172,10 +183,13 @@ export default {
|
|
|
172
183
|
".env.example": "# =============================================================================\n# Google Calendar Integration Setup\n# =============================================================================\n#\n# STEP 1: Create a Google Cloud Project\n# Visit: https://console.cloud.google.com/projectcreate\n#\n# STEP 2: Enable the Google Calendar API\n# Visit: https://console.cloud.google.com/apis/library/calendar-json.googleapis.com\n# Click \"Enable\" to activate the Calendar API for your project\n#\n# STEP 3: Configure OAuth Consent Screen\n# Visit: https://console.cloud.google.com/apis/credentials/consent\n# - Choose \"External\" user type (or \"Internal\" for Workspace)\n# - Fill in app name, support email\n# - Add scopes: calendar.readonly, calendar.events\n# - Add your email as a test user (required for development)\n#\n# STEP 4: Create OAuth Credentials\n# Visit: https://console.cloud.google.com/apis/credentials\n# - Click \"Create Credentials\" > \"OAuth client ID\"\n# - Application type: \"Web application\"\n# - Add Authorized redirect URI: http://localhost:3000/api/auth/calendar/callback\n# - Copy the Client ID and Client Secret below\n#\n# =============================================================================\n\nGOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com\nGOOGLE_CLIENT_SECRET=your-client-secret\n",
|
|
173
184
|
"app/api/auth/calendar/callback/route.ts": "/**\n * Calendar OAuth Callback\n *\n * Handles the OAuth callback from Google and stores the tokens.\n */\n\nimport { calendarConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(calendarConfig, { tokenStore: hybridTokenStore });\n",
|
|
174
185
|
"app/api/auth/calendar/route.ts": "import { calendarConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(calendarConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
|
|
175
|
-
"lib/calendar-client.ts": "/**\n * Google Calendar API Client\n *\n * Provides a type-safe interface to Google Calendar API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") {\n // @ts-ignore - Deno global\n return Deno.env.get(key);\n }\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) {\n // @ts-ignore - process global\n return process.env[key];\n }\n\n return undefined;\n}\n\nconst CALENDAR_API_BASE = \"https://www.googleapis.com/calendar/v3\";\n\nexport interface CalendarEvent {\n id: string;\n summary: string;\n description?: string;\n location?: string;\n start: {\n dateTime?: string;\n date?: string;\n timeZone?: string;\n };\n end: {\n dateTime?: string;\n date?: string;\n timeZone?: string;\n };\n attendees?: Array<{\n email: string;\n responseStatus: \"needsAction\" | \"declined\" | \"tentative\" | \"accepted\";\n displayName?: string;\n }>;\n htmlLink: string;\n status: \"confirmed\" | \"tentative\" | \"cancelled\";\n organizer?: { email: string; displayName?: string };\n}\n\nexport interface CreateEventOptions {\n summary: string;\n description?: string;\n location?: string;\n start: Date | string;\n end: Date | string;\n attendees?: string[];\n timeZone?: string;\n}\n\nexport interface FreeBusySlot {\n start: string;\n end: string;\n}\n\n/**\n * Google Calendar OAuth provider configuration\n */\nexport const calendarOAuthProvider = {\n name: \"calendar\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/calendar.readonly\",\n \"https://www.googleapis.com/auth/calendar.events\",\n ],\n callbackPath: \"/api/auth/calendar/callback\",\n};\n\ntype ListEventsOptions = {\n maxResults?: number;\n timeMin?: Date | string;\n timeMax?: Date | string;\n calendarId?: string;\n};\n\ntype FreeBusyOptions = {\n timeMin: Date | string;\n timeMax: Date | string;\n calendarId?: string;\n};\n\ntype FindFreeSlotsOptions = FreeBusyOptions & {\n durationMinutes: number;\n};\n\ntype CalendarClientShape = {\n listEvents(options?: ListEventsOptions): Promise<CalendarEvent[]>;\n getTodayEvents(): Promise<CalendarEvent[]>;\n createEvent(options: CreateEventOptions, calendarId?: string): Promise<CalendarEvent>;\n getFreeBusy(options: FreeBusyOptions): Promise<FreeBusySlot[]>;\n findFreeSlots(options: FindFreeSlotsOptions): Promise<Array<{ start: Date; end: Date }>>;\n deleteEvent(eventId: string, calendarId?: string): Promise<void>;\n};\n\n/**\n * Create a Calendar client for a specific user\n */\nexport function createCalendarClient(userId: string): CalendarClientShape {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(calendarOAuthProvider, userId, \"calendar\");\n if (!token) {\n throw new Error(\"Calendar not connected. Please connect your Google Calendar first.\");\n }\n return token;\n }\n\n async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${CALENDAR_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Calendar API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n async function listEvents(options: ListEventsOptions = {}): Promise<CalendarEvent[]> {\n const params = new URLSearchParams();\n\n const timeMin = options.timeMin ? new Date(options.timeMin) : new Date();\n params.set(\"timeMin\", timeMin.toISOString());\n\n if (options.timeMax) {\n params.set(\"timeMax\", new Date(options.timeMax).toISOString());\n }\n\n params.set(\"maxResults\", String(options.maxResults ?? 10));\n params.set(\"singleEvents\", \"true\");\n params.set(\"orderBy\", \"startTime\");\n\n const calendarId = encodeURIComponent(options.calendarId ?? \"primary\");\n const result = await apiRequest<{ items: CalendarEvent[] }>(\n `/calendars/${calendarId}/events?${params.toString()}`,\n );\n\n return result.items ?? [];\n }\n\n function getTodayEvents(): Promise<CalendarEvent[]> {\n const today = new Date();\n today.setHours(0, 0, 0, 0);\n\n const tomorrow = new Date(today);\n tomorrow.setDate(tomorrow.getDate() + 1);\n\n return listEvents({ timeMin: today, timeMax: tomorrow, maxResults: 50 });\n }\n\n function createEvent(options: CreateEventOptions, calendarId = \"primary\"): Promise<CalendarEvent> {\n const startDate = typeof options.start === \"string\" ? options.start : options.start.toISOString();\n const endDate = typeof options.end === \"string\" ? options.end : options.end.toISOString();\n const timeZone = options.timeZone ?? \"UTC\";\n\n const event = {\n summary: options.summary,\n description: options.description,\n location: options.location,\n start: { dateTime: startDate, timeZone },\n end: { dateTime: endDate, timeZone },\n attendees: options.attendees?.map((email) => ({ email })),\n };\n\n return apiRequest<CalendarEvent>(`/calendars/${encodeURIComponent(calendarId)}/events`, {\n method: \"POST\",\n body: JSON.stringify(event),\n });\n }\n\n async function getFreeBusy(options: FreeBusyOptions): Promise<FreeBusySlot[]> {\n const calendarId = options.calendarId ?? \"primary\";\n\n const result = await apiRequest<{\n calendars: Record<string, { busy: FreeBusySlot[] }>;\n }>(\"/freeBusy\", {\n method: \"POST\",\n body: JSON.stringify({\n timeMin: new Date(options.timeMin).toISOString(),\n timeMax: new Date(options.timeMax).toISOString(),\n items: [{ id: calendarId }],\n }),\n });\n\n return result.calendars[calendarId]?.busy ?? [];\n }\n\n async function findFreeSlots(\n options: FindFreeSlotsOptions,\n ): Promise<Array<{ start: Date; end: Date }>> {\n const busySlots = await getFreeBusy(options);\n\n const freeSlots: Array<{ start: Date; end: Date }> = [];\n const rangeStart = new Date(options.timeMin);\n const rangeEnd = new Date(options.timeMax);\n const durationMs = options.durationMinutes * 60 * 1000;\n\n let currentStart = rangeStart;\n\n const sortedBusy = busySlots\n .map((s) => ({ start: new Date(s.start), end: new Date(s.end) }))\n .sort((a, b) => a.start.getTime() - b.start.getTime());\n\n for (const busy of sortedBusy) {\n if (busy.start.getTime() - currentStart.getTime() >= durationMs) {\n freeSlots.push({ start: new Date(currentStart), end: new Date(busy.start) });\n }\n\n if (busy.end > currentStart) {\n currentStart = busy.end;\n }\n }\n\n if (rangeEnd.getTime() - currentStart.getTime() >= durationMs) {\n freeSlots.push({ start: new Date(currentStart), end: rangeEnd });\n }\n\n return freeSlots;\n }\n\n async function deleteEvent(eventId: string, calendarId = \"primary\"): Promise<void> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(\n `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,\n {\n method: \"DELETE\",\n headers: { Authorization: `Bearer ${accessToken}` },\n },\n );\n\n if (!response.ok && response.status !== 204) {\n throw new Error(`Failed to delete event: ${response.status}`);\n }\n }\n\n return {\n listEvents,\n getTodayEvents,\n createEvent,\n getFreeBusy,\n findFreeSlots,\n deleteEvent,\n };\n}\n\nexport type CalendarClient = ReturnType<typeof createCalendarClient>;\n",
|
|
186
|
+
"lib/calendar-client.ts": "/**\n * Google Calendar API Client\n *\n * Provides a type-safe interface to Google Calendar API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") {\n // @ts-ignore - Deno global\n return Deno.env.get(key);\n }\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) {\n // @ts-ignore - process global\n return process.env[key];\n }\n\n return undefined;\n}\n\nconst CALENDAR_API_BASE = \"https://www.googleapis.com/calendar/v3\";\n\nexport interface CalendarEvent {\n id: string;\n summary: string;\n description?: string;\n location?: string;\n start: {\n dateTime?: string;\n date?: string;\n timeZone?: string;\n };\n end: {\n dateTime?: string;\n date?: string;\n timeZone?: string;\n };\n attendees?: Array<{\n email: string;\n responseStatus: \"needsAction\" | \"declined\" | \"tentative\" | \"accepted\";\n displayName?: string;\n }>;\n htmlLink: string;\n status: \"confirmed\" | \"tentative\" | \"cancelled\";\n organizer?: { email: string; displayName?: string };\n}\n\nexport interface CreateEventOptions {\n summary: string;\n description?: string;\n location?: string;\n start: Date | string;\n end: Date | string;\n attendees?: string[];\n timeZone?: string;\n}\n\nexport interface UpdateEventOptions {\n summary?: string;\n description?: string;\n location?: string;\n start?: Date | string;\n end?: Date | string;\n attendees?: string[];\n timeZone?: string;\n}\n\nexport interface FreeBusySlot {\n start: string;\n end: string;\n}\n\n/**\n * Google Calendar OAuth provider configuration\n */\nexport const calendarOAuthProvider = {\n name: \"calendar\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/calendar.readonly\",\n \"https://www.googleapis.com/auth/calendar.events\",\n ],\n callbackPath: \"/api/auth/calendar/callback\",\n};\n\ntype ListEventsOptions = {\n maxResults?: number;\n timeMin?: Date | string;\n timeMax?: Date | string;\n calendarId?: string;\n};\n\ntype FreeBusyOptions = {\n timeMin: Date | string;\n timeMax: Date | string;\n calendarId?: string;\n};\n\ntype FindFreeSlotsOptions = FreeBusyOptions & {\n durationMinutes: number;\n};\n\ntype CalendarClientShape = {\n listEvents(options?: ListEventsOptions): Promise<CalendarEvent[]>;\n getTodayEvents(): Promise<CalendarEvent[]>;\n createEvent(\n options: CreateEventOptions,\n calendarId?: string,\n ): Promise<CalendarEvent>;\n updateEvent(\n eventId: string,\n options: UpdateEventOptions,\n calendarId?: string,\n ): Promise<CalendarEvent>;\n getFreeBusy(options: FreeBusyOptions): Promise<FreeBusySlot[]>;\n findFreeSlots(\n options: FindFreeSlotsOptions,\n ): Promise<Array<{ start: Date; end: Date }>>;\n deleteEvent(eventId: string, calendarId?: string): Promise<void>;\n};\n\n/**\n * Create a Calendar client for a specific user\n */\nexport function createCalendarClient(userId: string): CalendarClientShape {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(\n calendarOAuthProvider,\n userId,\n \"calendar\",\n );\n if (!token) {\n throw new Error(\n \"Calendar not connected. Please connect your Google Calendar first.\",\n );\n }\n return token;\n }\n\n async function apiRequest<T>(\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${CALENDAR_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Calendar API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n async function listEvents(\n options: ListEventsOptions = {},\n ): Promise<CalendarEvent[]> {\n const params = new URLSearchParams();\n\n const timeMin = options.timeMin ? new Date(options.timeMin) : new Date();\n params.set(\"timeMin\", timeMin.toISOString());\n\n if (options.timeMax) {\n params.set(\"timeMax\", new Date(options.timeMax).toISOString());\n }\n\n params.set(\"maxResults\", String(options.maxResults ?? 10));\n params.set(\"singleEvents\", \"true\");\n params.set(\"orderBy\", \"startTime\");\n\n const calendarId = encodeURIComponent(options.calendarId ?? \"primary\");\n const result = await apiRequest<{ items: CalendarEvent[] }>(\n `/calendars/${calendarId}/events?${params.toString()}`,\n );\n\n return result.items ?? [];\n }\n\n function getTodayEvents(): Promise<CalendarEvent[]> {\n const today = new Date();\n today.setHours(0, 0, 0, 0);\n\n const tomorrow = new Date(today);\n tomorrow.setDate(tomorrow.getDate() + 1);\n\n return listEvents({ timeMin: today, timeMax: tomorrow, maxResults: 50 });\n }\n\n function createEvent(\n options: CreateEventOptions,\n calendarId = \"primary\",\n ): Promise<CalendarEvent> {\n const startDate = typeof options.start === \"string\"\n ? options.start\n : options.start.toISOString();\n const endDate = typeof options.end === \"string\"\n ? options.end\n : options.end.toISOString();\n const timeZone = options.timeZone ?? \"UTC\";\n\n const event = {\n summary: options.summary,\n description: options.description,\n location: options.location,\n start: { dateTime: startDate, timeZone },\n end: { dateTime: endDate, timeZone },\n attendees: options.attendees?.map((email) => ({ email })),\n };\n\n return apiRequest<CalendarEvent>(\n `/calendars/${encodeURIComponent(calendarId)}/events`,\n {\n method: \"POST\",\n body: JSON.stringify(event),\n },\n );\n }\n\n function updateEvent(\n eventId: string,\n options: UpdateEventOptions,\n calendarId = \"primary\",\n ): Promise<CalendarEvent> {\n const timeZone = options.timeZone ?? \"UTC\";\n const event: Record<string, unknown> = {};\n\n if (options.summary !== undefined) event.summary = options.summary;\n if (options.description !== undefined) {\n event.description = options.description;\n }\n if (options.location !== undefined) event.location = options.location;\n if (options.start !== undefined) {\n const startDate = typeof options.start === \"string\"\n ? options.start\n : options.start.toISOString();\n event.start = { dateTime: startDate, timeZone };\n }\n if (options.end !== undefined) {\n const endDate = typeof options.end === \"string\"\n ? options.end\n : options.end.toISOString();\n event.end = { dateTime: endDate, timeZone };\n }\n if (options.attendees !== undefined) {\n event.attendees = options.attendees.map((email) => ({ email }));\n }\n\n return apiRequest<CalendarEvent>(\n `/calendars/${encodeURIComponent(calendarId)}/events/${\n encodeURIComponent(eventId)\n }?sendUpdates=none`,\n {\n method: \"PATCH\",\n body: JSON.stringify(event),\n },\n );\n }\n\n async function getFreeBusy(\n options: FreeBusyOptions,\n ): Promise<FreeBusySlot[]> {\n const calendarId = options.calendarId ?? \"primary\";\n\n const result = await apiRequest<{\n calendars: Record<string, { busy: FreeBusySlot[] }>;\n }>(\"/freeBusy\", {\n method: \"POST\",\n body: JSON.stringify({\n timeMin: new Date(options.timeMin).toISOString(),\n timeMax: new Date(options.timeMax).toISOString(),\n items: [{ id: calendarId }],\n }),\n });\n\n return result.calendars[calendarId]?.busy ?? [];\n }\n\n async function findFreeSlots(\n options: FindFreeSlotsOptions,\n ): Promise<Array<{ start: Date; end: Date }>> {\n const busySlots = await getFreeBusy(options);\n\n const freeSlots: Array<{ start: Date; end: Date }> = [];\n const rangeStart = new Date(options.timeMin);\n const rangeEnd = new Date(options.timeMax);\n const durationMs = options.durationMinutes * 60 * 1000;\n\n let currentStart = rangeStart;\n\n const sortedBusy = busySlots\n .map((s) => ({ start: new Date(s.start), end: new Date(s.end) }))\n .sort((a, b) => a.start.getTime() - b.start.getTime());\n\n for (const busy of sortedBusy) {\n if (busy.start.getTime() - currentStart.getTime() >= durationMs) {\n freeSlots.push({\n start: new Date(currentStart),\n end: new Date(busy.start),\n });\n }\n\n if (busy.end > currentStart) {\n currentStart = busy.end;\n }\n }\n\n if (rangeEnd.getTime() - currentStart.getTime() >= durationMs) {\n freeSlots.push({ start: new Date(currentStart), end: rangeEnd });\n }\n\n return freeSlots;\n }\n\n async function deleteEvent(\n eventId: string,\n calendarId = \"primary\",\n ): Promise<void> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(\n `${CALENDAR_API_BASE}/calendars/${\n encodeURIComponent(calendarId)\n }/events/${eventId}`,\n {\n method: \"DELETE\",\n headers: { Authorization: `Bearer ${accessToken}` },\n },\n );\n\n if (!response.ok && response.status !== 204) {\n throw new Error(`Failed to delete event: ${response.status}`);\n }\n }\n\n return {\n listEvents,\n getTodayEvents,\n createEvent,\n updateEvent,\n getFreeBusy,\n findFreeSlots,\n deleteEvent,\n };\n}\n\nexport type CalendarClient = ReturnType<typeof createCalendarClient>;\n",
|
|
187
|
+
"lib/user-id.ts": "import type { ToolExecutionContext } from \"veryfront/tool\";\n\nexport function requireUserIdFromContext(\n context?: ToolExecutionContext,\n): string {\n const userId = context?.userId;\n if (!userId) {\n throw new Error(\"Calendar tool execution requires an authenticated user.\");\n }\n return userId;\n}\n",
|
|
176
188
|
"tools/create-event.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"create-event\",\n description: \"Create a new event in Google Calendar\",\n inputSchema: defineSchema((v) => v.object({\n title: v.string().min(1).describe(\"Event title\"),\n startTime: v\n .string()\n .describe(\"Start time in ISO 8601 format (e.g., '2024-01-15T09:00:00')\"),\n endTime: v\n .string()\n .describe(\"End time in ISO 8601 format (e.g., '2024-01-15T10:00:00')\"),\n description: v.string().optional().describe(\"Event description\"),\n location: v.string().optional().describe(\"Event location\"),\n attendees: v\n .array(v.string().email())\n .optional()\n .describe(\"Email addresses of attendees to invite\"),\n timeZone: v\n .string()\n .default(\"UTC\")\n .describe(\"Time zone for the event (e.g., 'America/New_York')\"),\n }))(),\n execute: async (\n { title, startTime, endTime, description, location, attendees, timeZone },\n context,\n ) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const calendar = createCalendarClient(userId);\n const event = await calendar.createEvent({\n summary: title,\n start: startTime,\n end: endTime,\n description,\n location,\n attendees,\n timeZone,\n });\n\n return {\n success: true,\n event: {\n id: event.id,\n title: event.summary,\n start: event.start.dateTime ?? event.start.date,\n end: event.end.dateTime ?? event.end.date,\n url: event.htmlLink,\n location: event.location,\n attendees: event.attendees?.map((a: { email: string }) => a.email) ?? [],\n },\n message: `Event \"${title}\" created successfully.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Calendar not connected. Please connect your Google Calendar.\",\n connectUrl: \"/api/auth/calendar\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
189
|
+
"tools/delete-event.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"delete-event\",\n description: \"Delete a Google Calendar event by ID\",\n inputSchema: defineSchema((v) =>\n v.object({\n eventId: v.string().min(1).describe(\"Event ID to delete\"),\n calendarId: v.string().default(\"primary\").describe(\"Calendar ID\"),\n })\n )(),\n execute: async ({ eventId, calendarId }, context) => {\n const userId = requireUserIdFromContext(context);\n const calendar = createCalendarClient(userId);\n await calendar.deleteEvent(eventId, calendarId);\n\n return {\n success: true,\n eventId,\n message: `Event ${eventId} deleted successfully.`,\n };\n },\n});\n",
|
|
177
190
|
"tools/find-free-time.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype FreeSlot = { start: Date; end: Date };\n\nexport default tool({\n id: \"find-free-time\",\n description: \"Find available time slots in the calendar for scheduling\",\n inputSchema: defineSchema((v) => v.object({\n durationMinutes: v\n .number()\n .min(15)\n .max(480)\n .default(60)\n .describe(\"Duration needed in minutes\"),\n daysToSearch: v\n .number()\n .min(1)\n .max(14)\n .default(7)\n .describe(\"Number of days to search ahead\"),\n workingHoursOnly: v\n .boolean()\n .default(true)\n .describe(\"Only show slots during working hours (9 AM - 6 PM)\"),\n }))(),\n execute: async (\n { durationMinutes, daysToSearch, workingHoursOnly },\n context,\n ): Promise<unknown> => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const calendar = createCalendarClient(userId);\n\n const now = new Date();\n const searchEnd = new Date();\n searchEnd.setDate(searchEnd.getDate() + daysToSearch);\n\n const freeSlots = (await calendar.findFreeSlots({\n timeMin: now,\n timeMax: searchEnd,\n durationMinutes,\n })) as FreeSlot[];\n\n const slots = workingHoursOnly\n ? freeSlots.filter(({ start, end }) => {\n const startHour = start.getHours();\n const endHour = end.getHours();\n return startHour >= 9 && endHour <= 18;\n })\n : freeSlots;\n\n const formattedSlots = slots.slice(0, 10).map(({ start, end }) => {\n const duration = Math.round((end.getTime() - start.getTime()) / 60000);\n\n return {\n start: start.toISOString(),\n end: end.toISOString(),\n durationMinutes: duration,\n date: start.toLocaleDateString(\"en-US\", {\n weekday: \"long\",\n month: \"short\",\n day: \"numeric\",\n }),\n timeRange: `${start.toLocaleTimeString(\"en-US\", {\n hour: \"numeric\",\n minute: \"2-digit\",\n })} - ${end.toLocaleTimeString(\"en-US\", {\n hour: \"numeric\",\n minute: \"2-digit\",\n })}`,\n };\n });\n\n const count = formattedSlots.length;\n\n return {\n freeSlots: formattedSlots,\n count,\n searchCriteria: {\n durationMinutes,\n daysToSearch,\n workingHoursOnly,\n },\n message:\n count > 0\n ? `Found ${count} available slot(s) of ${durationMinutes} minutes or more.`\n : `No free slots of ${durationMinutes} minutes found in the next ${daysToSearch} days.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Calendar not connected. Please connect your Google Calendar.\",\n connectUrl: \"/api/auth/calendar\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
178
|
-
"tools/list-events.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype CalendarEvent = {\n id: string;\n summary: string;\n description?: string;\n location?: string;\n start: { dateTime?: string; date?: string };\n end: { dateTime?: string; date?: string };\n status: string;\n htmlLink: string;\n attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>;\n};\n\nexport default tool({\n id: \"list-events\",\n description: \"List upcoming calendar events. By default shows events from now onwards.\",\n inputSchema: defineSchema((v) => v.object({\n maxResults: v\n .number()\n .min(1)\n .max(100)\n .default(10)\n .describe(\"Maximum number of events to return\"),\n daysAhead: v.number().min(1).max(30).default(7).describe(\"Number of days to look ahead\"),\n todayOnly: v.boolean().default(false).describe(\"Only show events for today\"),\n }))(),\n execute: async ({ maxResults, daysAhead, todayOnly }, context) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const calendar = createCalendarClient(userId);\n\n const events = todayOnly\n ? ((await calendar.getTodayEvents()) as CalendarEvent[])\n : ((await calendar.listEvents({\n maxResults,\n timeMin: new Date(),\n timeMax: new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000),\n })) as CalendarEvent[]);\n\n return {\n events: events.map((event) => ({\n id: event.id,\n title: event.summary,\n description: event.description ?? null,\n location: event.location ?? null,\n start: event.start.dateTime || event.start.date,\n end: event.end.dateTime || event.end.date,\n isAllDay: !event.start.dateTime,\n status: event.status,\n url: event.htmlLink,\n attendees:\n event.attendees?.map((a) => ({\n email: a.email,\n name: a.displayName,\n status: a.responseStatus,\n })) ?? [],\n })),\n count: events.length,\n message: todayOnly\n ? `Found ${events.length} event(s) for today.`\n : `Found ${events.length} event(s) in the next ${daysAhead} days.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Calendar not connected. Please connect your Google Calendar.\",\n connectUrl: \"/api/auth/calendar\",\n };\n }\n throw error;\n }\n },\n});\n"
|
|
191
|
+
"tools/list-events.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype CalendarEvent = {\n id: string;\n summary: string;\n description?: string;\n location?: string;\n start: { dateTime?: string; date?: string };\n end: { dateTime?: string; date?: string };\n status: string;\n htmlLink: string;\n attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>;\n};\n\nexport default tool({\n id: \"list-events\",\n description: \"List upcoming calendar events. By default shows events from now onwards.\",\n inputSchema: defineSchema((v) => v.object({\n maxResults: v\n .number()\n .min(1)\n .max(100)\n .default(10)\n .describe(\"Maximum number of events to return\"),\n daysAhead: v.number().min(1).max(30).default(7).describe(\"Number of days to look ahead\"),\n todayOnly: v.boolean().default(false).describe(\"Only show events for today\"),\n }))(),\n execute: async ({ maxResults, daysAhead, todayOnly }, context) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const calendar = createCalendarClient(userId);\n\n const events = todayOnly\n ? ((await calendar.getTodayEvents()) as CalendarEvent[])\n : ((await calendar.listEvents({\n maxResults,\n timeMin: new Date(),\n timeMax: new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000),\n })) as CalendarEvent[]);\n\n return {\n events: events.map((event) => ({\n id: event.id,\n title: event.summary,\n description: event.description ?? null,\n location: event.location ?? null,\n start: event.start.dateTime || event.start.date,\n end: event.end.dateTime || event.end.date,\n isAllDay: !event.start.dateTime,\n status: event.status,\n url: event.htmlLink,\n attendees:\n event.attendees?.map((a) => ({\n email: a.email,\n name: a.displayName,\n status: a.responseStatus,\n })) ?? [],\n })),\n count: events.length,\n message: todayOnly\n ? `Found ${events.length} event(s) for today.`\n : `Found ${events.length} event(s) in the next ${daysAhead} days.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Calendar not connected. Please connect your Google Calendar.\",\n connectUrl: \"/api/auth/calendar\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
192
|
+
"tools/update-event.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"update-event\",\n description: \"Update an existing Google Calendar event by ID\",\n inputSchema: defineSchema((v) =>\n v.object({\n eventId: v.string().min(1).describe(\"Event ID to update\"),\n calendarId: v.string().default(\"primary\").describe(\"Calendar ID\"),\n title: v.string().optional().describe(\"Updated event title\"),\n startTime: v.string().optional().describe(\n \"Updated start time in ISO 8601 format\",\n ),\n endTime: v.string().optional().describe(\n \"Updated end time in ISO 8601 format\",\n ),\n description: v.string().optional().describe(\"Updated event description\"),\n location: v.string().optional().describe(\"Updated event location\"),\n attendees: v.array(v.string().email()).optional().describe(\n \"Updated attendee email addresses\",\n ),\n timeZone: v.string().default(\"UTC\").describe(\n \"Time zone for updated start/end values\",\n ),\n })\n )(),\n execute: async (\n {\n eventId,\n calendarId,\n title,\n startTime,\n endTime,\n description,\n location,\n attendees,\n timeZone,\n },\n context,\n ) => {\n const userId = requireUserIdFromContext(context);\n const calendar = createCalendarClient(userId);\n const event = await calendar.updateEvent(\n eventId,\n {\n summary: title,\n start: startTime,\n end: endTime,\n description,\n location,\n attendees,\n timeZone,\n },\n calendarId,\n );\n\n return {\n success: true,\n event: {\n id: event.id,\n title: event.summary,\n start: event.start.dateTime ?? event.start.date,\n end: event.end.dateTime ?? event.end.date,\n url: event.htmlLink,\n location: event.location,\n },\n message: `Event \"${event.summary}\" updated successfully.`,\n };\n },\n});\n"
|
|
179
193
|
}
|
|
180
194
|
},
|
|
181
195
|
"integration:confluence": {
|
|
@@ -263,11 +277,17 @@ export default {
|
|
|
263
277
|
"files": {
|
|
264
278
|
"app/api/auth/github/callback/route.ts": "import { createOAuthCallbackHandler, githubConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n async getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(githubConfig, { tokenStore: hybridTokenStore });\n",
|
|
265
279
|
"app/api/auth/github/route.ts": "import { createOAuthInitHandler, githubConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(githubConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
|
|
266
|
-
"lib/github-client.ts": "/**\n * GitHub API Client\n *\n * Provides a type-safe interface to GitHub API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n\n return undefined;\n}\n\nconst GITHUB_API_BASE = \"https://api.github.com\";\n\nexport interface GitHubRepo {\n id: number;\n name: string;\n full_name: string;\n description: string | null;\n private: boolean;\n html_url: string;\n default_branch: string;\n language: string | null;\n stargazers_count: number;\n forks_count: number;\n open_issues_count: number;\n updated_at: string;\n}\n\nexport interface GitHubPullRequest {\n id: number;\n number: number;\n title: string;\n body: string | null;\n state: \"open\" | \"closed\";\n html_url: string;\n user: { login: string; avatar_url: string };\n created_at: string;\n updated_at: string;\n head: { ref: string; sha: string };\n base: { ref: string };\n mergeable: boolean | null;\n additions: number;\n deletions: number;\n changed_files: number;\n draft: boolean;\n labels: Array<{ name: string; color: string }>;\n}\n\nexport interface GitHubIssue {\n id: number;\n number: number;\n title: string;\n body: string | null;\n state: \"open\" | \"closed\";\n html_url: string;\n user: { login: string };\n created_at: string;\n updated_at: string;\n labels: Array<{ name: string; color: string }>;\n assignees: Array<{ login: string }>;\n}\n\nexport interface GitHubCommit {\n sha: string;\n commit: {\n message: string;\n author: { name: string; date: string };\n };\n html_url: string;\n author: { login: string; avatar_url: string } | null;\n}\n\n/**\n * GitHub OAuth provider configuration\n */\nexport const githubOAuthProvider = {\n name: \"github\",\n authorizationUrl: \"https://github.com/login/oauth/authorize\",\n tokenUrl: \"https://github.com/login/oauth/access_token\",\n clientId: getEnv(\"GITHUB_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GITHUB_CLIENT_SECRET\") ?? \"\",\n scopes: [\"repo\", \"read:user\", \"read:org\"],\n callbackPath: \"/api/auth/github/callback\",\n};\n\nexport function createGitHubClient(userId: string): {\n listRepos(options?: {\n sort?: \"created\" | \"updated\" | \"pushed\" | \"full_name\";\n perPage?: number;\n type?: \"all\" | \"owner\" | \"public\" | \"private\" | \"member\";\n }): Promise<GitHubRepo[]>;\n listPullRequests(\n owner: string,\n repo: string,\n options?: { state?: \"open\" | \"closed\" | \"all\"; perPage?: number },\n ): Promise<GitHubPullRequest[]>;\n getPullRequest(owner: string
|
|
267
|
-
"
|
|
268
|
-
"tools/
|
|
269
|
-
"tools/
|
|
270
|
-
"tools/
|
|
280
|
+
"lib/github-client.ts": "/**\n * GitHub API Client\n *\n * Provides a type-safe interface to GitHub API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n\n return undefined;\n}\n\nconst GITHUB_API_BASE = \"https://api.github.com\";\n\nexport interface GitHubRepo {\n id: number;\n name: string;\n full_name: string;\n description: string | null;\n private: boolean;\n html_url: string;\n default_branch: string;\n language: string | null;\n stargazers_count: number;\n forks_count: number;\n open_issues_count: number;\n updated_at: string;\n}\n\nexport interface GitHubPullRequest {\n id: number;\n number: number;\n title: string;\n body: string | null;\n state: \"open\" | \"closed\";\n html_url: string;\n user: { login: string; avatar_url: string };\n created_at: string;\n updated_at: string;\n head: { ref: string; sha: string };\n base: { ref: string };\n mergeable: boolean | null;\n additions: number;\n deletions: number;\n changed_files: number;\n draft: boolean;\n labels: Array<{ name: string; color: string }>;\n}\n\nexport interface GitHubIssue {\n id: number;\n number: number;\n title: string;\n body: string | null;\n state: \"open\" | \"closed\";\n html_url: string;\n user: { login: string };\n created_at: string;\n updated_at: string;\n labels: Array<{ name: string; color: string }>;\n assignees: Array<{ login: string }>;\n}\n\nexport interface GitHubCommit {\n sha: string;\n commit: {\n message: string;\n author: { name: string; date: string };\n };\n html_url: string;\n author: { login: string; avatar_url: string } | null;\n}\n\n/**\n * GitHub OAuth provider configuration\n */\nexport const githubOAuthProvider = {\n name: \"github\",\n authorizationUrl: \"https://github.com/login/oauth/authorize\",\n tokenUrl: \"https://github.com/login/oauth/access_token\",\n clientId: getEnv(\"GITHUB_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GITHUB_CLIENT_SECRET\") ?? \"\",\n scopes: [\"repo\", \"read:user\", \"read:org\"],\n callbackPath: \"/api/auth/github/callback\",\n};\n\nexport function createGitHubClient(userId: string): {\n listRepos(options?: {\n sort?: \"created\" | \"updated\" | \"pushed\" | \"full_name\";\n perPage?: number;\n type?: \"all\" | \"owner\" | \"public\" | \"private\" | \"member\";\n }): Promise<GitHubRepo[]>;\n getRepo(owner: string, repo: string): Promise<GitHubRepo>;\n listPullRequests(\n owner: string,\n repo: string,\n options?: { state?: \"open\" | \"closed\" | \"all\"; perPage?: number },\n ): Promise<GitHubPullRequest[]>;\n getPullRequest(\n owner: string,\n repo: string,\n pullNumber: number,\n ): Promise<GitHubPullRequest>;\n getPullRequestDiff(\n owner: string,\n repo: string,\n pullNumber: number,\n ): Promise<string>;\n createIssue(\n owner: string,\n repo: string,\n options: {\n title: string;\n body?: string;\n labels?: string[];\n assignees?: string[];\n },\n ): Promise<GitHubIssue>;\n getIssue(\n owner: string,\n repo: string,\n issueNumber: number,\n ): Promise<GitHubIssue>;\n updateIssue(\n owner: string,\n repo: string,\n issueNumber: number,\n options: {\n title?: string;\n body?: string;\n state?: \"open\" | \"closed\";\n labels?: string[];\n assignees?: string[];\n },\n ): Promise<GitHubIssue>;\n addIssueComment(\n owner: string,\n repo: string,\n issueNumber: number,\n body: string,\n ): Promise<\n {\n id: number;\n html_url: string;\n body: string;\n user: { login: string };\n created_at: string;\n }\n >;\n listIssues(\n owner: string,\n repo: string,\n options?: { state?: \"open\" | \"closed\" | \"all\"; perPage?: number },\n ): Promise<GitHubIssue[]>;\n listCommits(\n owner: string,\n repo: string,\n options?: { sha?: string; perPage?: number },\n ): Promise<GitHubCommit[]>;\n getUser(): Promise<{ login: string; name: string; email: string }>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(githubOAuthProvider, userId, \"github\");\n if (!token) {\n throw new Error(\n \"GitHub not connected. Please connect your GitHub account first.\",\n );\n }\n return token;\n }\n\n async function apiRequest<T>(\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`GitHub API error: ${response.status} - ${error}`);\n }\n\n return response.json() as Promise<T>;\n }\n\n async function apiTextRequest(\n endpoint: string,\n accept: string,\n ): Promise<string> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {\n headers: {\n Authorization: `Bearer ${accessToken}`,\n Accept: accept,\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n },\n });\n\n if (!response.ok) throw new Error(`GitHub API error: ${response.status}`);\n\n return response.text();\n }\n\n function toQueryString(params: URLSearchParams): string {\n const query = params.toString();\n return query ? `?${query}` : \"\";\n }\n\n return {\n listRepos(options = {}): Promise<GitHubRepo[]> {\n const params = new URLSearchParams();\n if (options.sort) params.set(\"sort\", options.sort);\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n if (options.type) params.set(\"type\", options.type);\n\n return apiRequest<GitHubRepo[]>(`/user/repos${toQueryString(params)}`);\n },\n\n getRepo(owner, repo): Promise<GitHubRepo> {\n return apiRequest<GitHubRepo>(`/repos/${owner}/${repo}`);\n },\n\n listPullRequests(owner, repo, options = {}): Promise<GitHubPullRequest[]> {\n const params = new URLSearchParams();\n params.set(\"state\", options.state ?? \"open\");\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubPullRequest[]>(\n `/repos/${owner}/${repo}/pulls${toQueryString(params)}`,\n );\n },\n\n getPullRequest(owner, repo, pullNumber): Promise<GitHubPullRequest> {\n return apiRequest<GitHubPullRequest>(\n `/repos/${owner}/${repo}/pulls/${pullNumber}`,\n );\n },\n\n getPullRequestDiff(owner, repo, pullNumber): Promise<string> {\n return apiTextRequest(\n `/repos/${owner}/${repo}/pulls/${pullNumber}`,\n \"application/vnd.github.diff\",\n );\n },\n\n createIssue(owner, repo, options): Promise<GitHubIssue> {\n return apiRequest<GitHubIssue>(`/repos/${owner}/${repo}/issues`, {\n method: \"POST\",\n body: JSON.stringify(options),\n });\n },\n\n getIssue(owner, repo, issueNumber): Promise<GitHubIssue> {\n return apiRequest<GitHubIssue>(\n `/repos/${owner}/${repo}/issues/${issueNumber}`,\n );\n },\n\n updateIssue(owner, repo, issueNumber, options): Promise<GitHubIssue> {\n return apiRequest<GitHubIssue>(\n `/repos/${owner}/${repo}/issues/${issueNumber}`,\n {\n method: \"PATCH\",\n body: JSON.stringify(options),\n },\n );\n },\n\n addIssueComment(owner, repo, issueNumber, body) {\n return apiRequest(\n `/repos/${owner}/${repo}/issues/${issueNumber}/comments`,\n {\n method: \"POST\",\n body: JSON.stringify({ body }),\n },\n );\n },\n\n listIssues(owner, repo, options = {}): Promise<GitHubIssue[]> {\n const params = new URLSearchParams();\n params.set(\"state\", options.state ?? \"open\");\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubIssue[]>(\n `/repos/${owner}/${repo}/issues${toQueryString(params)}`,\n );\n },\n\n listCommits(owner, repo, options = {}): Promise<GitHubCommit[]> {\n const params = new URLSearchParams();\n if (options.sha) params.set(\"sha\", options.sha);\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubCommit[]>(\n `/repos/${owner}/${repo}/commits${toQueryString(params)}`,\n );\n },\n\n getUser(): Promise<{ login: string; name: string; email: string }> {\n return apiRequest(\"/user\");\n },\n };\n}\n\nexport type GitHubClient = ReturnType<typeof createGitHubClient>;\n",
|
|
281
|
+
"lib/user-id.ts": "import type { ToolExecutionContext } from \"veryfront/tool\";\n\nexport function requireUserIdFromContext(\n context?: ToolExecutionContext,\n): string {\n const userId = context?.userId;\n if (!userId) {\n throw new Error(\"GitHub tool execution requires an authenticated user.\");\n }\n return userId;\n}\n",
|
|
282
|
+
"tools/add-issue-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"add-issue-comment\",\n description: \"Add a comment to a GitHub issue or pull request\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v.string().describe(\"Repository in format 'owner/repo'\"),\n issueNumber: v.number().int().positive().describe(\n \"Issue or pull request number\",\n ),\n body: v.string().min(1).describe(\"Comment body (supports Markdown)\"),\n })\n )(),\n execute: async ({ repo, issueNumber, body }, context) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const comment = await github.addIssueComment(\n owner,\n repoName,\n issueNumber,\n body,\n );\n return {\n success: true,\n comment: {\n id: comment.id,\n url: comment.html_url,\n body: comment.body,\n author: comment.user.login,\n createdAt: comment.created_at,\n },\n message: `Comment added to issue #${issueNumber} in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
283
|
+
"tools/create-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"create-issue\",\n description: \"Create a new issue in a GitHub repository\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n title: v.string().min(1).describe(\"Issue title\"),\n body: v\n .string()\n .optional()\n .describe(\"Issue body/description (supports Markdown)\"),\n labels: v.array(v.string()).optional().describe(\n \"Labels to add to the issue\",\n ),\n assignees: v\n .array(v.string())\n .optional()\n .describe(\"GitHub usernames to assign to the issue\"),\n })\n )(),\n execute: async ({ repo, title, body, labels, assignees }, context) => {\n const userId = requireUserIdFromContext(context);\n\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const issue = await github.createIssue(owner, repoName, {\n title,\n body,\n labels,\n assignees,\n });\n\n return {\n success: true,\n issue: {\n number: issue.number,\n title: issue.title,\n url: issue.html_url,\n state: issue.state,\n labels: issue.labels.map((l: { name: string }) => l.name),\n assignees: issue.assignees.map((a: { login: string }) => a.login),\n },\n message: `Issue #${issue.number} created successfully in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
284
|
+
"tools/get-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"get-issue\",\n description: \"Get details of a GitHub issue\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v.string().describe(\"Repository in format 'owner/repo'\"),\n issueNumber: v.number().int().positive().describe(\"Issue number\"),\n })\n )(),\n execute: async ({ repo, issueNumber }, context) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const issue = await github.getIssue(owner, repoName, issueNumber);\n return {\n issue: {\n number: issue.number,\n title: issue.title,\n body: issue.body,\n state: issue.state,\n url: issue.html_url,\n author: issue.user.login,\n labels: issue.labels.map((label: { name: string }) => label.name),\n assignees: issue.assignees.map((assignee: { login: string }) =>\n assignee.login\n ),\n updatedAt: issue.updated_at,\n },\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
285
|
+
"tools/get-pr-diff.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"get-pr-diff\",\n description: \"Get the diff for a pull request to review code changes\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n prNumber: v.number().int().positive().describe(\"Pull request number\"),\n })\n )(),\n execute: async ({ repo, prNumber }, context) => {\n const userId = requireUserIdFromContext(context);\n\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n\n const pr = await github.getPullRequest(owner, repoName, prNumber);\n const diff = await github.getPullRequestDiff(owner, repoName, prNumber);\n\n const maxDiffLength = 50000;\n let truncatedDiff = diff;\n\n if (diff.length > maxDiffLength) {\n truncatedDiff = `${\n diff.substring(0, maxDiffLength)\n }\\n\\n... (diff truncated, ${\n diff.length - maxDiffLength\n } characters remaining)`;\n }\n\n return {\n pullRequest: {\n number: pr.number,\n title: pr.title,\n author: pr.user.login,\n url: pr.html_url,\n sourceBranch: pr.head.ref,\n targetBranch: pr.base.ref,\n additions: pr.additions,\n deletions: pr.deletions,\n changedFiles: pr.changed_files,\n isDraft: pr.draft,\n state: pr.state,\n },\n diff: truncatedDiff,\n stats: {\n additions: pr.additions,\n deletions: pr.deletions,\n changedFiles: pr.changed_files,\n },\n message:\n `Retrieved diff for PR #${prNumber} (${pr.additions} additions, ${pr.deletions} deletions across ${pr.changed_files} files).`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
286
|
+
"tools/get-repo.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"get-repo\",\n description: \"Get details of a GitHub repository\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n })\n )(),\n execute: async ({ repo }, context) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const result = await github.getRepo(owner, repoName);\n\n return {\n repository: {\n name: result.name,\n fullName: result.full_name,\n description: result.description ?? null,\n isPrivate: result.private,\n url: result.html_url,\n defaultBranch: result.default_branch,\n language: result.language,\n stars: result.stargazers_count,\n forks: result.forks_count,\n openIssues: result.open_issues_count,\n updatedAt: result.updated_at,\n },\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
287
|
+
"tools/list-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype GitHubIssueListItem = {\n number: number;\n title: string;\n body: string | null;\n state: string;\n html_url: string;\n user: { login: string };\n created_at: string;\n updated_at: string;\n labels: Array<{ name: string }>;\n assignees: Array<{ login: string }>;\n};\n\nexport default tool({\n id: \"list-issues\",\n description: \"List issues for a GitHub repository\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n state: v\n .enum([\"open\", \"closed\", \"all\"])\n .default(\"open\")\n .describe(\"State of issues to list\"),\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of issues to return\"),\n })\n )(),\n execute: async ({ repo, state, limit }, context) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const issues = await github.listIssues(owner, repoName, {\n state,\n perPage: limit,\n });\n\n return {\n issues: issues.map((issue: GitHubIssueListItem) => ({\n number: issue.number,\n title: issue.title,\n body: issue.body,\n state: issue.state,\n url: issue.html_url,\n author: issue.user.login,\n labels: issue.labels.map((label) => label.name),\n assignees: issue.assignees.map((assignee) => assignee.login),\n createdAt: issue.created_at,\n updatedAt: issue.updated_at,\n })),\n count: issues.length,\n repository: repo,\n message: `Found ${issues.length} ${state} issue(s) in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
288
|
+
"tools/list-prs.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype PullRequest = {\n number: number;\n title: string;\n state: string;\n draft: boolean;\n html_url: string;\n user: { login: string };\n created_at: string;\n updated_at: string;\n head: { ref: string };\n base: { ref: string };\n additions: number;\n deletions: number;\n changed_files: number;\n labels: Array<{ name: string }>;\n};\n\nexport default tool({\n id: \"list-prs\",\n description: \"List pull requests for a GitHub repository\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n state: v\n .enum([\"open\", \"closed\", \"all\"])\n .default(\"open\")\n .describe(\"State of pull requests to list\"),\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(10)\n .describe(\"Maximum number of pull requests to return\"),\n })\n )(),\n execute: async ({ repo, state, limit }, context) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const prs = await github.listPullRequests(owner, repoName, {\n state,\n perPage: limit,\n });\n\n return {\n pullRequests: prs.map((pr: PullRequest) => ({\n number: pr.number,\n title: pr.title,\n state: pr.state,\n isDraft: pr.draft,\n url: pr.html_url,\n author: pr.user.login,\n createdAt: pr.created_at,\n updatedAt: pr.updated_at,\n sourceBranch: pr.head.ref,\n targetBranch: pr.base.ref,\n additions: pr.additions,\n deletions: pr.deletions,\n changedFiles: pr.changed_files,\n labels: pr.labels.map(({ name }) => name),\n })),\n count: prs.length,\n repository: repo,\n message: `Found ${prs.length} ${state} pull request(s) in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
289
|
+
"tools/list-repos.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype GitHubRepo = {\n name: string;\n full_name: string;\n description: string | null;\n private: boolean;\n html_url: string;\n default_branch: string;\n language: string | null;\n stargazers_count: number;\n forks_count: number;\n open_issues_count: number;\n updated_at: string;\n};\n\nexport default tool({\n id: \"list-repos\",\n description: \"List GitHub repositories for the authenticated user\",\n inputSchema: defineSchema((v) =>\n v.object({\n type: v\n .enum([\"all\", \"owner\", \"public\", \"private\", \"member\"])\n .default(\"all\")\n .describe(\"Type of repositories to list\"),\n sort: v\n .enum([\"created\", \"updated\", \"pushed\", \"full_name\"])\n .default(\"updated\")\n .describe(\"How to sort the repositories\"),\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of repositories to return\"),\n })\n )(),\n execute: async ({ type, sort, limit }, context) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const github = createGitHubClient(userId);\n const repos = await github.listRepos({ type, sort, perPage: limit });\n\n return {\n repositories: repos.map((repo: GitHubRepo) => ({\n name: repo.name,\n fullName: repo.full_name,\n description: repo.description ?? null,\n isPrivate: repo.private,\n url: repo.html_url,\n defaultBranch: repo.default_branch,\n language: repo.language,\n stars: repo.stargazers_count,\n forks: repo.forks_count,\n openIssues: repo.open_issues_count,\n updatedAt: repo.updated_at,\n })),\n count: repos.length,\n message: `Found ${repos.length} repository(s).`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
290
|
+
"tools/update-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"update-issue\",\n description: \"Update, close, or reopen a GitHub issue\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v.string().describe(\"Repository in format 'owner/repo'\"),\n issueNumber: v.number().int().positive().describe(\"Issue number\"),\n title: v.string().optional().describe(\"Updated issue title\"),\n body: v.string().optional().describe(\"Updated issue body\"),\n state: v.enum([\"open\", \"closed\"]).optional().describe(\"Issue state\"),\n labels: v.array(v.string()).optional().describe(\n \"Replacement label names\",\n ),\n assignees: v.array(v.string()).optional().describe(\n \"Replacement assignee usernames\",\n ),\n })\n )(),\n execute: async (\n { repo, issueNumber, title, body, state, labels, assignees },\n context,\n ) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const issue = await github.updateIssue(owner, repoName, issueNumber, {\n title,\n body,\n state,\n labels,\n assignees,\n });\n return {\n success: true,\n issue: {\n number: issue.number,\n title: issue.title,\n state: issue.state,\n url: issue.html_url,\n labels: issue.labels.map((label: { name: string }) => label.name),\n assignees: issue.assignees.map((assignee: { login: string }) =>\n assignee.login\n ),\n },\n message: `Issue #${issue.number} updated successfully in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n"
|
|
271
291
|
}
|
|
272
292
|
},
|
|
273
293
|
"integration:gitlab": {
|
|
@@ -275,12 +295,17 @@ export default {
|
|
|
275
295
|
".env.example": "# GitLab OAuth Configuration\n# Create a new application at: https://gitlab.com/-/profile/applications\n# Set the redirect URI to: http://localhost:3000/api/auth/gitlab/callback\n# (Update the URL for production)\n\nGITLAB_CLIENT_ID=your_gitlab_application_id\nGITLAB_CLIENT_SECRET=your_gitlab_application_secret\n",
|
|
276
296
|
"app/api/auth/gitlab/callback/route.ts": "import { createOAuthCallbackHandler, gitlabConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(gitlabConfig, { tokenStore: hybridTokenStore });\n",
|
|
277
297
|
"app/api/auth/gitlab/route.ts": "import { createOAuthInitHandler, gitlabConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(gitlabConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
|
|
278
|
-
"lib/gitlab-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst GITLAB_BASE_URL = \"https://gitlab.com/api/v4\";\n\nexport interface GitLabProject {\n id: number;\n name: string;\n name_with_namespace: string;\n description: string | null;\n web_url: string;\n path_with_namespace: string;\n default_branch: string;\n visibility: \"private\" | \"internal\" | \"public\";\n created_at: string;\n last_activity_at: string;\n}\n\nexport interface GitLabIssue {\n id: number;\n iid: number;\n project_id: number;\n title: string;\n description: string | null;\n state: \"opened\" | \"closed\";\n created_at: string;\n updated_at: string;\n closed_at: string | null;\n labels: string[];\n milestone: {\n id: number;\n title: string;\n } | null;\n assignees: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n author: {\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n };\n web_url: string;\n time_stats: {\n time_estimate: number;\n total_time_spent: number;\n };\n}\n\nexport interface GitLabMergeRequest {\n id: number;\n iid: number;\n project_id: number;\n title: string;\n description: string | null;\n state: \"opened\" | \"closed\" | \"merged\";\n created_at: string;\n updated_at: string;\n merged_at: string | null;\n closed_at: string | null;\n target_branch: string;\n source_branch: string;\n author: {\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n };\n assignees: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n reviewers: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n labels: string[];\n draft: boolean;\n web_url: string;\n changes_count: string;\n diff_refs: {\n base_sha: string;\n head_sha: string;\n start_sha: string;\n };\n}\n\nexport interface GitLabUser {\n id: number;\n username: string;\n name: string;\n email: string;\n avatar_url: string;\n web_url: string;\n}\n\nfunction encodeProjectId(projectId: number | string): number | string {\n return typeof projectId === \"string\" ? encodeURIComponent(projectId) : projectId;\n}\n\nfunction buildQuery(params: URLSearchParams): string {\n const query = params.toString();\n return query ? `?${query}` : \"\";\n}\n\nasync function gitlabFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) throw new Error(\"Not authenticated with GitLab. Please connect your account.\");\n\n const response = await fetch(`${GITLAB_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as {\n message?: string;\n error?: string;\n };\n\n const message = error.message ?? error.error ?? response.statusText;\n throw new Error(`GitLab API error: ${response.status} ${message}`);\n }\n\n return (await response.json()) as T;\n}\n\nexport function getCurrentUser(): Promise<GitLabUser> {\n return gitlabFetch<GitLabUser>(\"/user\");\n}\n\nexport function listProjects(options?: {\n membership?: boolean;\n search?: string;\n orderBy?: \"id\" | \"name\" | \"created_at\" | \"updated_at\" | \"last_activity_at\";\n sort?: \"asc\" | \"desc\";\n perPage?: number;\n}): Promise<GitLabProject[]> {\n const params = new URLSearchParams();\n\n if (options?.membership !== false) params.set(\"membership\", \"true\");\n if (options?.search) params.set(\"search\", options.search);\n if (options?.orderBy) params.set(\"order_by\", options.orderBy);\n if (options?.sort) params.set(\"sort\", options.sort);\n if (options?.perPage) params.set(\"per_page\", options.perPage.toString());\n\n return gitlabFetch<GitLabProject[]>(`/projects${buildQuery(params)}`);\n}\n\nexport function getProject(projectId: number | string): Promise<GitLabProject> {\n return gitlabFetch<GitLabProject>(`/projects/${encodeProjectId(projectId)}`);\n}\n\nexport function searchIssues(options: {\n scope?: \"created_by_me\" | \"assigned_to_me\" | \"all\";\n state?: \"opened\" | \"closed\" | \"all\";\n labels?: string[];\n search?: string;\n projectId?: number | string;\n perPage?: number;\n}): Promise<GitLabIssue[]> {\n const params = new URLSearchParams();\n\n if (options.scope) params.set(\"scope\", options.scope);\n if (options.state) params.set(\"state\", options.state);\n if (options.labels?.length) params.set(\"labels\", options.labels.join(\",\"));\n if (options.search) params.set(\"search\", options.search);\n if (options.perPage) params.set(\"per_page\", options.perPage.toString());\n\n const base = options.projectId\n ? `/projects/${encodeProjectId(options.projectId)}/issues`\n : \"/issues\";\n\n return gitlabFetch<GitLabIssue[]>(`${base}${buildQuery(params)}`);\n}\n\nexport function getIssue(projectId: number | string, issueIid: number): Promise<GitLabIssue> {\n return gitlabFetch<GitLabIssue>(`/projects/${encodeProjectId(projectId)}/issues/${issueIid}`);\n}\n\nexport function createIssue(\n projectId: number | string,\n options: {\n title: string;\n description?: string;\n labels?: string[];\n assigneeIds?: number[];\n milestoneId?: number;\n dueDate?: string;\n },\n): Promise<GitLabIssue> {\n const body: Record<string, unknown> = { title: options.title };\n\n if (options.description) body.description = options.description;\n if (options.labels?.length) body.labels = options.labels.join(\",\");\n if (options.assigneeIds?.length) body.assignee_ids = options.assigneeIds;\n if (options.milestoneId) body.milestone_id = options.milestoneId;\n if (options.dueDate) body.due_date = options.dueDate;\n\n return gitlabFetch<GitLabIssue>(`/projects/${encodeProjectId(projectId)}/issues`, {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n\nexport function updateIssue(\n projectId: number | string,\n issueIid: number,\n options: {\n title?: string;\n description?: string;\n state?: \"opened\" | \"closed\";\n labels?: string[];\n assigneeIds?: number[];\n },\n): Promise<GitLabIssue> {\n const body: Record<string, unknown> = {};\n\n if (options.title) body.title = options.title;\n if (options.description !== undefined) body.description = options.description;\n if (options.state) body.state_event = options.state === \"closed\" ? \"close\" : \"reopen\";\n if (options.labels) body.labels = options.labels.join(\",\");\n if (options.assigneeIds) body.assignee_ids = options.assigneeIds;\n\n return gitlabFetch<GitLabIssue>(`/projects/${encodeProjectId(projectId)}/issues/${issueIid}`, {\n method: \"PUT\",\n body: JSON.stringify(body),\n });\n}\n\nexport function listMergeRequests(options?: {\n scope?: \"created_by_me\" | \"assigned_to_me\" | \"all\";\n state?: \"opened\" | \"closed\" | \"merged\" | \"all\";\n labels?: string[];\n projectId?: number | string;\n perPage?: number;\n}): Promise<GitLabMergeRequest[]> {\n const params = new URLSearchParams();\n\n if (options?.scope) params.set(\"scope\", options.scope);\n if (options?.state) params.set(\"state\", options.state);\n if (options?.labels?.length) params.set(\"labels\", options.labels.join(\",\"));\n if (options?.perPage) params.set(\"per_page\", options.perPage.toString());\n\n const base = options?.projectId\n ? `/projects/${encodeProjectId(options.projectId)}/merge_requests`\n : \"/merge_requests\";\n\n return gitlabFetch<GitLabMergeRequest[]>(`${base}${buildQuery(params)}`);\n}\n\nexport function getMergeRequest(\n projectId: number | string,\n mrIid: number,\n): Promise<GitLabMergeRequest> {\n return gitlabFetch<GitLabMergeRequest>(\n `/projects/${encodeProjectId(projectId)}/merge_requests/${mrIid}`,\n );\n}\n\nexport function formatIssueForDisplay(issue: GitLabIssue): string {\n const assignees = issue.assignees.map((a) => `@${a.username}`).join(\", \");\n const labels = issue.labels.length ? `[${issue.labels.join(\", \")}]` : \"\";\n\n return `#${issue.iid}: ${issue.title} ${labels}\nState: ${issue.state}\nAssignees: ${assignees || \"None\"}\nCreated: ${new Date(issue.created_at).toLocaleDateString()}\nURL: ${issue.web_url}`;\n}\n\nexport function formatMergeRequestForDisplay(mr: GitLabMergeRequest): string {\n const assignees = mr.assignees.map((a) => `@${a.username}`).join(\", \");\n const reviewers = mr.reviewers.map((r) => `@${r.username}`).join(\", \");\n const labels = mr.labels.length ? `[${mr.labels.join(\", \")}]` : \"\";\n\n return `!${mr.iid}: ${mr.title} ${labels}\nState: ${mr.state}${mr.draft ? \" (Draft)\" : \"\"}\nSource: ${mr.source_branch} → Target: ${mr.target_branch}\nAuthor: @${mr.author.username}\nAssignees: ${assignees || \"None\"}\nReviewers: ${reviewers || \"None\"}\nCreated: ${new Date(mr.created_at).toLocaleDateString()}\nURL: ${mr.web_url}`;\n}\n",
|
|
279
|
-
"tools/
|
|
280
|
-
"tools/
|
|
281
|
-
"tools/
|
|
282
|
-
"tools/
|
|
283
|
-
"tools/
|
|
298
|
+
"lib/gitlab-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst GITLAB_BASE_URL = \"https://gitlab.com/api/v4\";\n\nexport interface GitLabProject {\n id: number;\n name: string;\n name_with_namespace: string;\n description: string | null;\n web_url: string;\n path_with_namespace: string;\n default_branch: string;\n visibility: \"private\" | \"internal\" | \"public\";\n created_at: string;\n last_activity_at: string;\n}\n\nexport interface GitLabIssue {\n id: number;\n iid: number;\n project_id: number;\n title: string;\n description: string | null;\n state: \"opened\" | \"closed\";\n created_at: string;\n updated_at: string;\n closed_at: string | null;\n labels: string[];\n milestone: {\n id: number;\n title: string;\n } | null;\n assignees: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n author: {\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n };\n web_url: string;\n time_stats: {\n time_estimate: number;\n total_time_spent: number;\n };\n}\n\nexport interface GitLabMergeRequest {\n id: number;\n iid: number;\n project_id: number;\n title: string;\n description: string | null;\n state: \"opened\" | \"closed\" | \"merged\";\n created_at: string;\n updated_at: string;\n merged_at: string | null;\n closed_at: string | null;\n target_branch: string;\n source_branch: string;\n author: {\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n };\n assignees: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n reviewers: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n labels: string[];\n draft: boolean;\n web_url: string;\n changes_count: string;\n diff_refs: {\n base_sha: string;\n head_sha: string;\n start_sha: string;\n };\n}\n\nexport interface GitLabUser {\n id: number;\n username: string;\n name: string;\n email: string;\n avatar_url: string;\n web_url: string;\n}\n\nexport interface GitLabNote {\n id: number;\n body: string;\n author: { id: number; username: string; name: string; avatar_url: string };\n created_at: string;\n updated_at: string;\n system: boolean;\n confidential?: boolean;\n internal?: boolean;\n}\n\nfunction encodeProjectId(projectId: number | string): number | string {\n return typeof projectId === \"string\"\n ? encodeURIComponent(projectId)\n : projectId;\n}\n\nfunction buildQuery(params: URLSearchParams): string {\n const query = params.toString();\n return query ? `?${query}` : \"\";\n}\n\nasync function gitlabFetch<T>(\n endpoint: string,\n options: RequestInit = {},\n): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\n \"Not authenticated with GitLab. Please connect your account.\",\n );\n }\n\n const response = await fetch(`${GITLAB_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as {\n message?: string;\n error?: string;\n };\n\n const message = error.message ?? error.error ?? response.statusText;\n throw new Error(`GitLab API error: ${response.status} ${message}`);\n }\n\n return (await response.json()) as T;\n}\n\nexport function getCurrentUser(): Promise<GitLabUser> {\n return gitlabFetch<GitLabUser>(\"/user\");\n}\n\nexport function listProjects(options?: {\n membership?: boolean;\n search?: string;\n orderBy?: \"id\" | \"name\" | \"created_at\" | \"updated_at\" | \"last_activity_at\";\n sort?: \"asc\" | \"desc\";\n perPage?: number;\n}): Promise<GitLabProject[]> {\n const params = new URLSearchParams();\n\n if (options?.membership !== false) params.set(\"membership\", \"true\");\n if (options?.search) params.set(\"search\", options.search);\n if (options?.orderBy) params.set(\"order_by\", options.orderBy);\n if (options?.sort) params.set(\"sort\", options.sort);\n if (options?.perPage) params.set(\"per_page\", options.perPage.toString());\n\n return gitlabFetch<GitLabProject[]>(`/projects${buildQuery(params)}`);\n}\n\nexport function getProject(projectId: number | string): Promise<GitLabProject> {\n return gitlabFetch<GitLabProject>(`/projects/${encodeProjectId(projectId)}`);\n}\n\nexport function searchIssues(options: {\n scope?: \"created_by_me\" | \"assigned_to_me\" | \"all\";\n state?: \"opened\" | \"closed\" | \"all\";\n labels?: string[];\n search?: string;\n projectId?: number | string;\n perPage?: number;\n}): Promise<GitLabIssue[]> {\n const params = new URLSearchParams();\n\n if (options.scope) params.set(\"scope\", options.scope);\n if (options.state) params.set(\"state\", options.state);\n if (options.labels?.length) params.set(\"labels\", options.labels.join(\",\"));\n if (options.search) params.set(\"search\", options.search);\n if (options.perPage) params.set(\"per_page\", options.perPage.toString());\n\n const base = options.projectId\n ? `/projects/${encodeProjectId(options.projectId)}/issues`\n : \"/issues\";\n\n return gitlabFetch<GitLabIssue[]>(`${base}${buildQuery(params)}`);\n}\n\nexport function getIssue(\n projectId: number | string,\n issueIid: number,\n): Promise<GitLabIssue> {\n return gitlabFetch<GitLabIssue>(\n `/projects/${encodeProjectId(projectId)}/issues/${issueIid}`,\n );\n}\n\nexport function createIssue(\n projectId: number | string,\n options: {\n title: string;\n description?: string;\n labels?: string[];\n assigneeIds?: number[];\n milestoneId?: number;\n dueDate?: string;\n },\n): Promise<GitLabIssue> {\n const body: Record<string, unknown> = { title: options.title };\n\n if (options.description) body.description = options.description;\n if (options.labels?.length) body.labels = options.labels.join(\",\");\n if (options.assigneeIds?.length) body.assignee_ids = options.assigneeIds;\n if (options.milestoneId) body.milestone_id = options.milestoneId;\n if (options.dueDate) body.due_date = options.dueDate;\n\n return gitlabFetch<GitLabIssue>(\n `/projects/${encodeProjectId(projectId)}/issues`,\n {\n method: \"POST\",\n body: JSON.stringify(body),\n },\n );\n}\n\nexport function updateIssue(\n projectId: number | string,\n issueIid: number,\n options: {\n title?: string;\n description?: string;\n state?: \"opened\" | \"closed\";\n labels?: string[];\n assigneeIds?: number[];\n },\n): Promise<GitLabIssue> {\n const body: Record<string, unknown> = {};\n\n if (options.title) body.title = options.title;\n if (options.description !== undefined) body.description = options.description;\n if (options.state) {\n body.state_event = options.state === \"closed\" ? \"close\" : \"reopen\";\n }\n if (options.labels) body.labels = options.labels.join(\",\");\n if (options.assigneeIds) body.assignee_ids = options.assigneeIds;\n\n return gitlabFetch<GitLabIssue>(\n `/projects/${encodeProjectId(projectId)}/issues/${issueIid}`,\n {\n method: \"PUT\",\n body: JSON.stringify(body),\n },\n );\n}\n\nexport function addIssueComment(\n projectId: number | string,\n issueIid: number,\n options: { body: string; confidential?: boolean },\n): Promise<GitLabNote> {\n const body: Record<string, unknown> = { body: options.body };\n if (options.confidential !== undefined) {\n body.confidential = options.confidential;\n }\n\n return gitlabFetch<GitLabNote>(\n `/projects/${encodeProjectId(projectId)}/issues/${issueIid}/notes`,\n {\n method: \"POST\",\n body: JSON.stringify(body),\n },\n );\n}\n\nexport function listMergeRequests(options?: {\n scope?: \"created_by_me\" | \"assigned_to_me\" | \"all\";\n state?: \"opened\" | \"closed\" | \"merged\" | \"all\";\n labels?: string[];\n projectId?: number | string;\n perPage?: number;\n}): Promise<GitLabMergeRequest[]> {\n const params = new URLSearchParams();\n\n if (options?.scope) params.set(\"scope\", options.scope);\n if (options?.state) params.set(\"state\", options.state);\n if (options?.labels?.length) params.set(\"labels\", options.labels.join(\",\"));\n if (options?.perPage) params.set(\"per_page\", options.perPage.toString());\n\n const base = options?.projectId\n ? `/projects/${encodeProjectId(options.projectId)}/merge_requests`\n : \"/merge_requests\";\n\n return gitlabFetch<GitLabMergeRequest[]>(`${base}${buildQuery(params)}`);\n}\n\nexport function getMergeRequest(\n projectId: number | string,\n mrIid: number,\n): Promise<GitLabMergeRequest> {\n return gitlabFetch<GitLabMergeRequest>(\n `/projects/${encodeProjectId(projectId)}/merge_requests/${mrIid}`,\n );\n}\n\nexport function addMergeRequestComment(\n projectId: number | string,\n mrIid: number,\n options: { body: string; internal?: boolean },\n): Promise<GitLabNote> {\n const body: Record<string, unknown> = { body: options.body };\n if (options.internal !== undefined) body.internal = options.internal;\n\n return gitlabFetch<GitLabNote>(\n `/projects/${encodeProjectId(projectId)}/merge_requests/${mrIid}/notes`,\n {\n method: \"POST\",\n body: JSON.stringify(body),\n },\n );\n}\n\nexport function formatIssueForDisplay(issue: GitLabIssue): string {\n const assignees = issue.assignees.map((a) => `@${a.username}`).join(\", \");\n const labels = issue.labels.length ? `[${issue.labels.join(\", \")}]` : \"\";\n\n return `#${issue.iid}: ${issue.title} ${labels}\nState: ${issue.state}\nAssignees: ${assignees || \"None\"}\nCreated: ${new Date(issue.created_at).toLocaleDateString()}\nURL: ${issue.web_url}`;\n}\n\nexport function formatMergeRequestForDisplay(mr: GitLabMergeRequest): string {\n const assignees = mr.assignees.map((a) => `@${a.username}`).join(\", \");\n const reviewers = mr.reviewers.map((r) => `@${r.username}`).join(\", \");\n const labels = mr.labels.length ? `[${mr.labels.join(\", \")}]` : \"\";\n\n return `!${mr.iid}: ${mr.title} ${labels}\nState: ${mr.state}${mr.draft ? \" (Draft)\" : \"\"}\nSource: ${mr.source_branch} → Target: ${mr.target_branch}\nAuthor: @${mr.author.username}\nAssignees: ${assignees || \"None\"}\nReviewers: ${reviewers || \"None\"}\nCreated: ${new Date(mr.created_at).toLocaleDateString()}\nURL: ${mr.web_url}`;\n}\n",
|
|
299
|
+
"tools/add-issue-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { addIssueComment } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"add-issue-comment\",\n description: \"Add a Markdown comment/note to a GitLab issue.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n issueIid: v\n .number()\n .describe(\n \"Issue IID (the project-local number shown in the issue URL)\",\n ),\n body: v.string().min(1).describe(\"Comment body in Markdown\"),\n confidential: v.boolean().optional().describe(\n \"Make the note confidential\",\n ),\n })\n )(),\n async execute({ projectId, issueIid, body, confidential }) {\n const note = await addIssueComment(projectId, issueIid, {\n body,\n confidential,\n });\n\n return {\n success: true,\n message: `Comment added to issue #${issueIid}.`,\n note,\n };\n },\n});\n",
|
|
300
|
+
"tools/add-merge-request-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { addMergeRequestComment } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"add-merge-request-comment\",\n description: \"Add a Markdown comment/note to a GitLab merge request.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n mergeRequestIid: v\n .number()\n .describe(\n \"Merge request IID (the project-local number shown in the MR URL)\",\n ),\n body: v.string().min(1).describe(\"Comment body in Markdown\"),\n internal: v.boolean().optional().describe(\n \"Make the note internal when supported\",\n ),\n })\n )(),\n async execute({ projectId, mergeRequestIid, body, internal }) {\n const note = await addMergeRequestComment(projectId, mergeRequestIid, {\n body,\n internal,\n });\n\n return {\n success: true,\n message: `Comment added to merge request !${mergeRequestIid}.`,\n note,\n };\n },\n});\n",
|
|
301
|
+
"tools/create-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createIssue } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"create-issue\",\n description:\n \"Create a new issue in a GitLab project. Can set title, description, labels, assignees, milestone, and due date.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n title: v.string().min(1).describe(\"Issue title\"),\n description: v.string().optional().describe(\n \"Issue description in Markdown format\",\n ),\n labels: v.array(v.string()).optional().describe(\n 'Labels to apply (e.g., [\"bug\", \"urgent\"])',\n ),\n assigneeIds: v.array(v.number()).optional().describe(\n \"User IDs to assign the issue to\",\n ),\n milestoneId: v.number().optional().describe(\n \"Milestone ID to associate with the issue\",\n ),\n dueDate: v.string().optional().describe(\"Due date in YYYY-MM-DD format\"),\n })\n )(),\n async execute(\n {\n projectId,\n title,\n description,\n labels,\n assigneeIds,\n milestoneId,\n dueDate,\n },\n ) {\n const issue = await createIssue(projectId, {\n title,\n description,\n labels,\n assigneeIds,\n milestoneId,\n dueDate,\n });\n\n return {\n success: true,\n message: `Issue created successfully: #${issue.iid}`,\n issue: {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n state: issue.state,\n labels: issue.labels,\n assignees: issue.assignees.map(({ username, name }) => ({\n username,\n name,\n })),\n webUrl: issue.web_url,\n createdAt: issue.created_at,\n },\n };\n },\n});\n",
|
|
302
|
+
"tools/get-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getIssue } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"get-issue\",\n description:\n \"Get detailed information about a specific GitLab issue including full description, comments, time tracking, and metadata.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n issueIid: v\n .number()\n .describe(\n \"Issue IID (internal ID, the number shown in the issue URL like #123)\",\n ),\n })\n )(),\n async execute({ projectId, issueIid }) {\n const issue = await getIssue(projectId, issueIid);\n\n return {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n description: issue.description ?? \"No description provided\",\n state: issue.state,\n labels: issue.labels,\n milestone: issue.milestone\n ? { id: issue.milestone.id, title: issue.milestone.title }\n : null,\n assignees: issue.assignees.map(({ id, username, name, avatar_url }) => ({\n id,\n username,\n name,\n avatarUrl: avatar_url,\n })),\n author: {\n id: issue.author.id,\n username: issue.author.username,\n name: issue.author.name,\n avatarUrl: issue.author.avatar_url,\n },\n timeStats: {\n timeEstimate: issue.time_stats.time_estimate,\n totalTimeSpent: issue.time_stats.total_time_spent,\n },\n createdAt: issue.created_at,\n updatedAt: issue.updated_at,\n closedAt: issue.closed_at,\n webUrl: issue.web_url,\n };\n },\n});\n",
|
|
303
|
+
"tools/get-merge-request.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport {\n formatMergeRequestForDisplay,\n getMergeRequest,\n} from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"get-merge-request\",\n description:\n \"Get detailed information about a specific GitLab merge request.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n mergeRequestIid: v\n .number()\n .describe(\n \"Merge request IID (the project-local number shown in the MR URL)\",\n ),\n })\n )(),\n async execute({ projectId, mergeRequestIid }) {\n const mr = await getMergeRequest(projectId, mergeRequestIid);\n\n return {\n id: mr.id,\n iid: mr.iid,\n projectId: mr.project_id,\n title: mr.title,\n description: mr.description ?? \"No description provided\",\n state: mr.state,\n draft: mr.draft,\n sourceBranch: mr.source_branch,\n targetBranch: mr.target_branch,\n labels: mr.labels,\n author: { username: mr.author.username, name: mr.author.name },\n assignees: mr.assignees.map(({ username, name }) => ({ username, name })),\n reviewers: mr.reviewers.map(({ username, name }) => ({ username, name })),\n createdAt: mr.created_at,\n updatedAt: mr.updated_at,\n mergedAt: mr.merged_at,\n closedAt: mr.closed_at,\n webUrl: mr.web_url,\n summary: formatMergeRequestForDisplay(mr),\n };\n },\n});\n",
|
|
304
|
+
"tools/get-project.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getProject } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"get-project\",\n description: \"Get detailed information about a GitLab project.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n })\n )(),\n async execute({ projectId }) {\n const project = await getProject(projectId);\n\n return {\n id: project.id,\n name: project.name,\n nameWithNamespace: project.name_with_namespace,\n description: project.description ?? \"No description\",\n path: project.path_with_namespace,\n visibility: project.visibility,\n defaultBranch: project.default_branch,\n webUrl: project.web_url,\n createdAt: project.created_at,\n lastActivityAt: project.last_activity_at,\n };\n },\n});\n",
|
|
305
|
+
"tools/list-merge-requests.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport {\n formatMergeRequestForDisplay,\n listMergeRequests,\n} from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"list-merge-requests\",\n description:\n \"List merge requests in GitLab. Can filter by scope, state, labels, and specific project. Returns MR titles, states, branches, assignees, and reviewers.\",\n inputSchema: defineSchema((v) =>\n v.object({\n scope: v\n .enum([\"created_by_me\", \"assigned_to_me\", \"all\"])\n .default(\"all\")\n .describe(\"Scope of merge requests to list\"),\n state: v\n .enum([\"opened\", \"closed\", \"merged\", \"all\"])\n .default(\"opened\")\n .describe(\"State of merge requests to list\"),\n labels: v\n .array(v.string())\n .optional()\n .describe('Filter by labels (e.g., [\"feature\", \"review-needed\"])'),\n projectId: v\n .union([v.number(), v.string()])\n .optional()\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n limit: v.number().min(1).max(100).default(20).describe(\n \"Maximum number of results to return\",\n ),\n })\n )(),\n async execute({ scope, state, labels, projectId, limit }) {\n const mergeRequests = await listMergeRequests({\n scope,\n state,\n labels,\n projectId,\n perPage: limit,\n });\n\n if (mergeRequests.length === 0) {\n return {\n message: \"No merge requests found matching the criteria.\",\n count: 0,\n mergeRequests: [],\n };\n }\n\n return {\n count: mergeRequests.length,\n mergeRequests: mergeRequests.map((mr) => {\n const description = mr.description ?? \"\";\n const truncatedDescription = description.length > 200\n ? `${description.substring(0, 200)}...`\n : description;\n\n return {\n id: mr.id,\n iid: mr.iid,\n projectId: mr.project_id,\n title: mr.title,\n state: mr.state,\n draft: mr.draft,\n sourceBranch: mr.source_branch,\n targetBranch: mr.target_branch,\n labels: mr.labels,\n author: {\n username: mr.author.username,\n name: mr.author.name,\n },\n assignees: mr.assignees.map((a) => ({\n username: a.username,\n name: a.name,\n })),\n reviewers: mr.reviewers.map((r) => ({\n username: r.username,\n name: r.name,\n })),\n createdAt: mr.created_at,\n updatedAt: mr.updated_at,\n mergedAt: mr.merged_at,\n webUrl: mr.web_url,\n description: truncatedDescription,\n };\n }),\n summary: mergeRequests.map(formatMergeRequestForDisplay).join(\"\\n\\n\"),\n };\n },\n});\n",
|
|
306
|
+
"tools/list-projects.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listProjects } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"list-projects\",\n description:\n \"List GitLab projects accessible to the authenticated user. Can search, filter by membership, and sort results.\",\n inputSchema: defineSchema((v) =>\n v.object({\n search: v.string().optional().describe(\n \"Search query to filter projects by name or path\",\n ),\n membership: v.boolean().default(true).describe(\n \"Only show projects where user is a member\",\n ),\n orderBy: v\n .enum([\"id\", \"name\", \"created_at\", \"updated_at\", \"last_activity_at\"])\n .default(\"last_activity_at\")\n .describe(\"Field to order results by\"),\n sort: v.enum([\"asc\", \"desc\"]).default(\"desc\").describe(\"Sort direction\"),\n limit: v.number().min(1).max(100).default(20).describe(\n \"Maximum number of results to return\",\n ),\n })\n )(),\n async execute({ search, membership, orderBy, sort, limit }) {\n const projects = await listProjects({\n search,\n membership,\n orderBy,\n sort,\n perPage: limit,\n });\n\n const mappedProjects = projects.map((project) => ({\n id: project.id,\n name: project.name,\n nameWithNamespace: project.name_with_namespace,\n path: project.path_with_namespace,\n description: project.description ?? \"No description\",\n visibility: project.visibility,\n defaultBranch: project.default_branch,\n webUrl: project.web_url,\n createdAt: project.created_at,\n lastActivityAt: project.last_activity_at,\n }));\n\n if (!mappedProjects.length) {\n return {\n message: \"No projects found matching the criteria.\",\n count: 0,\n projects: [],\n };\n }\n\n return {\n count: mappedProjects.length,\n projects: mappedProjects,\n };\n },\n});\n",
|
|
307
|
+
"tools/search-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport {\n formatIssueForDisplay,\n searchIssues,\n} from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"search-issues\",\n description:\n \"Search for issues in GitLab projects. Can search across all accessible projects or within a specific project. Returns issue titles, states, assignees, and labels.\",\n inputSchema: defineSchema((v) =>\n v.object({\n scope: v\n .enum([\"created_by_me\", \"assigned_to_me\", \"all\"])\n .default(\"all\")\n .describe(\"Scope of issues to search\"),\n state: v\n .enum([\"opened\", \"closed\", \"all\"])\n .default(\"opened\")\n .describe(\"State of issues to search for\"),\n search: v.string().optional().describe(\n \"Search query to filter issues by title or description\",\n ),\n labels: v.array(v.string()).optional().describe(\n 'Filter by labels (e.g., [\"bug\", \"urgent\"])',\n ),\n projectId: v\n .union([v.number(), v.string()])\n .optional()\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n limit: v.number().min(1).max(100).default(20).describe(\n \"Maximum number of results to return\",\n ),\n })\n )(),\n async execute({ scope, state, search, labels, projectId, limit }) {\n const issues = await searchIssues({\n scope,\n state,\n search,\n labels,\n projectId,\n perPage: limit,\n });\n\n if (issues.length === 0) {\n return {\n message: \"No issues found matching the criteria.\",\n count: 0,\n issues: [],\n };\n }\n\n return {\n count: issues.length,\n issues: issues.map((issue) => {\n const description = issue.description ?? \"\";\n const truncatedDescription = description.length > 200\n ? `${description.substring(0, 200)}...`\n : description;\n\n return {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n state: issue.state,\n labels: issue.labels,\n assignees: issue.assignees.map(({ username, name }) => ({\n username,\n name,\n })),\n author: {\n username: issue.author.username,\n name: issue.author.name,\n },\n createdAt: issue.created_at,\n updatedAt: issue.updated_at,\n webUrl: issue.web_url,\n description: truncatedDescription,\n };\n }),\n summary: issues.map(formatIssueForDisplay).join(\"\\n\\n\"),\n };\n },\n});\n",
|
|
308
|
+
"tools/update-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { updateIssue } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"update-issue\",\n description: \"Update, close, or reopen a GitLab issue.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n issueIid: v\n .number()\n .describe(\n \"Issue IID (the project-local number shown in the issue URL)\",\n ),\n title: v.string().optional().describe(\"Updated issue title\"),\n description: v.string().optional().describe(\"Updated issue description\"),\n state: v.enum([\"opened\", \"closed\"]).optional().describe(\"Issue state\"),\n labels: v.array(v.string()).optional().describe(\"Replacement labels\"),\n assigneeIds: v.array(v.number()).optional().describe(\n \"GitLab user IDs to assign\",\n ),\n })\n )(),\n async execute(\n { projectId, issueIid, title, description, state, labels, assigneeIds },\n ) {\n const issue = await updateIssue(projectId, issueIid, {\n title,\n description,\n state,\n labels,\n assigneeIds,\n });\n\n return {\n success: true,\n message: `Issue #${issue.iid} updated successfully.`,\n issue: {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n state: issue.state,\n labels: issue.labels,\n assignees: issue.assignees.map(({ username, name }) => ({\n username,\n name,\n })),\n webUrl: issue.web_url,\n updatedAt: issue.updated_at,\n },\n };\n },\n});\n"
|
|
284
309
|
}
|
|
285
310
|
},
|
|
286
311
|
"integration:gmail": {
|
|
@@ -317,14 +342,12 @@ export default {
|
|
|
317
342
|
"tools/search-emails.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient, parseEmailHeaders } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"search-emails\",\n description:\n \"Search emails using Gmail's search syntax. Supports queries like 'from:person@email.com', 'subject:meeting', 'after:2024/01/01', etc.\",\n inputSchema: defineSchema((v) => v.object({\n query: v\n .string()\n .min(1)\n .describe(\n \"Search query using Gmail search syntax (e.g., 'from:boss@company.com subject:urgent')\",\n ),\n maxResults: v\n .number()\n .min(1)\n .max(50)\n .default(10)\n .describe(\"Maximum number of results to return\"),\n }))(),\n execute: async ({ query, maxResults }, context) => {\n const userId = resolveUserId(context);\n const gmail = createGmailClient(userId);\n\n try {\n const list = await gmail.listMessages({ query, maxResults });\n\n if (!list.messages?.length) {\n return {\n emails: [],\n query,\n message: `No emails found matching: \"${query}\"`,\n searchTips: [\n \"from:email@example.com - Search by sender\",\n \"to:email@example.com - Search by recipient\",\n \"subject:keywords - Search in subject\",\n \"after:YYYY/MM/DD - Emails after date\",\n \"before:YYYY/MM/DD - Emails before date\",\n \"is:unread - Unread emails only\",\n \"has:attachment - Emails with attachments\",\n ],\n };\n }\n\n const emails = await Promise.all(\n list.messages.map(async ({ id }) => {\n const message = await gmail.getMessage(id, \"metadata\");\n const headers = parseEmailHeaders(message.payload?.headers ?? []);\n\n return {\n id: message.id,\n threadId: message.threadId,\n from: headers.from,\n to: headers.to,\n subject: headers.subject,\n date: headers.date,\n snippet: message.snippet,\n isUnread: message.labelIds?.includes(\"UNREAD\") ?? false,\n labels: message.labelIds,\n };\n }),\n );\n\n return {\n emails,\n query,\n count: emails.length,\n message: `Found ${emails.length} email(s) matching: \"${query}\"`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
318
343
|
"tools/send-draft.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"send-draft\",\n description: \"Send an existing Gmail draft.\",\n inputSchema: defineSchema((v) => v.object({\n draftId: v.string().min(1).describe(\"Gmail draft ID\"),\n }))(),\n execute: async ({ draftId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const message = await gmail.sendDraft(draftId);\n\n return {\n success: true,\n messageId: message.id,\n threadId: message.threadId,\n message: \"Draft sent.\",\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
319
344
|
"tools/send-email.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nfunction formatRecipients(value?: string | string[]): string | undefined {\n if (!value) return undefined;\n return Array.isArray(value) ? value.join(\", \") : value;\n}\n\nexport default tool({\n id: \"send-email\",\n description: \"Send an email via Gmail. Can send to multiple recipients with CC and BCC support.\",\n inputSchema: defineSchema((v) => v.object({\n to: v.union([v.string().email(), v.array(v.string().email())]).describe(\"Email recipient(s)\"),\n subject: v.string().min(1).describe(\"Email subject line\"),\n body: v.string().min(1).describe(\"Email body content\"),\n cc: v\n .union([v.string().email(), v.array(v.string().email())])\n .optional()\n .describe(\"CC recipient(s)\"),\n bcc: v\n .union([v.string().email(), v.array(v.string().email())])\n .optional()\n .describe(\"BCC recipient(s)\"),\n isHtml: v.boolean().default(false).describe(\"Whether the body contains HTML\"),\n }))(),\n execute: async ({ to, subject, body, cc, bcc, isHtml }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n\n const result = await gmail.sendEmail({ to, subject, body, cc, bcc, isHtml });\n\n const toFormatted = formatRecipients(to) ?? \"\";\n\n return {\n success: true,\n messageId: result.id,\n threadId: result.threadId,\n message: `Email sent successfully to ${toFormatted}.`,\n details: {\n to: toFormatted,\n subject,\n cc: formatRecipients(cc),\n bcc: formatRecipients(bcc),\n },\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
320
|
-
"tools/stop-mailbox-watch.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"stop-mailbox-watch\",\n description: \"Stop Gmail push notifications for the connected mailbox.\",\n inputSchema: defineSchema((v) => v.object({}))(),\n execute: async (_input, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n await gmail.stopMailboxWatch();\n\n return {\n success: true,\n message: \"Mailbox watch stopped.\",\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
321
345
|
"tools/trash-email.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"trash-email\",\n description: \"Move a Gmail message to trash.\",\n inputSchema: defineSchema((v) => v.object({\n messageId: v.string().min(1).describe(\"Gmail message ID\"),\n }))(),\n execute: async ({ messageId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const message = await gmail.trashMessage(messageId);\n\n return {\n success: true,\n message,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
322
346
|
"tools/trash-thread.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"trash-thread\",\n description: \"Move a Gmail thread to trash.\",\n inputSchema: defineSchema((v) => v.object({\n threadId: v.string().min(1).describe(\"Gmail thread ID\"),\n }))(),\n execute: async ({ threadId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const thread = await gmail.trashThread(threadId);\n\n return {\n success: true,\n thread,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
323
347
|
"tools/untrash-email.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"untrash-email\",\n description: \"Remove a Gmail message from trash.\",\n inputSchema: defineSchema((v) => v.object({\n messageId: v.string().min(1).describe(\"Gmail message ID\"),\n }))(),\n execute: async ({ messageId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const message = await gmail.untrashMessage(messageId);\n\n return {\n success: true,\n message,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
324
348
|
"tools/untrash-thread.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"untrash-thread\",\n description: \"Remove a Gmail thread from trash.\",\n inputSchema: defineSchema((v) => v.object({\n threadId: v.string().min(1).describe(\"Gmail thread ID\"),\n }))(),\n execute: async ({ threadId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const thread = await gmail.untrashThread(threadId);\n\n return {\n success: true,\n thread,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
325
349
|
"tools/update-draft.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"update-draft\",\n description: \"Replace the content of a Gmail draft.\",\n inputSchema: defineSchema((v) => v.object({\n draftId: v.string().min(1).describe(\"Gmail draft ID\"),\n to: v.union([v.string().email(), v.array(v.string().email())]).describe(\"Email recipient(s)\"),\n subject: v.string().min(1).describe(\"Email subject line\"),\n body: v.string().min(1).describe(\"Email body content\"),\n cc: v\n .union([v.string().email(), v.array(v.string().email())])\n .optional()\n .describe(\"CC recipient(s)\"),\n bcc: v\n .union([v.string().email(), v.array(v.string().email())])\n .optional()\n .describe(\"BCC recipient(s)\"),\n replyTo: v.string().email().optional().describe(\"Reply-To address\"),\n isHtml: v.boolean().default(false).describe(\"Whether the body contains HTML\"),\n threadId: v.string().optional().describe(\"Thread ID to keep the draft in\"),\n }))(),\n execute: async ({ draftId, ...input }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const draft = await gmail.updateDraft(draftId, input);\n\n return {\n success: true,\n draft,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
326
|
-
"tools/update-label.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"update-label\",\n description: \"Update a Gmail user label.\",\n inputSchema: defineSchema((v) => v.object({\n labelId: v.string().min(1).describe(\"Gmail label ID\"),\n name: v.string().min(1).describe(\"Label display name\"),\n messageListVisibility: v.enum([\"show\", \"hide\"]).optional().describe(\"Message list visibility\"),\n labelListVisibility: v\n .enum([\"labelShow\", \"labelShowIfUnread\", \"labelHide\"])\n .optional()\n .describe(\"Label list visibility\"),\n textColor: v.string().optional().describe(\"Label text color hex value\"),\n backgroundColor: v.string().optional().describe(\"Label background color hex value\"),\n }))(),\n execute: async ({ labelId, textColor, backgroundColor, ...input }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const label = await gmail.updateLabel(labelId, {\n ...input,\n ...(textColor && backgroundColor ? { color: { textColor, backgroundColor } } : {}),\n });\n\n return {\n success: true,\n label,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n"
|
|
327
|
-
"tools/watch-mailbox.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"watch-mailbox\",\n description: \"Start Gmail push notifications for mailbox changes using a Cloud Pub/Sub topic.\",\n inputSchema: defineSchema((v) => v.object({\n topicName: v\n .string()\n .min(1)\n .describe(\"Cloud Pub/Sub topic name, for example projects/<PROJECT_ID>/topics/<TOPIC_ID>\"),\n labelIds: v.array(v.string().min(1)).optional().describe(\"Labels used to filter notifications\"),\n labelFilterBehavior: v\n .enum([\"include\", \"exclude\"])\n .optional()\n .describe(\"Whether labelIds are included or excluded\"),\n }))(),\n execute: async (input, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n return await gmail.watchMailbox(input);\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n"
|
|
350
|
+
"tools/update-label.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"update-label\",\n description: \"Update a Gmail user label.\",\n inputSchema: defineSchema((v) => v.object({\n labelId: v.string().min(1).describe(\"Gmail label ID\"),\n name: v.string().min(1).describe(\"Label display name\"),\n messageListVisibility: v.enum([\"show\", \"hide\"]).optional().describe(\"Message list visibility\"),\n labelListVisibility: v\n .enum([\"labelShow\", \"labelShowIfUnread\", \"labelHide\"])\n .optional()\n .describe(\"Label list visibility\"),\n textColor: v.string().optional().describe(\"Label text color hex value\"),\n backgroundColor: v.string().optional().describe(\"Label background color hex value\"),\n }))(),\n execute: async ({ labelId, textColor, backgroundColor, ...input }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const label = await gmail.updateLabel(labelId, {\n ...input,\n ...(textColor && backgroundColor ? { color: { textColor, backgroundColor } } : {}),\n });\n\n return {\n success: true,\n label,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n"
|
|
328
351
|
}
|
|
329
352
|
},
|
|
330
353
|
"integration:hubspot": {
|
|
@@ -344,12 +367,16 @@ export default {
|
|
|
344
367
|
"files": {
|
|
345
368
|
"app/api/auth/jira/callback/route.ts": "import { createOAuthCallbackHandler, jiraConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n async getTokens(serviceId: string, userId: string): Promise<unknown> {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ): Promise<void> {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string): Promise<void> {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ): Promise<void> {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string): Promise<unknown> {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(jiraConfig, { tokenStore: hybridTokenStore });\n",
|
|
346
369
|
"app/api/auth/jira/route.ts": "import { createOAuthInitHandler, jiraConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(jiraConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
|
|
347
|
-
"lib/jira-client.ts": "import { getAccessToken, getCloudId } from \"./token-store.ts\";\n\nconst JIRA_API_VERSION = \"3\";\n\ninterface JiraResponse<T> {\n expand?: string;\n startAt?: number;\n maxResults?: number;\n total?: number;\n issues?: T[];\n values?: T[];\n}\n\nexport interface JiraIssue {\n id: string;\n key: string;\n self: string;\n fields: {\n summary: string;\n description?:\n | {\n
|
|
348
|
-
"tools/
|
|
349
|
-
"tools/
|
|
350
|
-
"tools/
|
|
351
|
-
"tools/
|
|
352
|
-
"tools/
|
|
370
|
+
"lib/jira-client.ts": "import { getAccessToken, getCloudId } from \"./token-store.ts\";\n\nconst JIRA_API_VERSION = \"3\";\n\ninterface JiraResponse<T> {\n expand?: string;\n startAt?: number;\n maxResults?: number;\n total?: number;\n issues?: T[];\n values?: T[];\n}\n\nexport interface JiraIssue {\n id: string;\n key: string;\n self: string;\n fields: {\n summary: string;\n description?:\n | {\n type: string;\n content: unknown[];\n }\n | string;\n status: {\n name: string;\n statusCategory: {\n key: string;\n name: string;\n };\n };\n issuetype: {\n id: string;\n name: string;\n iconUrl: string;\n };\n priority?: {\n name: string;\n iconUrl: string;\n };\n assignee?: {\n displayName: string;\n emailAddress: string;\n accountId: string;\n };\n reporter?: {\n displayName: string;\n emailAddress: string;\n accountId: string;\n };\n created: string;\n updated: string;\n project: {\n id: string;\n key: string;\n name: string;\n };\n labels?: string[];\n [key: string]: unknown;\n };\n}\n\nexport interface JiraProject {\n id: string;\n key: string;\n name: string;\n projectTypeKey: string;\n self: string;\n avatarUrls?: Record<string, string>;\n lead?: {\n displayName: string;\n accountId: string;\n };\n}\n\nexport interface JiraIssueType {\n id: string;\n name: string;\n description: string;\n iconUrl: string;\n subtask: boolean;\n}\n\nexport interface JiraTransition {\n id: string;\n name: string;\n to: {\n id: string;\n name: string;\n };\n}\n\nexport interface JiraComment {\n id: string;\n body: unknown;\n author?: {\n displayName: string;\n accountId: string;\n };\n created: string;\n updated: string;\n}\n\nfunction buildAdfDescription(text: string): Record<string, unknown> {\n return {\n type: \"doc\",\n version: 1,\n content: [\n {\n type: \"paragraph\",\n content: [\n {\n type: \"text\",\n text,\n },\n ],\n },\n ],\n };\n}\n\nasync function jiraFetch<T>(\n endpoint: string,\n options: RequestInit = {},\n): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\n \"Not authenticated with Jira. Please connect your account.\",\n );\n }\n\n const cloudId = await getCloudId();\n if (!cloudId) {\n throw new Error(\"Jira cloud ID not found. Please reconnect your account.\");\n }\n\n const baseUrl =\n `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/${JIRA_API_VERSION}`;\n const url = endpoint.startsWith(\"http\") ? endpoint : `${baseUrl}${endpoint}`;\n\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({} as unknown));\n const message = (error as any)?.errorMessages?.join(\", \") ||\n (error as any)?.message ||\n response.statusText;\n\n throw new Error(`Jira API error: ${response.status} ${message}`);\n }\n\n if (response.status === 204) {\n return {} as T;\n }\n\n return response.json();\n}\n\nexport async function searchIssues(\n jql: string,\n options?: {\n fields?: string[];\n maxResults?: number;\n startAt?: number;\n },\n): Promise<{ issues: JiraIssue[]; total: number }> {\n const params = new URLSearchParams({\n jql,\n maxResults: String(options?.maxResults ?? 50),\n startAt: String(options?.startAt ?? 0),\n });\n\n if (options?.fields?.length) {\n params.set(\"fields\", options.fields.join(\",\"));\n }\n\n const response = await jiraFetch<JiraResponse<JiraIssue>>(\n `/search?${params.toString()}`,\n );\n\n return {\n issues: response.issues ?? [],\n total: response.total ?? 0,\n };\n}\n\nexport function getIssue(issueIdOrKey: string): Promise<JiraIssue> {\n return jiraFetch<JiraIssue>(`/issue/${issueIdOrKey}`);\n}\n\nexport async function createIssue(options: {\n projectKey: string;\n summary: string;\n description?: string;\n issueType: string;\n priority?: string;\n assigneeId?: string;\n labels?: string[];\n}): Promise<JiraIssue> {\n const fields: Record<string, unknown> = {\n project: { key: options.projectKey },\n summary: options.summary,\n issuetype: { name: options.issueType },\n };\n\n if (options.description) {\n fields.description = buildAdfDescription(options.description);\n }\n\n if (options.priority) {\n fields.priority = { name: options.priority };\n }\n\n if (options.assigneeId) {\n fields.assignee = { id: options.assigneeId };\n }\n\n if (options.labels?.length) {\n fields.labels = options.labels;\n }\n\n const response = await jiraFetch<{ id: string; key: string; self: string }>(\n \"/issue\",\n {\n method: \"POST\",\n body: JSON.stringify({ fields }),\n },\n );\n\n return getIssue(response.key);\n}\n\nexport async function listComments(\n issueIdOrKey: string,\n options?: { startAt?: number; maxResults?: number },\n): Promise<\n {\n comments: JiraComment[];\n total: number;\n startAt: number;\n maxResults: number;\n }\n> {\n const params = new URLSearchParams({\n startAt: String(options?.startAt ?? 0),\n maxResults: String(options?.maxResults ?? 50),\n });\n\n const response = await jiraFetch<{\n comments?: JiraComment[];\n total?: number;\n startAt?: number;\n maxResults?: number;\n }>(`/issue/${issueIdOrKey}/comment?${params.toString()}`);\n\n return {\n comments: response.comments ?? [],\n total: response.total ?? 0,\n startAt: response.startAt ?? 0,\n maxResults: response.maxResults ?? 0,\n };\n}\n\nexport function addComment(\n issueIdOrKey: string,\n body: string,\n): Promise<JiraComment> {\n return jiraFetch<JiraComment>(`/issue/${issueIdOrKey}/comment`, {\n method: \"POST\",\n body: JSON.stringify({ body: buildAdfDescription(body) }),\n });\n}\n\nexport function updateIssue(\n issueIdOrKey: string,\n updates: {\n summary?: string;\n description?: string;\n priority?: string;\n assigneeId?: string;\n labels?: string[];\n },\n): Promise<void> {\n const fields: Record<string, unknown> = {};\n\n if (updates.summary) {\n fields.summary = updates.summary;\n }\n\n if (updates.description) {\n fields.description = buildAdfDescription(updates.description);\n }\n\n if (updates.priority) {\n fields.priority = { name: updates.priority };\n }\n\n if (updates.assigneeId) {\n fields.assignee = { id: updates.assigneeId };\n }\n\n if (updates.labels) {\n fields.labels = updates.labels;\n }\n\n return jiraFetch<void>(`/issue/${issueIdOrKey}`, {\n method: \"PUT\",\n body: JSON.stringify({ fields }),\n });\n}\n\nexport async function transitionIssue(\n issueIdOrKey: string,\n transitionId: string,\n): Promise<void> {\n await jiraFetch<void>(`/issue/${issueIdOrKey}/transitions`, {\n method: \"POST\",\n body: JSON.stringify({ transition: { id: transitionId } }),\n });\n}\n\nexport async function getIssueTransitions(\n issueIdOrKey: string,\n): Promise<JiraTransition[]> {\n const response = await jiraFetch<{ transitions: JiraTransition[] }>(\n `/issue/${issueIdOrKey}/transitions`,\n );\n return response.transitions ?? [];\n}\n\nexport async function listProjects(): Promise<JiraProject[]> {\n return jiraFetch<JiraProject[]>(\"/project\");\n}\n\nexport function getProject(projectIdOrKey: string): Promise<JiraProject> {\n return jiraFetch<JiraProject>(`/project/${projectIdOrKey}`);\n}\n\nexport async function getProjectIssueTypes(\n projectIdOrKey: string,\n): Promise<JiraIssueType[]> {\n return jiraFetch<JiraIssueType[]>(`/project/${projectIdOrKey}/statuses`);\n}\n\nexport function extractDescriptionText(description: unknown): string {\n if (typeof description === \"string\") {\n return description;\n }\n\n if (!description || typeof description !== \"object\") {\n return \"\";\n }\n\n const content = (description as { content?: unknown[] }).content;\n if (!Array.isArray(content)) {\n return \"\";\n }\n\n const texts: string[] = [];\n\n function extractText(node: any): void {\n if (node?.type === \"text\" && node.text) {\n texts.push(node.text);\n }\n\n if (Array.isArray(node?.content)) {\n node.content.forEach(extractText);\n }\n }\n\n content.forEach(extractText);\n return texts.join(\" \");\n}\n",
|
|
371
|
+
"tools/add-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { addComment, extractDescriptionText } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"add-comment\",\n description: \"Add a comment to a Jira issue.\",\n inputSchema: defineSchema((v) =>\n v.object({\n issueKey: v.string().describe('The issue key (e.g., \"PROJ-123\") or ID'),\n body: v.string().min(1).describe(\"Comment body text\"),\n })\n )(),\n async execute({ issueKey, body }) {\n const comment = await addComment(issueKey, body);\n\n return {\n success: true,\n id: comment.id,\n author: comment.author?.displayName,\n body: extractDescriptionText(comment.body),\n created: comment.created,\n updated: comment.updated,\n message: `Comment added to ${issueKey}`,\n };\n },\n});\n",
|
|
372
|
+
"tools/create-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createIssue } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"create-issue\",\n description:\n \"Create a new Jira issue in a project. Requires project key, summary, and issue type. Optionally set description, priority, assignee, and labels.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectKey: v.string().describe('The project key (e.g., \"PROJ\", \"DEV\")'),\n summary: v.string().describe(\"Brief summary/title of the issue\"),\n issueType: v.string().describe(\n 'Type of issue: \"Task\", \"Bug\", \"Story\", \"Epic\", etc.',\n ),\n description: v.string().optional().describe(\n \"Detailed description of the issue\",\n ),\n priority: v\n .string()\n .optional()\n .describe('Priority: \"Highest\", \"High\", \"Medium\", \"Low\", \"Lowest\"'),\n assigneeId: v.string().optional().describe(\n \"Atlassian account ID of the assignee (optional)\",\n ),\n labels: v.array(v.string()).optional().describe(\n \"Array of labels to add to the issue\",\n ),\n })\n )(),\n async execute(\n {\n projectKey,\n summary,\n issueType,\n description,\n priority,\n assigneeId,\n labels,\n },\n ) {\n const { key, id, fields } = await createIssue({\n projectKey,\n summary,\n issueType,\n description,\n priority,\n assigneeId,\n labels,\n });\n\n return {\n key,\n id,\n summary: fields.summary,\n status: fields.status.name,\n type: fields.issuetype.name,\n priority: fields.priority?.name,\n assignee: fields.assignee?.displayName,\n project: {\n key: fields.project.key,\n name: fields.project.name,\n },\n created: fields.created,\n message: `Issue ${key} created successfully`,\n };\n },\n});\n",
|
|
373
|
+
"tools/get-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { extractDescriptionText, getIssue } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"get-issue\",\n description:\n \"Get detailed information about a specific Jira issue by its key (e.g., PROJ-123) or ID. Returns all fields including description, comments, history, etc.\",\n inputSchema: defineSchema((v) =>\n v.object({\n issueKey: v.string().describe('The issue key (e.g., \"PROJ-123\") or ID'),\n })\n )(),\n async execute({ issueKey }) {\n const issue = await getIssue(issueKey);\n const { fields } = issue;\n\n const priority = fields.priority\n ? { name: fields.priority.name, iconUrl: fields.priority.iconUrl }\n : null;\n\n const assignee = fields.assignee\n ? {\n displayName: fields.assignee.displayName,\n email: fields.assignee.emailAddress,\n accountId: fields.assignee.accountId,\n }\n : null;\n\n const reporter = fields.reporter\n ? {\n displayName: fields.reporter.displayName,\n email: fields.reporter.emailAddress,\n accountId: fields.reporter.accountId,\n }\n : null;\n\n return {\n key: issue.key,\n id: issue.id,\n summary: fields.summary,\n description: extractDescriptionText(fields.description),\n status: fields.status.name,\n statusCategory: fields.status.statusCategory.name,\n type: {\n name: fields.issuetype.name,\n iconUrl: fields.issuetype.iconUrl,\n },\n priority,\n assignee,\n reporter,\n project: {\n key: fields.project.key,\n name: fields.project.name,\n id: fields.project.id,\n },\n created: fields.created,\n updated: fields.updated,\n labels: fields.labels ?? [],\n url: issue.self,\n };\n },\n});\n",
|
|
374
|
+
"tools/get-project.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getProject } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"get-project\",\n description: \"Get detailed information about a Jira project by key or ID.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectIdOrKey: v.string().describe('Project key or ID (e.g., \"PROJ\")'),\n })\n )(),\n async execute({ projectIdOrKey }) {\n const project = await getProject(projectIdOrKey);\n\n return {\n key: project.key,\n id: project.id,\n name: project.name,\n projectType: project.projectTypeKey,\n lead: project.lead\n ? {\n displayName: project.lead.displayName,\n accountId: project.lead.accountId,\n }\n : null,\n avatarUrl: project.avatarUrls?.[\"48x48\"],\n self: project.self,\n };\n },\n});\n",
|
|
375
|
+
"tools/get-transitions.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getIssueTransitions } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"get-transitions\",\n description: \"List available workflow transitions for a Jira issue.\",\n inputSchema: defineSchema((v) =>\n v.object({\n issueKey: v.string().describe('The issue key (e.g., \"PROJ-123\") or ID'),\n })\n )(),\n async execute({ issueKey }) {\n const transitions = await getIssueTransitions(issueKey);\n\n return {\n issueKey,\n transitions: transitions.map((transition) => ({\n id: transition.id,\n name: transition.name,\n to: transition.to.name,\n })),\n };\n },\n});\n",
|
|
376
|
+
"tools/list-comments.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { extractDescriptionText, listComments } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"list-comments\",\n description: \"List comments on a Jira issue.\",\n inputSchema: defineSchema((v) =>\n v.object({\n issueKey: v.string().describe('The issue key (e.g., \"PROJ-123\") or ID'),\n startAt: v.number().min(0).default(0).describe(\"Pagination offset\"),\n maxResults: v.number().min(1).max(100).default(50).describe(\n \"Maximum comments to return\",\n ),\n })\n )(),\n async execute({ issueKey, startAt, maxResults }) {\n const result = await listComments(issueKey, { startAt, maxResults });\n\n return {\n total: result.total,\n startAt: result.startAt,\n maxResults: result.maxResults,\n comments: result.comments.map((comment) => ({\n id: comment.id,\n author: comment.author?.displayName,\n body: extractDescriptionText(comment.body),\n created: comment.created,\n updated: comment.updated,\n })),\n };\n },\n});\n",
|
|
377
|
+
"tools/list-projects.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listProjects } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"list-projects\",\n description:\n \"List all accessible Jira projects in the connected site. Returns project keys, names, and basic information.\",\n inputSchema: defineSchema((v) => v.object({}))(),\n async execute() {\n const projects = await listProjects();\n\n return {\n total: projects.length,\n projects: projects.map((project) => {\n const lead = project.lead\n ? {\n displayName: project.lead.displayName,\n accountId: project.lead.accountId,\n }\n : null;\n\n return {\n key: project.key,\n id: project.id,\n name: project.name,\n projectType: project.projectTypeKey,\n lead,\n avatarUrl: project.avatarUrls?.[\"48x48\"],\n };\n }),\n };\n },\n});\n",
|
|
378
|
+
"tools/search-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { extractDescriptionText, searchIssues } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"search-issues\",\n description:\n 'Search for Jira issues using JQL (Jira Query Language). Returns matching issues with key details. Common JQL examples: \"assignee = currentUser() AND status != Done\", \"project = PROJ AND type = Bug\", \"created >= -7d\".',\n inputSchema: defineSchema((v) =>\n v.object({\n jql: v\n .string()\n .describe(\n 'JQL query string to search issues. Examples: \"assignee = currentUser()\", \"project = PROJ\", \"status = Open\"',\n ),\n maxResults: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of results to return\"),\n fields: v\n .array(v.string())\n .optional()\n .describe(\n 'Specific fields to include (e.g., [\"summary\", \"status\", \"assignee\"])',\n ),\n })\n )(),\n async execute({ jql, maxResults, fields }) {\n const result = await searchIssues(jql, { maxResults, fields });\n\n return {\n total: result.total,\n issues: result.issues.map((issue) => {\n const issueFields = issue.fields;\n\n return {\n key: issue.key,\n id: issue.id,\n summary: issueFields.summary,\n description: extractDescriptionText(issueFields.description),\n status: issueFields.status.name,\n statusCategory: issueFields.status.statusCategory.name,\n type: issueFields.issuetype.name,\n priority: issueFields.priority?.name,\n assignee: issueFields.assignee?.displayName,\n reporter: issueFields.reporter?.displayName,\n project: {\n key: issueFields.project.key,\n name: issueFields.project.name,\n },\n created: issueFields.created,\n updated: issueFields.updated,\n labels: issueFields.labels ?? [],\n };\n }),\n };\n },\n});\n",
|
|
379
|
+
"tools/update-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport {\n getIssue,\n getIssueTransitions,\n transitionIssue,\n updateIssue,\n} from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"update-issue\",\n description:\n 'Update an existing Jira issue. Can update fields like summary, description, priority, assignee, labels, or transition the status (e.g., move to \"In Progress\", \"Done\").',\n inputSchema: defineSchema((v) =>\n v.object({\n issueKey: v.string().describe(\n 'The issue key (e.g., \"PROJ-123\") to update',\n ),\n summary: v.string().optional().describe(\n \"New summary/title for the issue\",\n ),\n description: v.string().optional().describe(\n \"New description for the issue\",\n ),\n priority: v\n .string()\n .optional()\n .describe('New priority: \"Highest\", \"High\", \"Medium\", \"Low\", \"Lowest\"'),\n assigneeId: v\n .string()\n .optional()\n .describe(\"Atlassian account ID of the new assignee\"),\n labels: v\n .array(v.string())\n .optional()\n .describe(\"New array of labels (replaces existing labels)\"),\n status: v\n .string()\n .optional()\n .describe(\n 'New status to transition to (e.g., \"In Progress\", \"Done\", \"To Do\")',\n ),\n })\n )(),\n async execute({\n issueKey,\n summary,\n description,\n priority,\n assigneeId,\n labels,\n status,\n }) {\n if (\n summary !== undefined ||\n description !== undefined ||\n priority !== undefined ||\n assigneeId !== undefined ||\n labels !== undefined\n ) {\n await updateIssue(issueKey, {\n summary,\n description,\n priority,\n assigneeId,\n labels,\n });\n }\n\n if (status) {\n const transitions = await getIssueTransitions(issueKey);\n const normalizedStatus = status.toLowerCase();\n\n const targetTransition = transitions.find((t) => {\n const transitionName = t.name.toLowerCase();\n const toName = t.to.name.toLowerCase();\n return transitionName === normalizedStatus ||\n toName === normalizedStatus;\n });\n\n if (!targetTransition) {\n const available = transitions.map((t) => t.to.name).join(\", \");\n throw new Error(\n `Status \"${status}\" not found. Available transitions: ${available}`,\n );\n }\n\n await transitionIssue(issueKey, targetTransition.id);\n }\n\n const updatedIssue = await getIssue(issueKey);\n\n return {\n key: updatedIssue.key,\n id: updatedIssue.id,\n summary: updatedIssue.fields.summary,\n status: updatedIssue.fields.status.name,\n type: updatedIssue.fields.issuetype.name,\n priority: updatedIssue.fields.priority?.name,\n assignee: updatedIssue.fields.assignee?.displayName,\n project: {\n key: updatedIssue.fields.project.key,\n name: updatedIssue.fields.project.name,\n },\n updated: updatedIssue.fields.updated,\n labels: updatedIssue.fields.labels ?? [],\n message: `Issue ${issueKey} updated successfully`,\n };\n },\n});\n"
|
|
353
380
|
}
|
|
354
381
|
},
|
|
355
382
|
"integration:linear": {
|
|
@@ -357,10 +384,14 @@ export default {
|
|
|
357
384
|
".env.example": "# Linear Integration\n# Create an OAuth application at https://linear.app/settings/api\n# Set the callback URL to: http://localhost:3000/api/auth/linear/callback (or your production URL)\n\nLINEAR_CLIENT_ID=your_client_id_here\nLINEAR_CLIENT_SECRET=your_client_secret_here\n",
|
|
358
385
|
"app/api/auth/linear/callback/route.ts": "/**\n * Linear OAuth Callback\n *\n * Handles the OAuth callback from Linear and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, linearConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(linearConfig, { tokenStore: hybridTokenStore });\n",
|
|
359
386
|
"app/api/auth/linear/route.ts": "import { createOAuthInitHandler, linearConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(linearConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
|
|
360
|
-
"lib/linear-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst LINEAR_API_URL = \"https://api.linear.app/graphql\";\n\nexport interface LinearIssue {\n id: string;\n identifier: string;\n title: string;\n description?: string;\n priority: number;\n priorityLabel: string;\n state: {\n id: string;\n name: string;\n type: string;\n };\n assignee?: {\n id: string;\n name: string;\n email: string;\n };\n team: {\n id: string;\n name: string;\n key: string;\n };\n project?: {\n id: string;\n name: string;\n };\n labels: {\n nodes: Array<{\n id: string;\n name: string;\n color: string;\n }>;\n };\n createdAt: string;\n updatedAt: string;\n url: string;\n}\n\nexport interface LinearProject {\n id: string;\n name: string;\n description?: string;\n state: string;\n progress: number;\n url: string;\n lead?: {\n id: string;\n name: string;\n };\n teams: {\n nodes: Array<{\n id: string;\n name: string;\n key: string;\n }>;\n };\n createdAt: string;\n updatedAt: string;\n}\n\nexport interface LinearTeam {\n id: string;\n name: string;\n key: string;\n}\n\nexport interface LinearWorkflowState {\n id: string;\n name: string;\n type: string;\n}\n\ninterface GraphQLResponse<T> {\n data?: T;\n errors?: Array<{\n message: string;\n path?: string[];\n }>;\n}\n\nasync function linearFetch<T>(query: string, variables?: Record<string, unknown>): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Linear. Please connect your account.\");\n }\n\n const response = await fetch(LINEAR_API_URL, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ query, variables }),\n });\n\n if (!response.ok) {\n throw new Error(`Linear API error: ${response.status} ${response.statusText}`);\n }\n\n const json: GraphQLResponse<T> = await response.json();\n\n const errorMessage = json.errors?.[0]?.message;\n if (errorMessage) {\n throw new Error(`Linear GraphQL error: ${errorMessage}`);\n }\n\n if (!json.data) {\n throw new Error(\"Linear API returned no data\");\n }\n\n return json.data;\n}\n\nexport async function searchIssues(\n query: string,\n options?: {\n limit?: number;\n includeArchived?: boolean;\n },\n): Promise<LinearIssue[]> {\n const gqlQuery = `\n query SearchIssues($query: String!, $first: Int, $includeArchived: Boolean) {\n issueSearch(query: $query, first: $first, includeArchived: $includeArchived) {\n nodes {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const data = await linearFetch<{ issueSearch: { nodes: LinearIssue[] } }>(gqlQuery, {\n query,\n first: options?.limit ?? 10,\n includeArchived: options?.includeArchived ?? false,\n });\n\n return data.issueSearch.nodes;\n}\n\nexport async function getIssue(issueId: string): Promise<LinearIssue> {\n const query = `\n query GetIssue($id: String!) {\n issue(id: $id) {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n `;\n\n const data = await linearFetch<{ issue: LinearIssue }>(query, { id: issueId });\n return data.issue;\n}\n\nexport async function createIssue(options: {\n teamId: string;\n title: string;\n description?: string;\n priority?: number;\n stateId?: string;\n assigneeId?: string;\n projectId?: string;\n labelIds?: string[];\n}): Promise<LinearIssue> {\n const mutation = `\n mutation CreateIssue($input: IssueCreateInput!) {\n issueCreate(input: $input) {\n success\n issue {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const input: Record<string, unknown> = {\n teamId: options.teamId,\n title: options.title,\n };\n\n if (options.description) input.description = options.description;\n if (options.priority !== undefined) input.priority = options.priority;\n if (options.stateId) input.stateId = options.stateId;\n if (options.assigneeId) input.assigneeId = options.assigneeId;\n if (options.projectId) input.projectId = options.projectId;\n if (options.labelIds?.length) input.labelIds = options.labelIds;\n\n const data = await linearFetch<{ issueCreate: { success: boolean; issue: LinearIssue } }>(mutation, {\n input,\n });\n\n if (!data.issueCreate.success) {\n throw new Error(\"Failed to create issue\");\n }\n\n return data.issueCreate.issue;\n}\n\nexport async function updateIssue(\n issueId: string,\n options: {\n title?: string;\n description?: string;\n priority?: number;\n stateId?: string;\n assigneeId?: string;\n projectId?: string;\n labelIds?: string[];\n },\n): Promise<LinearIssue> {\n const mutation = `\n mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n success\n issue {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const input: Record<string, unknown> = {};\n\n if (options.title) input.title = options.title;\n if (options.description !== undefined) input.description = options.description;\n if (options.priority !== undefined) input.priority = options.priority;\n if (options.stateId) input.stateId = options.stateId;\n if (options.assigneeId) input.assigneeId = options.assigneeId;\n if (options.projectId) input.projectId = options.projectId;\n if (options.labelIds) input.labelIds = options.labelIds;\n\n const data = await linearFetch<{ issueUpdate: { success: boolean; issue: LinearIssue } }>(mutation, {\n id: issueId,\n input,\n });\n\n if (!data.issueUpdate.success) {\n throw new Error(\"Failed to update issue\");\n }\n\n return data.issueUpdate.issue;\n}\n\nexport async function listProjects(options?: {\n limit?: number;\n includeArchived?: boolean;\n}): Promise<LinearProject[]> {\n const query = `\n query ListProjects($first: Int, $includeArchived: Boolean) {\n projects(first: $first, includeArchived: $includeArchived) {\n nodes {\n id\n name\n description\n state\n progress\n url\n lead {\n id\n name\n }\n teams {\n nodes {\n id\n name\n key\n }\n }\n createdAt\n updatedAt\n }\n }\n }\n `;\n\n const data = await linearFetch<{ projects: { nodes: LinearProject[] } }>(query, {\n first: options?.limit ?? 20,\n includeArchived: options?.includeArchived ?? false,\n });\n\n return data.projects.nodes;\n}\n\nexport async function getTeams(): Promise<LinearTeam[]> {\n const query = `\n query GetTeams {\n teams {\n nodes {\n id\n name\n key\n }\n }\n }\n `;\n\n const data = await linearFetch<{ teams: { nodes: LinearTeam[] } }>(query);\n return data.teams.nodes;\n}\n\nexport async function getWorkflowStates(teamId: string): Promise<LinearWorkflowState[]> {\n const query = `\n query GetWorkflowStates($teamId: String!) {\n team(id: $teamId) {\n states {\n nodes {\n id\n name\n type\n }\n }\n }\n }\n `;\n\n const data = await linearFetch<{ team: { states: { nodes: LinearWorkflowState[] } } }>(query, {\n teamId,\n });\n\n return data.team.states.nodes;\n}\n",
|
|
387
|
+
"lib/linear-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst LINEAR_API_URL = \"https://api.linear.app/graphql\";\n\nexport interface LinearIssue {\n id: string;\n identifier: string;\n title: string;\n description?: string;\n priority: number;\n priorityLabel: string;\n state: {\n id: string;\n name: string;\n type: string;\n };\n assignee?: {\n id: string;\n name: string;\n email: string;\n };\n team: {\n id: string;\n name: string;\n key: string;\n };\n project?: {\n id: string;\n name: string;\n };\n labels: {\n nodes: Array<{\n id: string;\n name: string;\n color: string;\n }>;\n };\n createdAt: string;\n updatedAt: string;\n url: string;\n}\n\nexport interface LinearProject {\n id: string;\n name: string;\n description?: string;\n state: string;\n progress: number;\n url: string;\n lead?: {\n id: string;\n name: string;\n };\n teams: {\n nodes: Array<{\n id: string;\n name: string;\n key: string;\n }>;\n };\n createdAt: string;\n updatedAt: string;\n}\n\nexport interface LinearTeam {\n id: string;\n name: string;\n key: string;\n}\n\nexport interface LinearWorkflowState {\n id: string;\n name: string;\n type: string;\n}\n\nexport interface LinearUser {\n id: string;\n name: string;\n displayName?: string;\n email: string;\n active: boolean;\n avatarUrl?: string;\n}\n\nexport interface LinearComment {\n id: string;\n body: string;\n createdAt: string;\n user?: {\n id: string;\n name: string;\n };\n issue?: {\n id: string;\n identifier: string;\n title: string;\n };\n}\n\ninterface GraphQLResponse<T> {\n data?: T;\n errors?: Array<{\n message: string;\n path?: string[];\n }>;\n}\n\nasync function linearFetch<T>(query: string, variables?: Record<string, unknown>): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Linear. Please connect your account.\");\n }\n\n const response = await fetch(LINEAR_API_URL, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ query, variables }),\n });\n\n if (!response.ok) {\n throw new Error(`Linear API error: ${response.status} ${response.statusText}`);\n }\n\n const json: GraphQLResponse<T> = await response.json();\n\n const errorMessage = json.errors?.[0]?.message;\n if (errorMessage) {\n throw new Error(`Linear GraphQL error: ${errorMessage}`);\n }\n\n if (!json.data) {\n throw new Error(\"Linear API returned no data\");\n }\n\n return json.data;\n}\n\nexport async function searchIssues(\n query: string,\n options?: {\n limit?: number;\n includeArchived?: boolean;\n },\n): Promise<LinearIssue[]> {\n const gqlQuery = `\n query SearchIssues($query: String!, $first: Int, $includeArchived: Boolean) {\n issueSearch(query: $query, first: $first, includeArchived: $includeArchived) {\n nodes {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const data = await linearFetch<{ issueSearch: { nodes: LinearIssue[] } }>(gqlQuery, {\n query,\n first: options?.limit ?? 10,\n includeArchived: options?.includeArchived ?? false,\n });\n\n return data.issueSearch.nodes;\n}\n\nexport async function getIssue(issueId: string): Promise<LinearIssue> {\n const query = `\n query GetIssue($id: String!) {\n issue(id: $id) {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n `;\n\n const data = await linearFetch<{ issue: LinearIssue }>(query, { id: issueId });\n return data.issue;\n}\n\nexport async function createIssue(options: {\n teamId: string;\n title: string;\n description?: string;\n priority?: number;\n stateId?: string;\n assigneeId?: string;\n projectId?: string;\n labelIds?: string[];\n}): Promise<LinearIssue> {\n const mutation = `\n mutation CreateIssue($input: IssueCreateInput!) {\n issueCreate(input: $input) {\n success\n issue {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const input: Record<string, unknown> = {\n teamId: options.teamId,\n title: options.title,\n };\n\n if (options.description) input.description = options.description;\n if (options.priority !== undefined) input.priority = options.priority;\n if (options.stateId) input.stateId = options.stateId;\n if (options.assigneeId) input.assigneeId = options.assigneeId;\n if (options.projectId) input.projectId = options.projectId;\n if (options.labelIds?.length) input.labelIds = options.labelIds;\n\n const data = await linearFetch<{ issueCreate: { success: boolean; issue: LinearIssue } }>(mutation, {\n input,\n });\n\n if (!data.issueCreate.success) {\n throw new Error(\"Failed to create issue\");\n }\n\n return data.issueCreate.issue;\n}\n\nexport async function updateIssue(\n issueId: string,\n options: {\n title?: string;\n description?: string;\n priority?: number;\n stateId?: string;\n assigneeId?: string;\n projectId?: string;\n labelIds?: string[];\n },\n): Promise<LinearIssue> {\n const mutation = `\n mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n success\n issue {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const input: Record<string, unknown> = {};\n\n if (options.title) input.title = options.title;\n if (options.description !== undefined) input.description = options.description;\n if (options.priority !== undefined) input.priority = options.priority;\n if (options.stateId) input.stateId = options.stateId;\n if (options.assigneeId) input.assigneeId = options.assigneeId;\n if (options.projectId) input.projectId = options.projectId;\n if (options.labelIds) input.labelIds = options.labelIds;\n\n const data = await linearFetch<{ issueUpdate: { success: boolean; issue: LinearIssue } }>(mutation, {\n id: issueId,\n input,\n });\n\n if (!data.issueUpdate.success) {\n throw new Error(\"Failed to update issue\");\n }\n\n return data.issueUpdate.issue;\n}\n\nexport async function listProjects(options?: {\n limit?: number;\n includeArchived?: boolean;\n}): Promise<LinearProject[]> {\n const query = `\n query ListProjects($first: Int, $includeArchived: Boolean) {\n projects(first: $first, includeArchived: $includeArchived) {\n nodes {\n id\n name\n description\n state\n progress\n url\n lead {\n id\n name\n }\n teams {\n nodes {\n id\n name\n key\n }\n }\n createdAt\n updatedAt\n }\n }\n }\n `;\n\n const data = await linearFetch<{ projects: { nodes: LinearProject[] } }>(query, {\n first: options?.limit ?? 20,\n includeArchived: options?.includeArchived ?? false,\n });\n\n return data.projects.nodes;\n}\n\nexport async function getTeams(): Promise<LinearTeam[]> {\n const query = `\n query GetTeams {\n teams {\n nodes {\n id\n name\n key\n }\n }\n }\n `;\n\n const data = await linearFetch<{ teams: { nodes: LinearTeam[] } }>(query);\n return data.teams.nodes;\n}\n\nexport async function getWorkflowStates(teamId: string): Promise<LinearWorkflowState[]> {\n const query = `\n query GetWorkflowStates($teamId: String!) {\n team(id: $teamId) {\n states {\n nodes {\n id\n name\n type\n }\n }\n }\n }\n `;\n\n const data = await linearFetch<{ team: { states: { nodes: LinearWorkflowState[] } } }>(query, {\n teamId,\n });\n\n return data.team.states.nodes;\n}\n\nexport async function listUsers(options?: {\n limit?: number;\n}): Promise<LinearUser[]> {\n const query = `\n query ListUsers($first: Int) {\n users(first: $first) {\n nodes {\n id\n name\n displayName\n email\n active\n avatarUrl\n }\n }\n }\n `;\n\n const data = await linearFetch<{ users: { nodes: LinearUser[] } }>(query, {\n first: options?.limit ?? 50,\n });\n\n return data.users.nodes;\n}\n\nexport async function addComment(options: {\n issueId: string;\n body: string;\n}): Promise<LinearComment> {\n const mutation = `\n mutation AddComment($issueId: String!, $body: String!) {\n commentCreate(input: { issueId: $issueId, body: $body }) {\n success\n comment {\n id\n body\n createdAt\n user {\n id\n name\n }\n issue {\n id\n identifier\n title\n }\n }\n }\n }\n `;\n\n const data = await linearFetch<{ commentCreate: { success: boolean; comment: LinearComment } }>(\n mutation,\n options,\n );\n\n if (!data.commentCreate.success) {\n throw new Error(\"Failed to add comment\");\n }\n\n return data.commentCreate.comment;\n}\n",
|
|
388
|
+
"tools/add-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { addComment } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"add-comment\",\n description: \"Add a comment to a Linear issue.\",\n inputSchema: defineSchema((v) => v.object({\n issueId: v.string().describe(\"Linear issue ID\"),\n body: v.string().min(1).describe(\"Comment body in markdown\"),\n }))(),\n async execute({ issueId, body }) {\n const comment = await addComment({ issueId, body });\n\n return {\n id: comment.id,\n body: comment.body,\n createdAt: comment.createdAt,\n user: comment.user\n ? { id: comment.user.id, name: comment.user.name }\n : null,\n issue: comment.issue\n ? {\n id: comment.issue.id,\n identifier: comment.issue.identifier,\n title: comment.issue.title,\n }\n : null,\n };\n },\n});\n",
|
|
361
389
|
"tools/create-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createIssue } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"create-issue\",\n description:\n \"Create a new Linear issue in a specified team. You can optionally set priority, assign to someone, add to a project, and attach labels.\",\n inputSchema: defineSchema((v) => v.object({\n teamId: v\n .string()\n .describe(\n \"The ID of the team to create the issue in. Use list-projects tool first if you need to find team IDs.\",\n ),\n title: v.string().describe(\"Title of the issue\"),\n description: v\n .string()\n .optional()\n .describe(\"Detailed description of the issue (supports markdown)\"),\n priority: v\n .number()\n .min(0)\n .max(4)\n .optional()\n .describe(\"Priority level: 0=No priority, 1=Urgent, 2=High, 3=Medium, 4=Low\"),\n stateId: v\n .string()\n .optional()\n .describe('Workflow state ID (e.g., \"Todo\", \"In Progress\", \"Done\")'),\n assigneeId: v.string().optional().describe(\"User ID to assign the issue to\"),\n projectId: v.string().optional().describe(\"Project ID to add the issue to\"),\n labelIds: v\n .array(v.string())\n .optional()\n .describe(\"Array of label IDs to attach to the issue\"),\n }))(),\n async execute(\n { teamId, title, description, priority, stateId, assigneeId, projectId, labelIds },\n ) {\n const issue = await createIssue({\n teamId,\n title,\n description,\n priority,\n stateId,\n assigneeId,\n projectId,\n labelIds,\n });\n\n const assignee = issue.assignee\n ? { name: issue.assignee.name, email: issue.assignee.email }\n : null;\n\n const project = issue.project ? { name: issue.project.name } : null;\n\n const labels = issue.labels.nodes.map((label) => ({\n name: label.name,\n color: label.color,\n }));\n\n return {\n id: issue.id,\n identifier: issue.identifier,\n title: issue.title,\n description: issue.description,\n priority: issue.priorityLabel,\n status: issue.state.name,\n assignee,\n team: {\n name: issue.team.name,\n key: issue.team.key,\n },\n project,\n labels,\n url: issue.url,\n createdAt: issue.createdAt,\n };\n },\n});\n",
|
|
362
390
|
"tools/get-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getIssue } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"get-issue\",\n description:\n \"Get detailed information about a specific Linear issue by its ID or identifier (e.g., ENG-123). Returns complete issue details including description, status, assignee, labels, and project.\",\n inputSchema: defineSchema((v) => v.object({\n issueId: v\n .string()\n .describe(\n 'The ID or identifier of the issue (e.g., \"ENG-123\" or full UUID)',\n ),\n }))(),\n async execute({ issueId }) {\n const issue = await getIssue(issueId);\n\n return {\n id: issue.id,\n identifier: issue.identifier,\n title: issue.title,\n description: issue.description,\n priority: issue.priorityLabel,\n priorityNumber: issue.priority,\n status: issue.state.name,\n statusType: issue.state.type,\n stateId: issue.state.id,\n assignee: issue.assignee\n ? {\n id: issue.assignee.id,\n name: issue.assignee.name,\n email: issue.assignee.email,\n }\n : null,\n team: {\n id: issue.team.id,\n name: issue.team.name,\n key: issue.team.key,\n },\n project: issue.project\n ? {\n id: issue.project.id,\n name: issue.project.name,\n }\n : null,\n labels: issue.labels.nodes.map(({ id, name, color }) => ({\n id,\n name,\n color,\n })),\n url: issue.url,\n createdAt: issue.createdAt,\n updatedAt: issue.updatedAt,\n };\n },\n});\n",
|
|
363
391
|
"tools/list-projects.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listProjects } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"list-projects\",\n description:\n \"List all projects in the Linear workspace. Returns project details including name, state, progress, and associated teams.\",\n inputSchema: defineSchema((v) => v.object({\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of projects to return\"),\n includeArchived: v\n .boolean()\n .default(false)\n .describe(\"Whether to include archived projects in results\"),\n }))(),\n async execute({ limit, includeArchived }) {\n const projects = await listProjects({ limit, includeArchived });\n\n return projects.map((project) => ({\n id: project.id,\n name: project.name,\n description: project.description,\n state: project.state,\n progress: Math.round(project.progress * 100),\n url: project.url,\n lead: project.lead\n ? { id: project.lead.id, name: project.lead.name }\n : null,\n teams: project.teams.nodes.map((team) => ({\n id: team.id,\n name: team.name,\n key: team.key,\n })),\n createdAt: project.createdAt,\n updatedAt: project.updatedAt,\n }));\n },\n});\n",
|
|
392
|
+
"tools/list-teams.ts": "import { tool } from \"veryfront/tool\";\nimport { getTeams } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"list-teams\",\n description:\n \"List teams in the Linear workspace. Use this to find the team ID required when creating issues.\",\n async execute() {\n const teams = await getTeams();\n\n return teams.map((team) => ({\n id: team.id,\n name: team.name,\n key: team.key,\n }));\n },\n});\n",
|
|
393
|
+
"tools/list-users.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listUsers } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"list-users\",\n description:\n \"List users in the Linear workspace. Use this to find assignee user IDs before assigning issues.\",\n inputSchema: defineSchema((v) => v.object({\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(50)\n .describe(\"Maximum number of users to return\"),\n }))(),\n async execute({ limit }) {\n const users = await listUsers({ limit });\n\n return users.map((user) => ({\n id: user.id,\n name: user.name,\n displayName: user.displayName,\n email: user.email,\n active: user.active,\n avatarUrl: user.avatarUrl,\n }));\n },\n});\n",
|
|
394
|
+
"tools/list-workflow-states.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getWorkflowStates } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"list-workflow-states\",\n description:\n \"List workflow states for a Linear team. Use this to find a state ID before updating an issue status.\",\n inputSchema: defineSchema((v) => v.object({\n teamId: v.string().describe(\"Linear team ID\"),\n }))(),\n async execute({ teamId }) {\n const states = await getWorkflowStates(teamId);\n\n return states.map((state) => ({\n id: state.id,\n name: state.name,\n type: state.type,\n }));\n },\n});\n",
|
|
364
395
|
"tools/search-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { searchIssues } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"search-issues\",\n description:\n \"Search for Linear issues by title or description. Returns matching issues with their details including status, assignee, and team.\",\n inputSchema: defineSchema((v) => v.object({\n query: v.string().describe(\"Search query to find issues (searches in title and description)\"),\n limit: v.number().min(1).max(50).default(10).describe(\"Maximum number of results to return\"),\n includeArchived: v\n .boolean()\n .default(false)\n .describe(\"Whether to include archived issues in results\"),\n }))(),\n async execute({ query, limit, includeArchived }) {\n const issues = await searchIssues(query, { limit, includeArchived });\n\n return issues.map((issue) => {\n const assignee = issue.assignee\n ? { name: issue.assignee.name, email: issue.assignee.email }\n : null;\n\n const project = issue.project ? { name: issue.project.name } : null;\n\n return {\n id: issue.id,\n identifier: issue.identifier,\n title: issue.title,\n description: issue.description,\n priority: issue.priorityLabel,\n status: issue.state.name,\n statusType: issue.state.type,\n assignee,\n team: { name: issue.team.name, key: issue.team.key },\n project,\n labels: issue.labels.nodes.map((label) => ({\n name: label.name,\n color: label.color,\n })),\n url: issue.url,\n createdAt: issue.createdAt,\n updatedAt: issue.updatedAt,\n };\n });\n },\n});\n",
|
|
365
396
|
"tools/update-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { updateIssue } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"update-issue\",\n description:\n \"Update an existing Linear issue. You can change the title, description, status, priority, assignee, project, or labels.\",\n inputSchema: defineSchema((v) => v.object({\n issueId: v.string().describe(\"The ID of the issue to update\"),\n title: v.string().optional().describe(\"New title for the issue\"),\n description: v\n .string()\n .optional()\n .describe(\"New description for the issue (supports markdown)\"),\n priority: v\n .number()\n .min(0)\n .max(4)\n .optional()\n .describe(\n \"New priority level: 0=No priority, 1=Urgent, 2=High, 3=Medium, 4=Low\",\n ),\n stateId: v\n .string()\n .optional()\n .describe(\"New workflow state ID to move the issue to\"),\n assigneeId: v\n .string()\n .optional()\n .describe(\"User ID to assign the issue to (or null to unassign)\"),\n projectId: v.string().optional().describe(\"Project ID to move the issue to\"),\n labelIds: v\n .array(v.string())\n .optional()\n .describe(\"New array of label IDs (replaces existing labels)\"),\n }))(),\n async execute({\n issueId,\n title,\n description,\n priority,\n stateId,\n assigneeId,\n projectId,\n labelIds,\n }) {\n const issue = await updateIssue(issueId, {\n title,\n description,\n priority,\n stateId,\n assigneeId,\n projectId,\n labelIds,\n });\n\n return {\n id: issue.id,\n identifier: issue.identifier,\n title: issue.title,\n description: issue.description,\n priority: issue.priorityLabel,\n status: issue.state.name,\n statusType: issue.state.type,\n assignee: issue.assignee\n ? { name: issue.assignee.name, email: issue.assignee.email }\n : null,\n team: {\n name: issue.team.name,\n key: issue.team.key,\n },\n project: issue.project ? { name: issue.project.name } : null,\n labels: issue.labels.nodes.map(({ name, color }) => ({ name, color })),\n url: issue.url,\n updatedAt: issue.updatedAt,\n };\n },\n});\n"
|
|
366
397
|
}
|
|
@@ -393,11 +424,15 @@ export default {
|
|
|
393
424
|
".env.example": "# Notion Integration\n# Create an integration at https://www.notion.so/my-integrations\n# Make sure to enable \"Public Integration\" for OAuth\n\nNOTION_CLIENT_ID=your_client_id_here\nNOTION_CLIENT_SECRET=your_client_secret_here\n",
|
|
394
425
|
"app/api/auth/notion/callback/route.ts": "import { createOAuthCallbackHandler, notionConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(notionConfig, { tokenStore: hybridTokenStore });\n",
|
|
395
426
|
"app/api/auth/notion/route.ts": "import { createOAuthInitHandler, notionConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(notionConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
|
|
396
|
-
"lib/notion-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst NOTION_API_VERSION = \"2022-06-28\";\nconst NOTION_BASE_URL = \"https://api.notion.com/v1\";\n\ninterface NotionResponse<T> {\n object: string;\n results?: T[];\n next_cursor?: string | null;\n has_more?: boolean;\n}\n\ninterface NotionPage {\n id: string;\n object: \"page\";\n created_time: string;\n last_edited_time: string;\n parent: { type: string; database_id?: string; page_id?: string };\n properties: Record<string, NotionProperty>;\n url: string;\n}\n\ninterface NotionDatabase {\n id: string;\n object: \"database\";\n title: Array<{ plain_text: string }>;\n properties: Record<string, { type: string }>;\n}\n\ninterface NotionBlock {\n id: string;\n type: string;\n [key: string]: unknown;\n}\n\ninterface NotionProperty {\n type: string;\n title?: Array<{ plain_text: string }>;\n rich_text?: Array<{ plain_text: string }>;\n [key: string]: unknown;\n}\n\nasync function notionFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Notion. Please connect your account.\");\n }\n\n const response = await fetch(`${NOTION_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Notion-Version\": NOTION_API_VERSION,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({} as { message?: string }))) as {\n message?: string;\n };\n throw new Error(\n `Notion API error: ${response.status} ${error.message ?? response.statusText}`,\n );\n }\n\n return response.json();\n}\n\nexport async function searchNotion(\n query: string,\n options?: {\n filter?: { property: \"object\"; value: \"page\" | \"database\" };\n pageSize?: number;\n },\n): Promise<Array<NotionPage | NotionDatabase>> {\n const body: Record<string, unknown> = {\n query,\n ...(options?.filter ? { filter: options.filter } : {}),\n ...(options?.pageSize ? { page_size: options.pageSize } : {}),\n };\n\n const response = await notionFetch<NotionResponse<NotionPage | NotionDatabase>>(\"/search\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n\n return response.results ?? [];\n}\n\nexport function getPage(pageId: string): Promise<NotionPage> {\n return notionFetch<NotionPage>(`/pages/${pageId}`);\n}\n\nexport async function getPageContent(pageId: string): Promise<NotionBlock[]> {\n const response = await notionFetch<NotionResponse<NotionBlock>>(`/blocks/${pageId}/children`);\n return response.results ?? [];\n}\n\nexport async function queryDatabase(\n databaseId: string,\n options?: {\n filter?: Record<string, unknown>;\n sorts?: Array<{ property: string; direction: \"ascending\" | \"descending\" }>;\n pageSize?: number;\n },\n): Promise<NotionPage[]> {\n const body: Record<string, unknown> = {\n ...(options?.filter ? { filter: options.filter } : {}),\n ...(options?.sorts ? { sorts: options.sorts } : {}),\n ...(options?.pageSize ? { page_size: options.pageSize } : {}),\n };\n\n const response = await notionFetch<NotionResponse<NotionPage>>(\n `/databases/${databaseId}/query`,\n { method: \"POST\", body: JSON.stringify(body) },\n );\n\n return response.results ?? [];\n}\n\nexport function createPage(options: {\n parentId: string;\n parentType: \"database\" | \"page\";\n title: string;\n content?: string;\n properties?: Record<string, unknown>;\n}): Promise<NotionPage> {\n const parent =\n options.parentType === \"database\"\n ? { database_id: options.parentId }\n : { page_id: options.parentId };\n\n const properties: Record<string, unknown> = options.properties ?? {};\n\n if (options.parentType === \"database\") {\n properties.title ??= { title: [{ text: { content: options.title } }] };\n }\n\n const children: Array<Record<string, unknown>> = [];\n\n if (options.parentType === \"page\") {\n children.push({\n object: \"block\",\n type: \"heading_1\",\n heading_1: {\n rich_text: [{ type: \"text\", text: { content: options.title } }],\n },\n });\n }\n\n for (const paragraph of options.content?.split(\"\\n\\n\") ?? []) {\n const trimmed = paragraph.trim();\n if (!trimmed) continue;\n\n children.push({\n object: \"block\",\n type: \"paragraph\",\n paragraph: {\n rich_text: [{ type: \"text\", text: { content: trimmed } }],\n },\n });\n }\n\n return notionFetch<NotionPage>(\"/pages\", {\n method: \"POST\",\n body: JSON.stringify({\n parent,\n properties,\n children: children.length ? children : undefined,\n }),\n });\n}\n\nexport function extractPlainText(blocks: NotionBlock[]): string {\n const texts: string[] = [];\n\n for (const block of blocks) {\n const content = block[block.type] as { rich_text?: Array<{ plain_text: string }> } | undefined;\n const text = content?.rich_text?.map((t) => t.plain_text).join(\"\");\n if (text) texts.push(text);\n }\n\n return texts.join(\"\\n\\n\");\n}\n\nexport function getPageTitle(page: NotionPage): string {\n for (const prop of Object.values(page.properties)) {\n if (prop.type === \"title\" && prop.title) {\n return prop.title.map((t) => t.plain_text).join(\"\");\n }\n }\n\n return \"Untitled\";\n}\n",
|
|
427
|
+
"lib/notion-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst NOTION_API_VERSION = \"2022-06-28\";\nconst NOTION_BASE_URL = \"https://api.notion.com/v1\";\n\ninterface NotionResponse<T> {\n object: string;\n results?: T[];\n next_cursor?: string | null;\n has_more?: boolean;\n}\n\ninterface NotionPage {\n id: string;\n object: \"page\";\n created_time: string;\n last_edited_time: string;\n parent: { type: string; database_id?: string; page_id?: string };\n properties: Record<string, NotionProperty>;\n url: string;\n}\n\ninterface NotionDatabase {\n id: string;\n object: \"database\";\n title: Array<{ plain_text: string }>;\n properties: Record<string, { type: string }>;\n}\n\ninterface NotionBlock {\n id: string;\n type: string;\n [key: string]: unknown;\n}\n\ninterface NotionProperty {\n type: string;\n title?: Array<{ plain_text: string }>;\n rich_text?: Array<{ plain_text: string }>;\n [key: string]: unknown;\n}\n\nasync function notionFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Notion. Please connect your account.\");\n }\n\n const response = await fetch(`${NOTION_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Notion-Version\": NOTION_API_VERSION,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({} as { message?: string }))) as {\n message?: string;\n };\n throw new Error(\n `Notion API error: ${response.status} ${error.message ?? response.statusText}`,\n );\n }\n\n return response.json();\n}\n\nexport async function searchNotion(\n query: string,\n options?: {\n filter?: { property: \"object\"; value: \"page\" | \"database\" };\n pageSize?: number;\n },\n): Promise<Array<NotionPage | NotionDatabase>> {\n const body: Record<string, unknown> = {\n query,\n ...(options?.filter ? { filter: options.filter } : {}),\n ...(options?.pageSize ? { page_size: options.pageSize } : {}),\n };\n\n const response = await notionFetch<NotionResponse<NotionPage | NotionDatabase>>(\"/search\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n\n return response.results ?? [];\n}\n\nexport function getPage(pageId: string): Promise<NotionPage> {\n return notionFetch<NotionPage>(`/pages/${pageId}`);\n}\n\nexport async function getPageContent(pageId: string): Promise<NotionBlock[]> {\n const response = await notionFetch<NotionResponse<NotionBlock>>(`/blocks/${pageId}/children`);\n return response.results ?? [];\n}\n\nexport async function queryDatabase(\n databaseId: string,\n options?: {\n filter?: Record<string, unknown>;\n sorts?: Array<{ property: string; direction: \"ascending\" | \"descending\" }>;\n pageSize?: number;\n },\n): Promise<NotionPage[]> {\n const body: Record<string, unknown> = {\n ...(options?.filter ? { filter: options.filter } : {}),\n ...(options?.sorts ? { sorts: options.sorts } : {}),\n ...(options?.pageSize ? { page_size: options.pageSize } : {}),\n };\n\n const response = await notionFetch<NotionResponse<NotionPage>>(\n `/databases/${databaseId}/query`,\n { method: \"POST\", body: JSON.stringify(body) },\n );\n\n return response.results ?? [];\n}\n\nexport function createPage(options: {\n parentId: string;\n parentType: \"database\" | \"page\";\n title: string;\n content?: string;\n properties?: Record<string, unknown>;\n}): Promise<NotionPage> {\n const parent =\n options.parentType === \"database\"\n ? { database_id: options.parentId }\n : { page_id: options.parentId };\n\n const properties: Record<string, unknown> = options.properties ?? {};\n\n if (options.parentType === \"database\") {\n properties.title ??= { title: [{ text: { content: options.title } }] };\n }\n\n const children: Array<Record<string, unknown>> = [];\n\n if (options.parentType === \"page\") {\n children.push({\n object: \"block\",\n type: \"heading_1\",\n heading_1: {\n rich_text: [{ type: \"text\", text: { content: options.title } }],\n },\n });\n }\n\n for (const paragraph of options.content?.split(\"\\n\\n\") ?? []) {\n const trimmed = paragraph.trim();\n if (!trimmed) continue;\n\n children.push({\n object: \"block\",\n type: \"paragraph\",\n paragraph: {\n rich_text: [{ type: \"text\", text: { content: trimmed } }],\n },\n });\n }\n\n return notionFetch<NotionPage>(\"/pages\", {\n method: \"POST\",\n body: JSON.stringify({\n parent,\n properties,\n children: children.length ? children : undefined,\n }),\n });\n}\n\nexport function extractPlainText(blocks: NotionBlock[]): string {\n const texts: string[] = [];\n\n for (const block of blocks) {\n const content = block[block.type] as { rich_text?: Array<{ plain_text: string }> } | undefined;\n const text = content?.rich_text?.map((t) => t.plain_text).join(\"\");\n if (text) texts.push(text);\n }\n\n return texts.join(\"\\n\\n\");\n}\n\nexport function getPageTitle(page: NotionPage): string {\n for (const prop of Object.values(page.properties)) {\n if (prop.type === \"title\" && prop.title) {\n return prop.title.map((t) => t.plain_text).join(\"\");\n }\n }\n\n return \"Untitled\";\n}\n\nexport function getDatabase(databaseId: string): Promise<NotionDatabase> {\n return notionFetch<NotionDatabase>(`/databases/${databaseId}`);\n}\n\nexport async function appendBlocks(options: {\n blockId: string;\n children: Array<Record<string, unknown>>;\n after?: string;\n}): Promise<NotionBlock[]> {\n const response = await notionFetch<NotionResponse<NotionBlock>>(\n `/blocks/${options.blockId}/children`,\n {\n method: \"PATCH\",\n body: JSON.stringify({\n children: options.children,\n after: options.after,\n }),\n },\n );\n\n return response.results ?? [];\n}\n\nexport function updatePage(options: {\n pageId: string;\n properties?: Record<string, unknown>;\n archived?: boolean;\n icon?: Record<string, unknown>;\n cover?: Record<string, unknown>;\n}): Promise<NotionPage> {\n return notionFetch<NotionPage>(`/pages/${options.pageId}`, {\n method: \"PATCH\",\n body: JSON.stringify({\n properties: options.properties,\n archived: options.archived,\n icon: options.icon,\n cover: options.cover,\n }),\n });\n}\n",
|
|
428
|
+
"tools/append-blocks.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { appendBlocks } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"append-blocks\",\n description: \"Append child blocks to a Notion page or block.\",\n inputSchema: defineSchema((v) => v.object({\n blockId: v.string().describe(\"The page or block ID to append children to\"),\n children: v.array(v.record(v.string(), v.unknown())).describe(\"Notion block objects to append\"),\n after: v.string().optional().describe(\"Optional existing child block ID after which to append\"),\n }))(),\n async execute({ blockId, children, after }) {\n const blocks = await appendBlocks({ blockId, children, after });\n\n return blocks.map((block) => ({\n id: block.id,\n type: block.type,\n block,\n }));\n },\n});\n",
|
|
397
429
|
"tools/create-page.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createPage, getPageTitle } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"create-page\",\n description:\n \"Create a new page in Notion. Can create as a subpage of an existing page or as a new entry in a database.\",\n inputSchema: defineSchema((v) => v.object({\n parentId: v.string().describe(\"The ID of the parent page or database\"),\n parentType: v.enum([\"page\", \"database\"]).describe(\"Whether the parent is a page or database\"),\n title: v.string().describe(\"Title of the new page\"),\n content: v\n .string()\n .optional()\n .describe(\n \"Initial content for the page (plain text, paragraphs separated by double newlines)\",\n ),\n }))(),\n async execute({ parentId, parentType, title, content }) {\n const page = await createPage({ parentId, parentType, title, content });\n\n return {\n id: page.id,\n title: getPageTitle(page),\n url: page.url,\n createdAt: page.created_time,\n };\n },\n});\n",
|
|
430
|
+
"tools/get-database.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getDatabase } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"get-database\",\n description: \"Get Notion database metadata, title, and property schema.\",\n inputSchema: defineSchema((v) => v.object({\n databaseId: v.string().describe(\"The ID of the Notion database to retrieve\"),\n }))(),\n async execute({ databaseId }) {\n const database = await getDatabase(databaseId);\n\n return {\n id: database.id,\n title: database.title?.map((item) => item.plain_text).join(\"\") ?? \"\",\n properties: database.properties,\n };\n },\n});\n",
|
|
431
|
+
"tools/get-page.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getPage, getPageTitle } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"get-page\",\n description: \"Get Notion page metadata and properties without reading child block content.\",\n inputSchema: defineSchema((v) => v.object({\n pageId: v.string().describe(\"The ID of the Notion page to retrieve\"),\n }))(),\n async execute({ pageId }) {\n const page = await getPage(pageId);\n\n return {\n id: page.id,\n title: getPageTitle(page),\n url: page.url,\n parent: page.parent,\n properties: page.properties,\n lastEdited: page.last_edited_time,\n createdAt: page.created_time,\n };\n },\n});\n",
|
|
398
432
|
"tools/query-database.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getPageTitle, queryDatabase } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"query-database\",\n description: \"Query a Notion database to retrieve entries. Supports filtering and sorting.\",\n inputSchema: defineSchema((v) => v.object({\n databaseId: v.string().describe(\"The ID of the Notion database to query\"),\n sortProperty: v.string().optional().describe(\"Property name to sort by\"),\n sortDirection: v\n .enum([\"ascending\", \"descending\"])\n .default(\"descending\")\n .describe(\"Sort direction\"),\n limit: v.number().min(1).max(50).default(20).describe(\"Maximum number of results\"),\n }))(),\n async execute({ databaseId, sortProperty, sortDirection, limit }) {\n const results = await queryDatabase(databaseId, {\n sorts: sortProperty ? [{ property: sortProperty, direction: sortDirection }] : undefined,\n pageSize: limit,\n });\n\n return results.map((page) => {\n const properties: Record<string, string> = {};\n\n for (const [key, prop] of Object.entries(page.properties)) {\n if (prop.type !== \"title\" && prop.type !== \"rich_text\") continue;\n\n const text =\n prop.type === \"title\"\n ? prop.title?.map((t) => t.plain_text).join(\"\") ?? \"\"\n : prop.rich_text?.map((t) => t.plain_text).join(\"\") ?? \"\";\n\n properties[key] = text;\n }\n\n return {\n id: page.id,\n title: getPageTitle(page),\n url: page.url,\n properties,\n lastEdited: page.last_edited_time,\n };\n });\n },\n});\n",
|
|
399
433
|
"tools/read-page.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { extractPlainText, getPage, getPageContent, getPageTitle } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"read-page\",\n description: \"Read the content of a Notion page. Returns the page title and text content.\",\n inputSchema: defineSchema((v) => v.object({\n pageId: v.string().describe(\"The ID of the Notion page to read\"),\n }))(),\n async execute({ pageId }): Promise<{\n id: string;\n title: string;\n url: string;\n content: string;\n lastEdited: string;\n createdAt: string;\n }> {\n const [page, blocks] = await Promise.all([getPage(pageId), getPageContent(pageId)]);\n\n return {\n id: page.id,\n title: getPageTitle(page),\n url: page.url,\n content: extractPlainText(blocks),\n lastEdited: page.last_edited_time,\n createdAt: page.created_time,\n };\n },\n});\n",
|
|
400
|
-
"tools/search-notion.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getPageTitle, searchNotion } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"search-notion\",\n description:\n \"Search for pages and databases in the connected Notion workspace. Returns matching pages with their titles and IDs.\",\n inputSchema: defineSchema((v) => v.object({\n query: v.string().describe(\"Search query to find pages or databases\"),\n type: v\n .enum([\"page\", \"database\", \"all\"])\n .default(\"all\")\n .describe(\"Type of objects to search for\"),\n limit: v\n .number()\n .min(1)\n .max(20)\n .default(10)\n .describe(\"Maximum number of results to return\"),\n }))(),\n async execute({ query, type, limit }) {\n const filter = type === \"all\" ? undefined : { property: \"object\", value: type };\n const results = await searchNotion(query, { filter, pageSize: limit });\n\n return results.map((item) => {\n if (item.object === \"page\") {\n return {\n id: item.id,\n type: \"page\",\n title: getPageTitle(item),\n url: item.url,\n lastEdited: item.last_edited_time,\n };\n }\n\n return {\n id: item.id,\n type: \"database\",\n title: item.title?.map((t) => t.plain_text).join(\"\") ?? \"\",\n url: item.url,\n };\n });\n },\n});\n"
|
|
434
|
+
"tools/search-notion.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getPageTitle, searchNotion } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"search-notion\",\n description:\n \"Search for pages and databases in the connected Notion workspace. Returns matching pages with their titles and IDs.\",\n inputSchema: defineSchema((v) => v.object({\n query: v.string().describe(\"Search query to find pages or databases\"),\n type: v\n .enum([\"page\", \"database\", \"all\"])\n .default(\"all\")\n .describe(\"Type of objects to search for\"),\n limit: v\n .number()\n .min(1)\n .max(20)\n .default(10)\n .describe(\"Maximum number of results to return\"),\n }))(),\n async execute({ query, type, limit }) {\n const filter = type === \"all\" ? undefined : { property: \"object\", value: type };\n const results = await searchNotion(query, { filter, pageSize: limit });\n\n return results.map((item) => {\n if (item.object === \"page\") {\n return {\n id: item.id,\n type: \"page\",\n title: getPageTitle(item),\n url: item.url,\n lastEdited: item.last_edited_time,\n };\n }\n\n return {\n id: item.id,\n type: \"database\",\n title: item.title?.map((t) => t.plain_text).join(\"\") ?? \"\",\n url: item.url,\n };\n });\n },\n});\n",
|
|
435
|
+
"tools/update-page.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getPageTitle, updatePage } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"update-page\",\n description: \"Update Notion page properties or archive/unarchive a page.\",\n inputSchema: defineSchema((v) => v.object({\n pageId: v.string().describe(\"The ID of the Notion page to update\"),\n properties: v.record(v.string(), v.unknown()).optional().describe(\"Page properties to update\"),\n archived: v.boolean().optional().describe(\"Whether the page should be archived\"),\n icon: v.record(v.string(), v.unknown()).optional().describe(\"Optional page icon object\"),\n cover: v.record(v.string(), v.unknown()).optional().describe(\"Optional page cover object\"),\n }))(),\n async execute({ pageId, properties, archived, icon, cover }) {\n const page = await updatePage({ pageId, properties, archived, icon, cover });\n\n return {\n id: page.id,\n title: getPageTitle(page),\n url: page.url,\n properties: page.properties,\n archived,\n lastEdited: page.last_edited_time,\n };\n },\n});\n"
|
|
401
436
|
}
|
|
402
437
|
},
|
|
403
438
|
"integration:onedrive": {
|
|
@@ -487,11 +522,22 @@ export default {
|
|
|
487
522
|
".env.example": "# Google Sheets Integration\n# Create OAuth credentials at https://console.cloud.google.com/apis/credentials\n# Make sure to enable:\n# - Google Sheets API: https://console.cloud.google.com/apis/library/sheets.googleapis.com\n# - Google Drive API: https://console.cloud.google.com/apis/library/drive.googleapis.com\n\nGOOGLE_CLIENT_ID=your_client_id_here\nGOOGLE_CLIENT_SECRET=your_client_secret_here\n",
|
|
488
523
|
"app/api/auth/sheets/callback/route.ts": "import { createOAuthCallbackHandler, sheetsConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(sheetsConfig, { tokenStore: hybridTokenStore });\n",
|
|
489
524
|
"app/api/auth/sheets/route.ts": "import { createOAuthInitHandler, sheetsConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(sheetsConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
|
|
490
|
-
"lib/sheets-client.ts": "/**\n * Google Sheets API Client\n *\n * Provides a type-safe interface to Google Sheets API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst SHEETS_API_BASE = \"https://sheets.googleapis.com/v4\";\nconst DRIVE_API_BASE = \"https://www.googleapis.com/drive/v3\";\n\nexport interface Spreadsheet {\n spreadsheetId: string;\n properties: {\n title: string;\n locale: string;\n autoRecalc: string;\n timeZone: string;\n };\n sheets: Sheet[];\n spreadsheetUrl: string;\n}\n\nexport interface Sheet {\n properties: {\n sheetId: number;\n title: string;\n index: number;\n sheetType: \"GRID\" | \"OBJECT\";\n gridProperties?: {\n rowCount: number;\n columnCount: number;\n };\n };\n}\n\nexport interface SpreadsheetFile {\n id: string;\n name: string;\n mimeType: string;\n createdTime: string;\n modifiedTime: string;\n webViewLink: string;\n}\n\nexport interface CellData {\n values: unknown[][];\n range: string;\n}\n\nexport interface CreateSpreadsheetOptions {\n title: string;\n sheets?: Array<{\n title: string;\n rowCount?: number;\n columnCount?: number;\n }>;\n}\n\nexport interface WriteRangeOptions {\n spreadsheetId: string;\n range: string;\n values: unknown[][];\n valueInputOption?: \"RAW\" | \"USER_ENTERED\";\n}\n\nexport const sheetsOAuthProvider = {\n name: \"sheets\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/spreadsheets\",\n \"https://www.googleapis.com/auth/drive.readonly\",\n ],\n callbackPath: \"/api/auth/sheets/callback\",\n};\n\nexport function createSheetsClient(userId: string): {\n listSpreadsheets(options?: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n }): Promise<SpreadsheetFile[]>;\n getSpreadsheet(spreadsheetId: string): Promise<Spreadsheet>;\n readRange(spreadsheetId: string, range: string): Promise<CellData>;\n readRanges(spreadsheetId: string, ranges: string[]): Promise<CellData[]>;\n writeRange(options: WriteRangeOptions): Promise<{\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n }>;\n appendRange(\n spreadsheetId: string,\n range: string,\n values: unknown[][],\n valueInputOption?: \"RAW\" | \"USER_ENTERED\",\n ): Promise<{\n updates: {\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n };\n }>;\n clearRange(spreadsheetId: string, range: string): Promise<{ clearedRange: string }>;\n createSpreadsheet(options: CreateSpreadsheetOptions): Promise<Spreadsheet>;\n addSheet(\n spreadsheetId: string,\n title: string,\n options?: { rowCount?: number; columnCount?: number },\n ): Promise<Sheet>;\n deleteSheet(spreadsheetId: string, sheetId: number): Promise<void>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(sheetsOAuthProvider, userId, \"sheets\");\n if (token) return token;\n throw new Error(\"Google Sheets not connected. Please connect your Google account first.\");\n }\n\n async function apiRequest<T>(\n baseUrl: string,\n serviceName: \"Sheets\" | \"Drive\",\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${baseUrl}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`${serviceName} API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n function sheetsApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiRequest<T>(SHEETS_API_BASE, \"Sheets\", endpoint, options);\n }\n\n function driveApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiRequest<T>(DRIVE_API_BASE, \"Drive\", endpoint, options);\n }\n\n return {\n async listSpreadsheets(options: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n } = {}): Promise<SpreadsheetFile[]> {\n const params = new URLSearchParams({\n q: \"mimeType='application/vnd.google-apps.spreadsheet' and trashed=false\",\n fields: \"files(id,name,mimeType,createdTime,modifiedTime,webViewLink)\",\n pageSize: String(options.maxResults ?? 20),\n orderBy: `${options.orderBy ?? \"modifiedTime\"} desc`,\n });\n\n const result = await driveApiRequest<{ files?: SpreadsheetFile[] }>(`/files?${params.toString()}`);\n return result.files ?? [];\n },\n\n getSpreadsheet(spreadsheetId: string): Promise<Spreadsheet> {\n return sheetsApiRequest<Spreadsheet>(`/spreadsheets/${spreadsheetId}`);\n },\n\n async readRange(spreadsheetId: string, range: string): Promise<CellData> {\n const result = await sheetsApiRequest<{ values?: unknown[][]; range: string }>(\n `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}`,\n );\n\n return { values: result.values ?? [], range: result.range };\n },\n\n async readRanges(spreadsheetId: string, ranges: string[]): Promise<CellData[]> {\n const params = new URLSearchParams();\n ranges.forEach((range) => params.append(\"ranges\", range));\n\n const result = await sheetsApiRequest<{\n valueRanges: Array<{ values?: unknown[][]; range: string }>;\n }>(`/spreadsheets/${spreadsheetId}/values:batchGet?${params.toString()}`);\n\n return result.valueRanges.map((vr) => ({ values: vr.values ?? [], range: vr.range }));\n },\n\n writeRange(options: WriteRangeOptions): Promise<{\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n }> {\n const valueInputOption = options.valueInputOption ?? \"USER_ENTERED\";\n\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}/values/${encodeURIComponent(options.range)}?valueInputOption=${valueInputOption}`,\n {\n method: \"PUT\",\n body: JSON.stringify({ values: options.values }),\n },\n );\n },\n\n appendRange(\n spreadsheetId: string,\n range: string,\n values: unknown[][],\n valueInputOption: \"RAW\" | \"USER_ENTERED\" = \"USER_ENTERED\",\n ): Promise<{\n updates: {\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n };\n }> {\n return sheetsApiRequest(\n `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}:append?valueInputOption=${valueInputOption}`,\n {\n method: \"POST\",\n body: JSON.stringify({ values }),\n },\n );\n },\n\n clearRange(spreadsheetId: string, range: string): Promise<{ clearedRange: string }> {\n return sheetsApiRequest(`/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}:clear`, {\n method: \"POST\",\n });\n },\n\n createSpreadsheet(options: CreateSpreadsheetOptions): Promise<Spreadsheet> {\n const body: {\n properties: { title: string };\n sheets?: Array<{\n properties: {\n title: string;\n gridProperties?: { rowCount: number; columnCount: number };\n };\n }>;\n } = { properties: { title: options.title } };\n\n if (options.sheets?.length) {\n body.sheets = options.sheets.map((sheet) => ({\n properties: {\n title: sheet.title,\n gridProperties: {\n rowCount: sheet.rowCount ?? 1000,\n columnCount: sheet.columnCount ?? 26,\n },\n },\n }));\n }\n\n return sheetsApiRequest(\"/spreadsheets\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n },\n\n async addSheet(\n spreadsheetId: string,\n title: string,\n options?: { rowCount?: number; columnCount?: number },\n ): Promise<Sheet> {\n const result = await sheetsApiRequest<{\n replies: Array<{ addSheet?: { properties: Sheet[\"properties\"] } }>;\n }>(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [\n {\n addSheet: {\n properties: {\n title,\n gridProperties: {\n rowCount: options?.rowCount ?? 1000,\n columnCount: options?.columnCount ?? 26,\n },\n },\n },\n },\n ],\n }),\n });\n\n const properties = result.replies[0]?.addSheet?.properties;\n if (!properties) throw new Error(\"Failed to add sheet\");\n\n return { properties };\n },\n\n async deleteSheet(spreadsheetId: string, sheetId: number): Promise<void> {\n await sheetsApiRequest(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{ deleteSheet: { sheetId } }],\n }),\n });\n },\n };\n}\n\nexport type SheetsClient = ReturnType<typeof createSheetsClient>;\n",
|
|
525
|
+
"lib/sheets-client.ts": "/**\n * Google Sheets API Client\n *\n * Provides a type-safe interface to Google Sheets API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst SHEETS_API_BASE = \"https://sheets.googleapis.com/v4\";\nconst DRIVE_API_BASE = \"https://www.googleapis.com/drive/v3\";\n\nexport interface Spreadsheet {\n spreadsheetId: string;\n properties: {\n title: string;\n locale: string;\n autoRecalc: string;\n timeZone: string;\n };\n sheets: Sheet[];\n spreadsheetUrl: string;\n}\n\nexport interface Sheet {\n properties: {\n sheetId: number;\n title: string;\n index: number;\n sheetType: \"GRID\" | \"OBJECT\";\n gridProperties?: {\n rowCount: number;\n columnCount: number;\n };\n };\n}\n\nexport interface SpreadsheetFile {\n id: string;\n name: string;\n mimeType: string;\n createdTime: string;\n modifiedTime: string;\n webViewLink: string;\n}\n\nexport interface CellData {\n values: unknown[][];\n range: string;\n}\n\nexport interface CreateSpreadsheetOptions {\n title: string;\n sheets?: Array<{\n title: string;\n rowCount?: number;\n columnCount?: number;\n }>;\n}\n\nexport interface WriteRangeOptions {\n spreadsheetId: string;\n range: string;\n values: unknown[][];\n valueInputOption?: \"RAW\" | \"USER_ENTERED\";\n}\n\nexport interface AppendRangeOptions extends WriteRangeOptions {\n insertDataOption?: \"OVERWRITE\" | \"INSERT_ROWS\";\n}\n\nexport interface BatchUpdateOptions {\n spreadsheetId: string;\n requests: Array<Record<string, unknown>>;\n includeSpreadsheetInResponse?: boolean;\n responseRanges?: string[];\n}\n\nexport const sheetsOAuthProvider = {\n name: \"sheets\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/spreadsheets\",\n \"https://www.googleapis.com/auth/drive.readonly\",\n \"https://www.googleapis.com/auth/drive.file\",\n ],\n callbackPath: \"/api/auth/sheets/callback\",\n};\n\nexport function createSheetsClient(userId: string): {\n listSpreadsheets(options?: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n }): Promise<SpreadsheetFile[]>;\n getSpreadsheet(spreadsheetId: string): Promise<Spreadsheet>;\n readRange(spreadsheetId: string, range: string): Promise<CellData>;\n readRanges(spreadsheetId: string, ranges: string[]): Promise<CellData[]>;\n writeRange(options: WriteRangeOptions): Promise<{\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n }>;\n appendRange(options: AppendRangeOptions): Promise<{\n updates: {\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n };\n }>;\n clearRange(\n spreadsheetId: string,\n range: string,\n ): Promise<{ clearedRange: string }>;\n batchUpdate(options: BatchUpdateOptions): Promise<unknown>;\n createSpreadsheet(options: CreateSpreadsheetOptions): Promise<Spreadsheet>;\n addSheet(\n spreadsheetId: string,\n title: string,\n options?: { rowCount?: number; columnCount?: number },\n ): Promise<Sheet>;\n deleteSheet(spreadsheetId: string, sheetId: number): Promise<void>;\n renameSheet(\n spreadsheetId: string,\n sheetId: number,\n title: string,\n ): Promise<unknown>;\n deleteSpreadsheet(\n spreadsheetId: string,\n options?: { permanentlyDelete?: boolean },\n ): Promise<\n { deleted: true; spreadsheetId: string; permanentlyDeleted: boolean }\n >;\n findReplace(options: {\n spreadsheetId: string;\n find: string;\n replacement: string;\n sheetId?: number;\n matchCase?: boolean;\n matchEntireCell?: boolean;\n searchByRegex?: boolean;\n }): Promise<unknown>;\n copySheet(options: {\n spreadsheetId: string;\n sheetId: number;\n destinationSpreadsheetId: string;\n }): Promise<unknown>;\n createChart(\n spreadsheetId: string,\n chart: Record<string, unknown>,\n ): Promise<unknown>;\n setDataValidation(options: {\n spreadsheetId: string;\n range: Record<string, unknown>;\n rule: Record<string, unknown>;\n }): Promise<unknown>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(sheetsOAuthProvider, userId, \"sheets\");\n if (token) return token;\n throw new Error(\n \"Google Sheets not connected. Please connect your Google account first.\",\n );\n }\n\n async function apiRequest<T>(\n baseUrl: string,\n serviceName: \"Sheets\" | \"Drive\",\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${baseUrl}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(\n `${serviceName} API error: ${response.status} - ${error}`,\n );\n }\n\n if (response.status === 204) return undefined as T;\n return response.json();\n }\n\n function sheetsApiRequest<T>(\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n return apiRequest<T>(SHEETS_API_BASE, \"Sheets\", endpoint, options);\n }\n\n function driveApiRequest<T>(\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n return apiRequest<T>(DRIVE_API_BASE, \"Drive\", endpoint, options);\n }\n\n return {\n async listSpreadsheets(options: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n } = {}): Promise<SpreadsheetFile[]> {\n const params = new URLSearchParams({\n q: \"mimeType='application/vnd.google-apps.spreadsheet' and trashed=false\",\n fields: \"files(id,name,mimeType,createdTime,modifiedTime,webViewLink)\",\n pageSize: String(options.maxResults ?? 20),\n orderBy: `${options.orderBy ?? \"modifiedTime\"} desc`,\n });\n\n const result = await driveApiRequest<{ files?: SpreadsheetFile[] }>(\n `/files?${params.toString()}`,\n );\n return result.files ?? [];\n },\n\n getSpreadsheet(spreadsheetId: string): Promise<Spreadsheet> {\n return sheetsApiRequest<Spreadsheet>(`/spreadsheets/${spreadsheetId}`);\n },\n\n async readRange(spreadsheetId: string, range: string): Promise<CellData> {\n const result = await sheetsApiRequest<\n { values?: unknown[][]; range: string }\n >(\n `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}`,\n );\n\n return { values: result.values ?? [], range: result.range };\n },\n\n async readRanges(\n spreadsheetId: string,\n ranges: string[],\n ): Promise<CellData[]> {\n const params = new URLSearchParams();\n ranges.forEach((range) => params.append(\"ranges\", range));\n\n const result = await sheetsApiRequest<{\n valueRanges: Array<{ values?: unknown[][]; range: string }>;\n }>(`/spreadsheets/${spreadsheetId}/values:batchGet?${params.toString()}`);\n\n return result.valueRanges.map((vr) => ({\n values: vr.values ?? [],\n range: vr.range,\n }));\n },\n\n writeRange(options: WriteRangeOptions): Promise<{\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n }> {\n const valueInputOption = options.valueInputOption ?? \"USER_ENTERED\";\n\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}/values/${\n encodeURIComponent(options.range)\n }?valueInputOption=${valueInputOption}`,\n {\n method: \"PUT\",\n body: JSON.stringify({ values: options.values }),\n },\n );\n },\n\n appendRange(options: AppendRangeOptions): Promise<{\n updates: {\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n };\n }> {\n const params = new URLSearchParams({\n valueInputOption: options.valueInputOption ?? \"USER_ENTERED\",\n insertDataOption: options.insertDataOption ?? \"INSERT_ROWS\",\n });\n\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}/values/${\n encodeURIComponent(options.range)\n }:append?${params.toString()}`,\n {\n method: \"POST\",\n body: JSON.stringify({ values: options.values }),\n },\n );\n },\n\n clearRange(\n spreadsheetId: string,\n range: string,\n ): Promise<{ clearedRange: string }> {\n return sheetsApiRequest(\n `/spreadsheets/${spreadsheetId}/values/${\n encodeURIComponent(range)\n }:clear`,\n {\n method: \"POST\",\n },\n );\n },\n\n batchUpdate(options: BatchUpdateOptions): Promise<unknown> {\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}:batchUpdate`,\n {\n method: \"POST\",\n body: JSON.stringify({\n requests: options.requests,\n includeSpreadsheetInResponse: options.includeSpreadsheetInResponse,\n responseRanges: options.responseRanges,\n }),\n },\n );\n },\n\n createSpreadsheet(options: CreateSpreadsheetOptions): Promise<Spreadsheet> {\n const body: {\n properties: { title: string };\n sheets?: Array<{\n properties: {\n title: string;\n gridProperties?: { rowCount: number; columnCount: number };\n };\n }>;\n } = { properties: { title: options.title } };\n\n if (options.sheets?.length) {\n body.sheets = options.sheets.map((sheet) => ({\n properties: {\n title: sheet.title,\n gridProperties: {\n rowCount: sheet.rowCount ?? 1000,\n columnCount: sheet.columnCount ?? 26,\n },\n },\n }));\n }\n\n return sheetsApiRequest(\"/spreadsheets\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n },\n\n async addSheet(\n spreadsheetId: string,\n title: string,\n options?: { rowCount?: number; columnCount?: number },\n ): Promise<Sheet> {\n const result = await sheetsApiRequest<{\n replies: Array<{ addSheet?: { properties: Sheet[\"properties\"] } }>;\n }>(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [\n {\n addSheet: {\n properties: {\n title,\n gridProperties: {\n rowCount: options?.rowCount ?? 1000,\n columnCount: options?.columnCount ?? 26,\n },\n },\n },\n },\n ],\n }),\n });\n\n const properties = result.replies[0]?.addSheet?.properties;\n if (!properties) throw new Error(\"Failed to add sheet\");\n\n return { properties };\n },\n\n async deleteSheet(spreadsheetId: string, sheetId: number): Promise<void> {\n await sheetsApiRequest(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{ deleteSheet: { sheetId } }],\n }),\n });\n },\n\n renameSheet(\n spreadsheetId: string,\n sheetId: number,\n title: string,\n ): Promise<unknown> {\n return sheetsApiRequest(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{\n updateSheetProperties: {\n properties: { sheetId, title },\n fields: \"title\",\n },\n }],\n }),\n });\n },\n\n async deleteSpreadsheet(\n spreadsheetId: string,\n options: { permanentlyDelete?: boolean } = {},\n ): Promise<\n { deleted: true; spreadsheetId: string; permanentlyDeleted: boolean }\n > {\n if (options.permanentlyDelete) {\n await driveApiRequest(`/files/${spreadsheetId}`, { method: \"DELETE\" });\n } else {\n await driveApiRequest(`/files/${spreadsheetId}`, {\n method: \"PATCH\",\n body: JSON.stringify({ trashed: true }),\n });\n }\n\n return {\n deleted: true,\n spreadsheetId,\n permanentlyDeleted: Boolean(options.permanentlyDelete),\n };\n },\n\n findReplace(options: {\n spreadsheetId: string;\n find: string;\n replacement: string;\n sheetId?: number;\n matchCase?: boolean;\n matchEntireCell?: boolean;\n searchByRegex?: boolean;\n }): Promise<unknown> {\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}:batchUpdate`,\n {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{\n findReplace: {\n find: options.find,\n replacement: options.replacement,\n sheetId: options.sheetId,\n matchCase: options.matchCase,\n matchEntireCell: options.matchEntireCell,\n searchByRegex: options.searchByRegex,\n allSheets: options.sheetId === undefined ? true : undefined,\n },\n }],\n }),\n },\n );\n },\n\n copySheet(options: {\n spreadsheetId: string;\n sheetId: number;\n destinationSpreadsheetId: string;\n }): Promise<unknown> {\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}:copyTo`,\n {\n method: \"POST\",\n body: JSON.stringify({\n destinationSpreadsheetId: options.destinationSpreadsheetId,\n }),\n },\n );\n },\n\n createChart(\n spreadsheetId: string,\n chart: Record<string, unknown>,\n ): Promise<unknown> {\n return sheetsApiRequest(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{ addChart: { chart } }],\n }),\n });\n },\n\n setDataValidation(options: {\n spreadsheetId: string;\n range: Record<string, unknown>;\n rule: Record<string, unknown>;\n }): Promise<unknown> {\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}:batchUpdate`,\n {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{\n repeatCell: {\n range: options.range,\n cell: { dataValidation: options.rule },\n fields: \"dataValidation\",\n },\n }],\n }),\n },\n );\n },\n };\n}\n\nexport type SheetsClient = ReturnType<typeof createSheetsClient>;\n",
|
|
526
|
+
"tools/add-sheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"add-sheet\",\n description: \"Add a new sheet/tab to an existing spreadsheet.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n title: v.string().describe(\"Title for the new sheet/tab\"),\n rowCount: v.number().min(1).max(10000).optional(),\n columnCount: v.number().min(1).max(18278).optional(),\n })\n )(),\n execute({ spreadsheetId, title, rowCount, columnCount }) {\n return createSheetsClient(DEFAULT_USER_ID).addSheet(spreadsheetId, title, {\n rowCount,\n columnCount,\n });\n },\n});\n",
|
|
527
|
+
"tools/append-rows.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"append-rows\",\n description:\n \"Append rows to a Google Sheets range. Use for trackers, logs, and adding records without overwriting existing rows.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n range: v.string().describe(\n \"A1 notation range/table to append to (e.g., 'Sheet1!A:C')\",\n ),\n values: v.array(v.array(v.any())).describe(\"2D array of rows to append\"),\n valueInputOption: v.enum([\"RAW\", \"USER_ENTERED\"]).default(\"USER_ENTERED\"),\n insertDataOption: v.enum([\"OVERWRITE\", \"INSERT_ROWS\"]).default(\n \"INSERT_ROWS\",\n ),\n })\n )(),\n execute(\n { spreadsheetId, range, values, valueInputOption, insertDataOption },\n ) {\n return createSheetsClient(DEFAULT_USER_ID).appendRange({\n spreadsheetId,\n range,\n values,\n valueInputOption,\n insertDataOption,\n });\n },\n});\n",
|
|
528
|
+
"tools/batch-update.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"batch-update\",\n description:\n \"Run Google Sheets batchUpdate requests for formatting, filters, dimensions, protected ranges, charts, and other advanced spreadsheet changes.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n requests: v.array(v.record(v.string(), v.any())).describe(\n \"Google Sheets API batchUpdate requests\",\n ),\n includeSpreadsheetInResponse: v.boolean().optional(),\n responseRanges: v.array(v.string()).optional(),\n })\n )(),\n execute(\n { spreadsheetId, requests, includeSpreadsheetInResponse, responseRanges },\n ) {\n return createSheetsClient(DEFAULT_USER_ID).batchUpdate({\n spreadsheetId,\n requests,\n includeSpreadsheetInResponse,\n responseRanges,\n });\n },\n});\n",
|
|
529
|
+
"tools/clear-range.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"clear-range\",\n description:\n \"Clear cell values from a Google Sheets range while preserving formatting.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n range: v.string().describe(\n \"A1 notation range to clear (e.g., 'Sheet1!A2:D100')\",\n ),\n })\n )(),\n execute({ spreadsheetId, range }) {\n return createSheetsClient(DEFAULT_USER_ID).clearRange(spreadsheetId, range);\n },\n});\n",
|
|
530
|
+
"tools/copy-sheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"copy-sheet\",\n description: \"Copy a sheet/tab to another spreadsheet.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"Source spreadsheet ID\"),\n sheetId: v.number().describe(\"Numeric source sheet ID\"),\n destinationSpreadsheetId: v.string().describe(\n \"Destination spreadsheet ID\",\n ),\n })\n )(),\n execute({ spreadsheetId, sheetId, destinationSpreadsheetId }) {\n return createSheetsClient(DEFAULT_USER_ID).copySheet({\n spreadsheetId,\n sheetId,\n destinationSpreadsheetId,\n });\n },\n});\n",
|
|
531
|
+
"tools/create-chart.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"create-chart\",\n description:\n \"Create an embedded chart. Provide a Google Sheets API EmbeddedChart spec without chartId.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n chart: v.record(v.string(), v.any()).describe(\n \"Google Sheets API EmbeddedChart spec\",\n ),\n })\n )(),\n execute({ spreadsheetId, chart }) {\n return createSheetsClient(DEFAULT_USER_ID).createChart(\n spreadsheetId,\n chart,\n );\n },\n});\n",
|
|
491
532
|
"tools/create-spreadsheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"create-spreadsheet\",\n description:\n \"Create a new Google Sheets spreadsheet with optional sheet configurations. Returns the new spreadsheet ID and URL.\",\n inputSchema: defineSchema((v) => v.object({\n title: v.string().describe(\"Title of the new spreadsheet\"),\n sheets: v\n .array(\n v.object({\n title: v.string().describe(\"Name of the sheet/tab\"),\n rowCount: v\n .number()\n .min(1)\n .max(10000)\n .optional()\n .describe(\"Number of rows (default: 1000)\"),\n columnCount: v\n .number()\n .min(1)\n .max(26)\n .optional()\n .describe(\"Number of columns (default: 26)\"),\n }),\n )\n .optional()\n .describe(\n \"Optional array of sheet configurations. If not provided, a single default sheet is created.\",\n ),\n initialData: v\n .object({\n sheetTitle: v.string().describe(\"Name of the sheet to write data to\"),\n range: v\n .string()\n .describe(\"Range in A1 notation (e.g., 'A1', 'A1:D10')\"),\n values: v\n .array(v.array(v.any()))\n .describe(\n \"2D array of values to write. Example: [['Name', 'Age'], ['John', 30]]\",\n ),\n })\n .optional()\n .describe(\"Optional initial data to populate the spreadsheet\"),\n }))(),\n async execute({ title, sheets, initialData }) {\n const client = createSheetsClient(DEFAULT_USER_ID);\n const spreadsheet = await client.createSpreadsheet({ title, sheets });\n\n if (!initialData) {\n return {\n id: spreadsheet.spreadsheetId,\n title: spreadsheet.properties.title,\n url: spreadsheet.spreadsheetUrl,\n sheets: spreadsheet.sheets.map(({ properties }) => ({\n id: properties.sheetId,\n title: properties.title,\n index: properties.index,\n })),\n };\n }\n\n await client.writeRange({\n spreadsheetId: spreadsheet.spreadsheetId,\n range: `${initialData.sheetTitle}!${initialData.range}`,\n values: initialData.values,\n valueInputOption: \"USER_ENTERED\",\n });\n\n return {\n id: spreadsheet.spreadsheetId,\n title: spreadsheet.properties.title,\n url: spreadsheet.spreadsheetUrl,\n sheets: spreadsheet.sheets.map(({ properties }) => ({\n id: properties.sheetId,\n title: properties.title,\n index: properties.index,\n })),\n };\n },\n});\n",
|
|
533
|
+
"tools/delete-sheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"delete-sheet\",\n description: \"Delete a sheet/tab from a spreadsheet by numeric sheet ID.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n sheetId: v.number().describe(\n \"Numeric sheet ID to delete, from get-spreadsheet\",\n ),\n })\n )(),\n execute({ spreadsheetId, sheetId }) {\n return createSheetsClient(DEFAULT_USER_ID).deleteSheet(\n spreadsheetId,\n sheetId,\n );\n },\n});\n",
|
|
534
|
+
"tools/delete-spreadsheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"delete-spreadsheet\",\n description:\n \"Delete an app-accessible spreadsheet file. Defaults to moving it to trash for safer cleanup.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The spreadsheet/Drive file ID\"),\n permanentlyDelete: v.boolean().default(false).describe(\n \"If true, permanently deletes instead of moving to trash\",\n ),\n })\n )(),\n execute({ spreadsheetId, permanentlyDelete }) {\n return createSheetsClient(DEFAULT_USER_ID).deleteSpreadsheet(\n spreadsheetId,\n { permanentlyDelete },\n );\n },\n});\n",
|
|
535
|
+
"tools/find-replace.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"find-replace\",\n description:\n \"Find and replace text in a spreadsheet, optionally limited to a single sheet ID.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n find: v.string().describe(\"Text or regex pattern to find\"),\n replacement: v.string().describe(\"Replacement text\"),\n sheetId: v.number().optional().describe(\n \"Optional numeric sheet ID to limit replacement\",\n ),\n matchCase: v.boolean().optional(),\n matchEntireCell: v.boolean().optional(),\n searchByRegex: v.boolean().optional(),\n })\n )(),\n execute(\n {\n spreadsheetId,\n find,\n replacement,\n sheetId,\n matchCase,\n matchEntireCell,\n searchByRegex,\n },\n ) {\n return createSheetsClient(DEFAULT_USER_ID).findReplace({\n spreadsheetId,\n find,\n replacement,\n sheetId,\n matchCase,\n matchEntireCell,\n searchByRegex,\n });\n },\n});\n",
|
|
492
536
|
"tools/get-spreadsheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"get-spreadsheet\",\n description:\n \"Get metadata about a Google Sheets spreadsheet including all sheet names, properties, and structure. Use this to discover available sheets and their dimensions.\",\n inputSchema: defineSchema((v) => v.object({\n spreadsheetId: v\n .string()\n .describe(\"The ID of the spreadsheet (from URL or list-spreadsheets)\"),\n }))(),\n async execute({ spreadsheetId }) {\n const client = createSheetsClient(DEFAULT_USER_ID);\n const spreadsheet = await client.getSpreadsheet(spreadsheetId);\n\n return {\n id: spreadsheet.spreadsheetId,\n title: spreadsheet.properties.title,\n url: spreadsheet.spreadsheetUrl,\n locale: spreadsheet.properties.locale,\n timeZone: spreadsheet.properties.timeZone,\n sheets: spreadsheet.sheets.map(({ properties }) => ({\n id: properties.sheetId,\n title: properties.title,\n index: properties.index,\n type: properties.sheetType,\n rowCount: properties.gridProperties?.rowCount,\n columnCount: properties.gridProperties?.columnCount,\n })),\n };\n },\n});\n",
|
|
493
537
|
"tools/list-spreadsheets.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"list-spreadsheets\",\n description:\n \"List recent Google Sheets spreadsheets from Google Drive. Returns spreadsheet names, IDs, and metadata.\",\n inputSchema: defineSchema((v) => v.object({\n maxResults: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of spreadsheets to return\"),\n orderBy: v\n .enum([\"createdTime\", \"modifiedTime\", \"name\"])\n .default(\"modifiedTime\")\n .describe(\"Sort order for results\"),\n }))(),\n async execute({ maxResults, orderBy }) {\n const client = createSheetsClient(DEFAULT_USER_ID);\n const spreadsheets = await client.listSpreadsheets({ maxResults, orderBy });\n\n return spreadsheets.map((spreadsheet) => ({\n id: spreadsheet.id,\n name: spreadsheet.name,\n url: spreadsheet.webViewLink,\n createdTime: spreadsheet.createdTime,\n modifiedTime: spreadsheet.modifiedTime,\n }));\n },\n});\n",
|
|
494
538
|
"tools/read-range.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"read-range\",\n description:\n \"Read cell data from a Google Sheets range. Returns a 2D array of values. Use A1 notation (e.g., 'Sheet1!A1:D10', 'A1:B', or just 'Sheet1' for entire sheet).\",\n inputSchema: defineSchema((v) => v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n range: v\n .string()\n .describe(\n \"Range in A1 notation (e.g., 'Sheet1!A1:D10', 'A1:B5', or 'Sheet1' for entire sheet)\",\n ),\n }))(),\n async execute({ spreadsheetId, range }) {\n const client = createSheetsClient(DEFAULT_USER_ID);\n const { range: resultRange, values } = await client.readRange(\n spreadsheetId,\n range,\n );\n\n return {\n range: resultRange,\n values,\n rowCount: values.length,\n columnCount: values[0]?.length ?? 0,\n };\n },\n});\n",
|
|
539
|
+
"tools/rename-sheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"rename-sheet\",\n description: \"Rename an existing sheet/tab by numeric sheet ID.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n sheetId: v.number().describe(\"Numeric sheet ID to rename\"),\n title: v.string().describe(\"New sheet/tab title\"),\n })\n )(),\n execute({ spreadsheetId, sheetId, title }) {\n return createSheetsClient(DEFAULT_USER_ID).renameSheet(\n spreadsheetId,\n sheetId,\n title,\n );\n },\n});\n",
|
|
540
|
+
"tools/set-data-validation.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"set-data-validation\",\n description: \"Set a Google Sheets data validation rule on a grid range.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n range: v.record(v.string(), v.any()).describe(\n \"Google Sheets API GridRange\",\n ),\n rule: v.record(v.string(), v.any()).describe(\n \"Google Sheets API DataValidationRule\",\n ),\n })\n )(),\n execute({ spreadsheetId, range, rule }) {\n return createSheetsClient(DEFAULT_USER_ID).setDataValidation({\n spreadsheetId,\n range,\n rule,\n });\n },\n});\n",
|
|
495
541
|
"tools/write-range.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"write-range\",\n description:\n \"Write data to a Google Sheets range. Overwrites existing content in the specified range. Provide data as a 2D array where each inner array is a row.\",\n inputSchema: defineSchema((v) => v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n range: v\n .string()\n .describe(\n \"Range in A1 notation where to write data (e.g., 'Sheet1!A1', 'Sheet1!A1:D5')\",\n ),\n values: v\n .array(v.array(v.any()))\n .describe(\n \"2D array of values to write. Each inner array represents a row. Example: [['Name', 'Age'], ['John', 30], ['Jane', 25]]\",\n ),\n valueInputOption: v\n .enum([\"RAW\", \"USER_ENTERED\"])\n .default(\"USER_ENTERED\")\n .describe(\n \"RAW: Values are stored as-is. USER_ENTERED: Values are parsed as if typed by user (formulas, numbers, dates)\",\n ),\n }))(),\n async execute({ spreadsheetId, range, values, valueInputOption }) {\n const client = createSheetsClient(DEFAULT_USER_ID);\n return client.writeRange({ spreadsheetId, range, values, valueInputOption });\n },\n});\n"
|
|
496
542
|
}
|
|
497
543
|
},
|
|
@@ -512,7 +558,7 @@ export default {
|
|
|
512
558
|
"files": {
|
|
513
559
|
"app/api/auth/slack/callback/route.ts": "import { createOAuthCallbackHandler, slackConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(slackConfig, { tokenStore: hybridTokenStore });\n",
|
|
514
560
|
"app/api/auth/slack/route.ts": "import { createOAuthInitHandler, slackConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(slackConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
|
|
515
|
-
"lib/slack-client.ts": "/**\n * Slack API Client\n *\n * Provides a type-safe interface to Slack API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") {\n // @ts-ignore - Deno global\n return Deno.env.get(key);\n }\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) {\n // @ts-ignore - process global\n return process.env[key];\n }\n\n return undefined;\n}\n\nconst SLACK_API_BASE = \"https://slack.com/api\";\n\nexport interface SlackChannel {\n id: string;\n name: string;\n is_channel: boolean;\n is_private: boolean;\n is_member: boolean;\n topic?: { value: string };\n purpose?: { value: string };\n}\n\nexport interface SlackMessage {\n type: string;\n user?: string;\n text: string;\n ts: string;\n thread_ts?: string;\n reply_count?: number;\n reactions?: Array<{ name: string; count: number }>;\n}\n\nexport interface SlackUser {\n id: string;\n name: string;\n real_name: string;\n profile: {\n display_name: string;\n email?: string;\n image_48?: string;\n };\n}\n\n/**\n * Slack OAuth provider configuration\n */\nexport const slackOAuthProvider = {\n name: \"slack\",\n authorizationUrl: \"https://slack.com/oauth/v2/authorize\",\n tokenUrl: \"https://slack.com/api/oauth.v2.access\",\n clientId: getEnv(\"SLACK_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"SLACK_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"channels:history\",\n \"channels:read\",\n \"chat:write\",\n \"
|
|
561
|
+
"lib/slack-client.ts": "/**\n * Slack API Client\n *\n * Provides a type-safe interface to Slack API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") {\n // @ts-ignore - Deno global\n return Deno.env.get(key);\n }\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) {\n // @ts-ignore - process global\n return process.env[key];\n }\n\n return undefined;\n}\n\nconst SLACK_API_BASE = \"https://slack.com/api\";\n\nexport interface SlackChannel {\n id: string;\n name: string;\n is_channel: boolean;\n is_private: boolean;\n is_member: boolean;\n topic?: { value: string };\n purpose?: { value: string };\n}\n\nexport interface SlackMessage {\n type: string;\n user?: string;\n text: string;\n ts: string;\n thread_ts?: string;\n reply_count?: number;\n reactions?: Array<{ name: string; count: number }>;\n}\n\nexport interface SlackUser {\n id: string;\n name: string;\n real_name: string;\n profile: {\n display_name: string;\n email?: string;\n image_48?: string;\n };\n}\n\n/**\n * Slack OAuth provider configuration\n */\nexport const slackOAuthProvider = {\n name: \"slack\",\n authorizationUrl: \"https://slack.com/oauth/v2/authorize\",\n tokenUrl: \"https://slack.com/api/oauth.v2.access\",\n clientId: getEnv(\"SLACK_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"SLACK_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"channels:history\",\n \"channels:read\",\n \"chat:write\",\n \"groups:history\",\n \"groups:read\",\n \"im:history\",\n \"im:read\",\n \"mpim:history\",\n \"mpim:read\",\n \"users:read\",\n ],\n callbackPath: \"/api/auth/slack/callback\",\n};\n\nexport interface SlackClient {\n listChannels(options?: {\n limit?: number;\n excludeArchived?: boolean;\n }): Promise<SlackChannel[]>;\n getMessages(\n channelId: string,\n options?: { limit?: number; oldest?: string },\n ): Promise<SlackMessage[]>;\n sendMessage(\n channelId: string,\n text: string,\n options?: { threadTs?: string; unfurlLinks?: boolean },\n ): Promise<{ ts: string; channel: string }>;\n getUser(userId: string): Promise<SlackUser>;\n getThread(channelId: string, threadTs: string): Promise<SlackMessage[]>;\n searchMessages(\n query: string,\n options?: { count?: number },\n ): Promise<SlackMessage[]>;\n}\n\n/**\n * Create a Slack client for a specific user\n */\nexport function createSlackClient(userId: string): SlackClient {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(slackOAuthProvider, userId, \"slack\");\n if (!token) {\n throw new Error(\n \"Slack not connected. Please connect your Slack account first.\",\n );\n }\n return token;\n }\n\n async function apiRequest<T>(\n method: string,\n params: Record<string, unknown> = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${SLACK_API_BASE}/${method}`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json; charset=utf-8\",\n },\n body: JSON.stringify(params),\n });\n\n const data = await response.json();\n\n if (!data.ok) {\n throw new Error(`Slack API error: ${data.error}`);\n }\n\n return data as T;\n }\n\n return {\n /**\n * List channels the user is a member of\n */\n async listChannels(options = {}): Promise<SlackChannel[]> {\n const result = await apiRequest<{ channels: SlackChannel[] }>(\n \"conversations.list\",\n {\n limit: options.limit ?? 100,\n exclude_archived: options.excludeArchived ?? true,\n types: \"public_channel,private_channel\",\n },\n );\n return result.channels;\n },\n\n /**\n * Get messages from a channel\n */\n async getMessages(\n channelId: string,\n options = {},\n ): Promise<SlackMessage[]> {\n const result = await apiRequest<{ messages: SlackMessage[] }>(\n \"conversations.history\",\n {\n channel: channelId,\n limit: options.limit ?? 20,\n oldest: options.oldest,\n },\n );\n return result.messages;\n },\n\n /**\n * Send a message to a channel\n */\n async sendMessage(\n channelId: string,\n text: string,\n options = {},\n ): Promise<{ ts: string; channel: string }> {\n return apiRequest<{ ts: string; channel: string }>(\"chat.postMessage\", {\n channel: channelId,\n text,\n thread_ts: options.threadTs,\n unfurl_links: options.unfurlLinks ?? true,\n });\n },\n\n /**\n * Get user info\n */\n async getUser(userId: string): Promise<SlackUser> {\n const result = await apiRequest<{ user: SlackUser }>(\"users.info\", {\n user: userId,\n });\n return result.user;\n },\n\n /**\n * Get thread replies\n */\n async getThread(\n channelId: string,\n threadTs: string,\n ): Promise<SlackMessage[]> {\n const result = await apiRequest<{ messages: SlackMessage[] }>(\n \"conversations.replies\",\n {\n channel: channelId,\n ts: threadTs,\n },\n );\n return result.messages;\n },\n\n /**\n * Search messages\n */\n async searchMessages(\n query: string,\n options = {},\n ): Promise<SlackMessage[]> {\n const result = await apiRequest<{\n messages: { matches: SlackMessage[] };\n }>(\"search.messages\", {\n query,\n count: options.count ?? 20,\n });\n return result.messages.matches;\n },\n };\n}\n",
|
|
516
562
|
"tools/get-messages.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSlackClient } from \"../../lib/slack-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype SlackMessage = {\n text?: string;\n user?: string;\n ts: string;\n thread_ts?: string;\n reply_count?: number;\n reactions?: Array<{ name: string; count: number }>;\n};\n\nexport default tool({\n id: \"get-messages\",\n description: \"Get recent messages from a Slack channel\",\n inputSchema: defineSchema((v) => v.object({\n channel: v.string().describe(\"Channel ID (e.g., 'C1234567890')\"),\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of messages to return\"),\n }))(),\n execute: async ({ channel, limit }, context) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const slack = createSlackClient(userId);\n const messages = await slack.getMessages(channel, { limit });\n\n return {\n messages: messages.map((msg: SlackMessage) => ({\n text: msg.text ?? \"\",\n user: msg.user ?? \"unknown\",\n timestamp: msg.ts,\n threadTs: msg.thread_ts,\n replyCount: msg.reply_count ?? 0,\n reactions: msg.reactions?.map((r) => `${r.name} (${r.count})`) ?? [],\n })),\n count: messages.length,\n channel,\n message: `Retrieved ${messages.length} message(s) from channel.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Slack not connected. Please connect your Slack account.\",\n connectUrl: \"/api/auth/slack\",\n };\n }\n throw error;\n }\n },\n});\n",
|
|
517
563
|
"tools/list-channels.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSlackClient } from \"../../lib/slack-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype SlackChannel = {\n id: string;\n name: string;\n is_private: boolean;\n is_member: boolean;\n topic?: { value: string };\n purpose?: { value: string };\n};\n\nexport default tool({\n id: \"list-channels\",\n description: \"List Slack channels the user is a member of\",\n inputSchema: defineSchema((v) => v.object({\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of channels to return\"),\n excludeArchived: v\n .boolean()\n .default(true)\n .describe(\"Exclude archived channels\"),\n }))(),\n execute: async ({ limit, excludeArchived }, context) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const slack = createSlackClient(userId);\n const channels = await slack.listChannels({ limit, excludeArchived });\n const count = channels.length;\n\n return {\n channels: channels.map((ch: SlackChannel) => ({\n id: ch.id,\n name: ch.name,\n isPrivate: ch.is_private,\n isMember: ch.is_member,\n topic: ch.topic?.value ?? null,\n purpose: ch.purpose?.value ?? null,\n })),\n count,\n message: `Found ${count} channel(s).`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Slack not connected. Please connect your Slack account.\",\n connectUrl: \"/api/auth/slack\",\n };\n }\n\n throw error;\n }\n },\n});\n",
|
|
518
564
|
"tools/send-message.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSlackClient } from \"../../lib/slack-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"send-message\",\n description: \"Send a message to a Slack channel\",\n inputSchema: defineSchema((v) => v.object({\n channel: v\n .string()\n .describe(\"Channel ID or name (e.g., 'C1234567890' or '#general')\"),\n text: v.string().min(1).describe(\"Message text to send\"),\n threadTs: v\n .string()\n .optional()\n .describe(\"Thread timestamp to reply to (for threaded messages)\"),\n }))(),\n execute: async ({ channel, text, threadTs }, context) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const slack = createSlackClient(userId);\n const result = await slack.sendMessage(channel, text, { threadTs });\n\n return {\n success: true,\n messageTs: result.ts,\n channel: result.channel,\n message: threadTs\n ? `Reply sent to thread in ${channel}.`\n : `Message sent to ${channel}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Slack not connected. Please connect your Slack account.\",\n connectUrl: \"/api/auth/slack\",\n };\n }\n throw error;\n }\n },\n});\n"
|