From 687b03ba7467e3432303b174d29f9507f970baad Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Thu, 27 Mar 2025 00:06:10 +0000 Subject: [PATCH] feat: add Vercel integration for project deployment This commit introduces Vercel integration, enabling users to deploy projects directly to Vercel. It includes: - New Vercel types and store for managing connections and stats. - A VercelConnection component for managing Vercel account connections. - A VercelDeploymentLink component for displaying deployment links. - API routes for handling Vercel deployments. - Updates to the HeaderActionButtons component to support Vercel deployment. The integration allows users to connect their Vercel accounts, view project stats, and deploy projects with ease. --- .../tabs/connections/ConnectionsTab.tsx | 4 + .../tabs/connections/VercelConnection.tsx | 289 ++++++++++++++++++ .../chat/NetlifyDeploymentLink.client.tsx | 4 +- .../chat/VercelDeploymentLink.client.tsx | 158 ++++++++++ .../header/HeaderActionButtons.client.tsx | 165 ++++++++-- app/lib/stores/vercel.ts | 94 ++++++ .../{api.deploy.ts => api.netlify-deploy.ts} | 0 app/routes/api.vercel-deploy.ts | 248 +++++++++++++++ app/types/vercel.ts | 40 +++ 9 files changed, 982 insertions(+), 20 deletions(-) create mode 100644 app/components/@settings/tabs/connections/VercelConnection.tsx create mode 100644 app/components/chat/VercelDeploymentLink.client.tsx create mode 100644 app/lib/stores/vercel.ts rename app/routes/{api.deploy.ts => api.netlify-deploy.ts} (100%) create mode 100644 app/routes/api.vercel-deploy.ts create mode 100644 app/types/vercel.ts diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx index 72ff6434..defd7aeb 100644 --- a/app/components/@settings/tabs/connections/ConnectionsTab.tsx +++ b/app/components/@settings/tabs/connections/ConnectionsTab.tsx @@ -1,5 +1,6 @@ import { motion } from 'framer-motion'; import React, { Suspense } from 'react'; +import VercelConnection from './VercelConnection'; // Use React.lazy for dynamic imports const GithubConnection = React.lazy(() => import('./GithubConnection')); @@ -39,6 +40,9 @@ export default function ConnectionsTab() { }> + }> + + ); diff --git a/app/components/@settings/tabs/connections/VercelConnection.tsx b/app/components/@settings/tabs/connections/VercelConnection.tsx new file mode 100644 index 00000000..4a442a08 --- /dev/null +++ b/app/components/@settings/tabs/connections/VercelConnection.tsx @@ -0,0 +1,289 @@ +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { toast } from 'react-toastify'; +import { useStore } from '@nanostores/react'; +import { logStore } from '~/lib/stores/logs'; +import { classNames } from '~/utils/classNames'; +import { + vercelConnection, + isConnecting, + isFetchingStats, + updateVercelConnection, + fetchVercelStats, +} from '~/lib/stores/vercel'; + +export default function VercelConnection() { + const connection = useStore(vercelConnection); + const connecting = useStore(isConnecting); + const fetchingStats = useStore(isFetchingStats); + const [isProjectsExpanded, setIsProjectsExpanded] = useState(false); + + useEffect(() => { + const fetchProjects = async () => { + if (connection.user && connection.token) { + await fetchVercelStats(connection.token); + } + }; + fetchProjects(); + }, [connection.user, connection.token]); + + const handleConnect = async (event: React.FormEvent) => { + event.preventDefault(); + isConnecting.set(true); + + try { + const response = await fetch('https://api.vercel.com/v2/user', { + headers: { + Authorization: `Bearer ${connection.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Invalid token or unauthorized'); + } + + const userData = (await response.json()) as any; + updateVercelConnection({ + user: userData.user || userData, // Handle both possible structures + token: connection.token, + }); + + await fetchVercelStats(connection.token); + toast.success('Successfully connected to Vercel'); + } catch (error) { + console.error('Auth error:', error); + logStore.logError('Failed to authenticate with Vercel', { error }); + toast.error('Failed to connect to Vercel'); + updateVercelConnection({ user: null, token: '' }); + } finally { + isConnecting.set(false); + } + }; + + const handleDisconnect = () => { + updateVercelConnection({ user: null, token: '' }); + toast.success('Disconnected from Vercel'); + }; + + console.log('connection', connection); + + return ( + +
+
+
+ +

Vercel Connection

+
+
+ + {!connection.user ? ( +
+
+ + updateVercelConnection({ ...connection, token: e.target.value })} + disabled={connecting} + placeholder="Enter your Vercel personal access token" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', + 'border border-[#E5E5E5] dark:border-[#333333]', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive', + 'disabled:opacity-50', + )} + /> + + + +
+ ) : ( +
+
+
+ + +
+ Connected to Vercel + +
+
+ +
+ {/* Debug output */} +
{JSON.stringify(connection.user, null, 2)}
+ + User Avatar +
+

+ {connection.user?.username || connection.user?.user?.username || 'Vercel User'} +

+

+ {connection.user?.email || connection.user?.user?.email || 'No email available'} +

+
+
+ + {fetchingStats ? ( +
+
+ Fetching Vercel projects... +
+ ) : ( +
+ + {isProjectsExpanded && connection.stats?.projects?.length ? ( +
+ {connection.stats.projects.map((project) => ( + +
+
+
+
+ {project.name} +
+
+ {project.targets?.production?.alias && project.targets.production.alias.length > 0 ? ( + <> + a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`} + target="_blank" + rel="noopener noreferrer" + className="hover:text-bolt-elements-borderColorActive" + > + {project.targets.production.alias.find( + (a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'), + ) || project.targets.production.alias[0]} + + + +
+ {new Date(project.createdAt).toLocaleDateString()} + + + ) : project.latestDeployments && project.latestDeployments.length > 0 ? ( + <> + + {project.latestDeployments[0].url} + + + +
+ {new Date(project.latestDeployments[0].created).toLocaleDateString()} + + + ) : null} +
+
+ {project.framework && ( +
+ +
+ {project.framework} + +
+ )} +
+ + ))} +
+ ) : isProjectsExpanded ? ( +
+
+ No projects found in your Vercel account +
+ ) : null} +
+ )} +
+ )} +
+ + ); +} diff --git a/app/components/chat/NetlifyDeploymentLink.client.tsx b/app/components/chat/NetlifyDeploymentLink.client.tsx index da8e0b41..4e60793f 100644 --- a/app/components/chat/NetlifyDeploymentLink.client.tsx +++ b/app/components/chat/NetlifyDeploymentLink.client.tsx @@ -30,10 +30,10 @@ export function NetlifyDeploymentLink() { rel="noopener noreferrer" className="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textSecondary hover:text-[#00AD9F] z-50" onClick={(e) => { - e.stopPropagation(); // Add this to prevent click from bubbling up + e.stopPropagation(); // This is to prevent click from bubbling up }} > -
+
diff --git a/app/components/chat/VercelDeploymentLink.client.tsx b/app/components/chat/VercelDeploymentLink.client.tsx new file mode 100644 index 00000000..ecb5a587 --- /dev/null +++ b/app/components/chat/VercelDeploymentLink.client.tsx @@ -0,0 +1,158 @@ +import { useStore } from '@nanostores/react'; +import { vercelConnection } from '~/lib/stores/vercel'; +import { chatId } from '~/lib/persistence/useChatHistory'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { useEffect, useState } from 'react'; + +export function VercelDeploymentLink() { + const connection = useStore(vercelConnection); + const currentChatId = useStore(chatId); + const [deploymentUrl, setDeploymentUrl] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + async function fetchProjectData() { + if (!connection.token || !currentChatId) { + return; + } + + // Check if we have a stored project ID for this chat + const projectId = localStorage.getItem(`vercel-project-${currentChatId}`); + + if (!projectId) { + return; + } + + setIsLoading(true); + + try { + // Fetch projects directly from the API + const projectsResponse = await fetch('https://api.vercel.com/v9/projects', { + headers: { + Authorization: `Bearer ${connection.token}`, + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }); + + if (!projectsResponse.ok) { + throw new Error(`Failed to fetch projects: ${projectsResponse.status}`); + } + + const projectsData = (await projectsResponse.json()) as any; + const projects = projectsData.projects || []; + + // Extract the chat number from currentChatId + const chatNumber = currentChatId.split('-')[0]; + + // Find project by matching the chat number in the name + const project = projects.find((p: { name: string | string[] }) => p.name.includes(`bolt-diy-${chatNumber}`)); + + if (project) { + // Fetch project details including deployments + const projectDetailsResponse = await fetch(`https://api.vercel.com/v9/projects/${project.id}`, { + headers: { + Authorization: `Bearer ${connection.token}`, + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }); + + if (projectDetailsResponse.ok) { + const projectDetails = (await projectDetailsResponse.json()) as any; + + // Try to get URL from production aliases first + if (projectDetails.targets?.production?.alias && projectDetails.targets.production.alias.length > 0) { + // Find the clean URL (without -projects.vercel.app) + const cleanUrl = projectDetails.targets.production.alias.find( + (a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'), + ); + + if (cleanUrl) { + setDeploymentUrl(`https://${cleanUrl}`); + return; + } else { + // If no clean URL found, use the first alias + setDeploymentUrl(`https://${projectDetails.targets.production.alias[0]}`); + return; + } + } + } + + // If no aliases or project details failed, try fetching deployments + const deploymentsResponse = await fetch( + `https://api.vercel.com/v6/deployments?projectId=${project.id}&limit=1`, + { + headers: { + Authorization: `Bearer ${connection.token}`, + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }, + ); + + if (deploymentsResponse.ok) { + const deploymentsData = (await deploymentsResponse.json()) as any; + + if (deploymentsData.deployments && deploymentsData.deployments.length > 0) { + setDeploymentUrl(`https://${deploymentsData.deployments[0].url}`); + return; + } + } + } + + // Fallback to API call if not found in fetched projects + const fallbackResponse = await fetch(`/api/vercel-deploy?projectId=${projectId}&token=${connection.token}`, { + method: 'GET', + }); + + const data = await fallbackResponse.json(); + + if ((data as { deploy?: { url?: string } }).deploy?.url) { + setDeploymentUrl((data as { deploy: { url: string } }).deploy.url); + } else if ((data as { project?: { url?: string } }).project?.url) { + setDeploymentUrl((data as { project: { url: string } }).project.url); + } + } catch (err) { + console.error('Error fetching Vercel deployment:', err); + } finally { + setIsLoading(false); + } + } + + fetchProjectData(); + }, [connection.token, currentChatId]); + + if (!deploymentUrl) { + return null; + } + + return ( + + + + { + e.stopPropagation(); + }} + > +
+ + + + + {deploymentUrl} + + + + + + ); +} diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index d6273f57..56945c9e 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -3,26 +3,30 @@ import { toast } from 'react-toastify'; import useViewport from '~/lib/hooks'; import { chatStore } from '~/lib/stores/chat'; import { netlifyConnection } from '~/lib/stores/netlify'; +import { vercelConnection } from '~/lib/stores/vercel'; import { workbenchStore } from '~/lib/stores/workbench'; import { webcontainer } from '~/lib/webcontainer'; import { classNames } from '~/utils/classNames'; import { path } from '~/utils/path'; import { useEffect, useRef, useState } from 'react'; import type { ActionCallbackData } from '~/lib/runtime/message-parser'; -import { chatId } from '~/lib/persistence/useChatHistory'; // Add this import +import { chatId } from '~/lib/persistence/useChatHistory'; import { streamingState } from '~/lib/stores/streaming'; import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client'; +import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client'; interface HeaderActionButtonsProps {} export function HeaderActionButtons({}: HeaderActionButtonsProps) { const showWorkbench = useStore(workbenchStore.showWorkbench); const { showChat } = useStore(chatStore); - const connection = useStore(netlifyConnection); + const netlifyConn = useStore(netlifyConnection); + const vercelConn = useStore(vercelConnection); const [activePreviewIndex] = useState(0); const previews = useStore(workbenchStore.previews); const activePreview = previews[activePreviewIndex]; const [isDeploying, setIsDeploying] = useState(false); + const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | null>(null); const isSmallViewport = useViewport(1024); const canHideChat = showWorkbench || !showChat; const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -42,8 +46,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { const currentChatId = useStore(chatId); - const handleDeploy = async () => { - if (!connection.user || !connection.token) { + const handleNetlifyDeploy = async () => { + if (!netlifyConn.user || !netlifyConn.token) { toast.error('Please connect to Netlify first in the settings tab!'); return; } @@ -118,7 +122,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`); // Deploy using the API route with file contents - const response = await fetch('/api/deploy', { + const response = await fetch('/api/netlify-deploy', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -126,7 +130,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { body: JSON.stringify({ siteId: existingSiteId || undefined, files: fileContents, - token: connection.token, + token: netlifyConn.token, chatId: currentChatId, // Use chatId instead of artifact.id }), }); @@ -149,7 +153,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { `https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`, { headers: { - Authorization: `Bearer ${connection.token}`, + Authorization: `Bearer ${netlifyConn.token}`, }, }, ); @@ -203,6 +207,125 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { } }; + const handleVercelDeploy = async () => { + if (!vercelConn.user || !vercelConn.token) { + toast.error('Please connect to Vercel first in the settings tab!'); + return; + } + + if (!currentChatId) { + toast.error('No active chat found'); + return; + } + + try { + setIsDeploying(true); + setDeployingTo('vercel'); + + const artifact = workbenchStore.firstArtifact; + + if (!artifact) { + throw new Error('No active project found'); + } + + const actionId = 'build-' + Date.now(); + const actionData: ActionCallbackData = { + messageId: 'vercel build', + artifactId: artifact.id, + actionId, + action: { + type: 'build' as const, + content: 'npm run build', + }, + }; + + // Add the action first + artifact.runner.addAction(actionData); + + // Then run it + await artifact.runner.runAction(actionData); + + if (!artifact.runner.buildOutput) { + throw new Error('Build failed'); + } + + // Get the build files + const container = await webcontainer; + + // Remove /home/project from buildPath if it exists + const buildPath = artifact.runner.buildOutput.path.replace('/home/project', ''); + + // Get all files recursively + async function getAllFiles(dirPath: string): Promise> { + const files: Record = {}; + const entries = await container.fs.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + if (entry.isFile()) { + const content = await container.fs.readFile(fullPath, 'utf-8'); + + // Remove /dist prefix from the path + const deployPath = fullPath.replace(buildPath, ''); + files[deployPath] = content; + } else if (entry.isDirectory()) { + const subFiles = await getAllFiles(fullPath); + Object.assign(files, subFiles); + } + } + + return files; + } + + const fileContents = await getAllFiles(buildPath); + + // Use chatId instead of artifact.id + const existingProjectId = localStorage.getItem(`vercel-project-${currentChatId}`); + + // Deploy using the API route with file contents + const response = await fetch('/api/vercel-deploy', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + projectId: existingProjectId || undefined, + files: fileContents, + token: vercelConn.token, + chatId: currentChatId, + }), + }); + + const data = (await response.json()) as any; + + if (!response.ok || !data.deploy || !data.project) { + console.error('Invalid deploy response:', data); + throw new Error(data.error || 'Invalid deployment response'); + } + + // Store the project ID if it's a new project + if (data.project) { + localStorage.setItem(`vercel-project-${currentChatId}`, data.project.id); + } + + toast.success( +
+ Deployed successfully to Vercel!{' '} + + View site + +
, + ); + } catch (error) { + console.error('Vercel deploy error:', error); + toast.error(error instanceof Error ? error.message : 'Vercel deployment failed'); + } finally { + setIsDeploying(false); + setDeployingTo(null); + } + }; + return (
@@ -213,7 +336,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { onClick={() => setIsDropdownOpen(!isDropdownOpen)} className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2" > - {isDeploying ? 'Deploying...' : 'Deploy'} + {isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
@@ -225,10 +348,10 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { diff --git a/app/lib/stores/vercel.ts b/app/lib/stores/vercel.ts new file mode 100644 index 00000000..3258642c --- /dev/null +++ b/app/lib/stores/vercel.ts @@ -0,0 +1,94 @@ +import { atom } from 'nanostores'; +import type { VercelConnection } from '~/types/vercel'; +import { logStore } from './logs'; +import { toast } from 'react-toastify'; + +// Initialize with stored connection or defaults +const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('vercel_connection') : null; +const initialConnection: VercelConnection = storedConnection + ? JSON.parse(storedConnection) + : { + user: null, + token: '', + stats: undefined, + }; + +export const vercelConnection = atom(initialConnection); +export const isConnecting = atom(false); +export const isFetchingStats = atom(false); + +export const updateVercelConnection = (updates: Partial) => { + const currentState = vercelConnection.get(); + const newState = { ...currentState, ...updates }; + vercelConnection.set(newState); + + // Persist to localStorage + if (typeof window !== 'undefined') { + localStorage.setItem('vercel_connection', JSON.stringify(newState)); + } +}; + +export async function fetchVercelStats(token: string) { + try { + isFetchingStats.set(true); + + const projectsResponse = await fetch('https://api.vercel.com/v9/projects', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!projectsResponse.ok) { + throw new Error(`Failed to fetch projects: ${projectsResponse.status}`); + } + + const projectsData = (await projectsResponse.json()) as any; + const projects = projectsData.projects || []; + + // Fetch latest deployment for each project + const projectsWithDeployments = await Promise.all( + projects.map(async (project: any) => { + try { + const deploymentsResponse = await fetch( + `https://api.vercel.com/v6/deployments?projectId=${project.id}&limit=1`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }, + ); + + if (deploymentsResponse.ok) { + const deploymentsData = (await deploymentsResponse.json()) as any; + return { + ...project, + latestDeployments: deploymentsData.deployments || [], + }; + } + + return project; + } catch (error) { + console.error(`Error fetching deployments for project ${project.id}:`, error); + return project; + } + }), + ); + + const currentState = vercelConnection.get(); + updateVercelConnection({ + ...currentState, + stats: { + projects: projectsWithDeployments, + totalProjects: projectsWithDeployments.length, + }, + }); + } catch (error) { + console.error('Vercel API Error:', error); + logStore.logError('Failed to fetch Vercel stats', { error }); + toast.error('Failed to fetch Vercel statistics'); + } finally { + isFetchingStats.set(false); + } +} diff --git a/app/routes/api.deploy.ts b/app/routes/api.netlify-deploy.ts similarity index 100% rename from app/routes/api.deploy.ts rename to app/routes/api.netlify-deploy.ts diff --git a/app/routes/api.vercel-deploy.ts b/app/routes/api.vercel-deploy.ts new file mode 100644 index 00000000..c9f6909d --- /dev/null +++ b/app/routes/api.vercel-deploy.ts @@ -0,0 +1,248 @@ +import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from '@remix-run/cloudflare'; +import type { VercelProjectInfo } from '~/types/vercel'; + +// Add loader function to handle GET requests +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const projectId = url.searchParams.get('projectId'); + const token = url.searchParams.get('token'); + + if (!projectId || !token) { + return json({ error: 'Missing projectId or token' }, { status: 400 }); + } + + try { + // Get project info + const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${projectId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!projectResponse.ok) { + return json({ error: 'Failed to fetch project' }, { status: 400 }); + } + + const projectData = (await projectResponse.json()) as any; + + // Get latest deployment + const deploymentsResponse = await fetch(`https://api.vercel.com/v6/deployments?projectId=${projectId}&limit=1`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!deploymentsResponse.ok) { + return json({ error: 'Failed to fetch deployments' }, { status: 400 }); + } + + const deploymentsData = (await deploymentsResponse.json()) as any; + + const latestDeployment = deploymentsData.deployments?.[0]; + + return json({ + project: { + id: projectData.id, + name: projectData.name, + url: `https://${projectData.name}.vercel.app`, + }, + deploy: latestDeployment + ? { + id: latestDeployment.id, + state: latestDeployment.state, + url: latestDeployment.url ? `https://${latestDeployment.url}` : `https://${projectData.name}.vercel.app`, + } + : null, + }); + } catch (error) { + console.error('Error fetching Vercel deployment:', error); + return json({ error: 'Failed to fetch deployment' }, { status: 500 }); + } +} + +interface DeployRequestBody { + projectId?: string; + files: Record; + chatId: string; +} + +// Existing action function for POST requests +export async function action({ request }: ActionFunctionArgs) { + try { + const { projectId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string }; + + if (!token) { + return json({ error: 'Not connected to Vercel' }, { status: 401 }); + } + + let targetProjectId = projectId; + let projectInfo: VercelProjectInfo | undefined; + + // If no projectId provided, create a new project + if (!targetProjectId) { + const projectName = `bolt-diy-${chatId}-${Date.now()}`; + const createProjectResponse = await fetch('https://api.vercel.com/v9/projects', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: projectName, + framework: null, + }), + }); + + if (!createProjectResponse.ok) { + const errorData = (await createProjectResponse.json()) as any; + return json( + { error: `Failed to create project: ${errorData.error?.message || 'Unknown error'}` }, + { status: 400 }, + ); + } + + const newProject = (await createProjectResponse.json()) as any; + targetProjectId = newProject.id; + projectInfo = { + id: newProject.id, + name: newProject.name, + url: `https://${newProject.name}.vercel.app`, + chatId, + }; + } else { + // Get existing project info + const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${targetProjectId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (projectResponse.ok) { + const existingProject = (await projectResponse.json()) as any; + projectInfo = { + id: existingProject.id, + name: existingProject.name, + url: `https://${existingProject.name}.vercel.app`, + chatId, + }; + } else { + // If project doesn't exist, create a new one + const projectName = `bolt-diy-${chatId}-${Date.now()}`; + const createProjectResponse = await fetch('https://api.vercel.com/v9/projects', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: projectName, + framework: null, + }), + }); + + if (!createProjectResponse.ok) { + const errorData = (await createProjectResponse.json()) as any; + return json( + { error: `Failed to create project: ${errorData.error?.message || 'Unknown error'}` }, + { status: 400 }, + ); + } + + const newProject = (await createProjectResponse.json()) as any; + targetProjectId = newProject.id; + projectInfo = { + id: newProject.id, + name: newProject.name, + url: `https://${newProject.name}.vercel.app`, + chatId, + }; + } + } + + // Prepare files for deployment + const deploymentFiles = []; + + for (const [filePath, content] of Object.entries(files)) { + // Ensure file path doesn't start with a slash for Vercel + const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; + deploymentFiles.push({ + file: normalizedPath, + data: content, + }); + } + + // Create a new deployment + const deployResponse = await fetch(`https://api.vercel.com/v13/deployments`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: projectInfo.name, + project: targetProjectId, + target: 'production', + files: deploymentFiles, + routes: [{ src: '/(.*)', dest: '/$1' }], + }), + }); + + if (!deployResponse.ok) { + const errorData = (await deployResponse.json()) as any; + return json( + { error: `Failed to create deployment: ${errorData.error?.message || 'Unknown error'}` }, + { status: 400 }, + ); + } + + const deployData = (await deployResponse.json()) as any; + + // Poll for deployment status + let retryCount = 0; + const maxRetries = 60; + let deploymentUrl = ''; + let deploymentState = ''; + + while (retryCount < maxRetries) { + const statusResponse = await fetch(`https://api.vercel.com/v13/deployments/${deployData.id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (statusResponse.ok) { + const status = (await statusResponse.json()) as any; + deploymentState = status.readyState; + deploymentUrl = status.url ? `https://${status.url}` : ''; + + if (status.readyState === 'READY' || status.readyState === 'ERROR') { + break; + } + } + + retryCount++; + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + if (deploymentState === 'ERROR') { + return json({ error: 'Deployment failed' }, { status: 500 }); + } + + if (retryCount >= maxRetries) { + return json({ error: 'Deployment timed out' }, { status: 500 }); + } + + return json({ + success: true, + deploy: { + id: deployData.id, + state: deploymentState, + url: deploymentUrl || projectInfo.url, + }, + project: projectInfo, + }); + } catch (error) { + console.error('Vercel deploy error:', error); + return json({ error: 'Deployment failed' }, { status: 500 }); + } +} diff --git a/app/types/vercel.ts b/app/types/vercel.ts new file mode 100644 index 00000000..5a7d8db8 --- /dev/null +++ b/app/types/vercel.ts @@ -0,0 +1,40 @@ +export interface VercelUser { + user: any; + id: string; + username: string; + email: string; + name: string; + avatar?: string; +} + +export interface VercelProject { + createdAt: string | number | Date; + targets: any; + id: string; + name: string; + framework?: string; + latestDeployments?: Array<{ + id: string; + url: string; + created: number; + state: string; + }>; +} + +export interface VercelStats { + projects: VercelProject[]; + totalProjects: number; +} + +export interface VercelConnection { + user: VercelUser | null; + token: string; + stats?: VercelStats; +} + +export interface VercelProjectInfo { + id: string; + name: string; + url: string; + chatId: string; +}