From 687b03ba7467e3432303b174d29f9507f970baad Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Thu, 27 Mar 2025 00:06:10 +0000 Subject: [PATCH 1/3] 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 72ff643..defd7ae 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 0000000..4a442a0 --- /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 da8e0b4..4e60793 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 0000000..ecb5a58 --- /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 d6273f5..56945c9 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 0000000..3258642 --- /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 0000000..c9f6909 --- /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 0000000..5a7d8db --- /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; +} From 95dcd0261a6c3cfcb3d9c84f1a632918a5168737 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Thu, 27 Mar 2025 16:24:12 +0000 Subject: [PATCH 2/3] refactor: consolidate imports in supabase API routes change over to cloudflare. --- app/routes/api.supabase.ts | 3 +-- app/routes/api.supabase.variables.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/routes/api.supabase.ts b/app/routes/api.supabase.ts index f21da60..804d3b7 100644 --- a/app/routes/api.supabase.ts +++ b/app/routes/api.supabase.ts @@ -1,5 +1,4 @@ -import { json } from '@remix-run/node'; -import type { ActionFunction } from '@remix-run/node'; +import { json, type ActionFunction } from '@remix-run/cloudflare'; import type { SupabaseProject } from '~/types/supabase'; export const action: ActionFunction = async ({ request }) => { diff --git a/app/routes/api.supabase.variables.ts b/app/routes/api.supabase.variables.ts index fd2d028..55e796c 100644 --- a/app/routes/api.supabase.variables.ts +++ b/app/routes/api.supabase.variables.ts @@ -1,5 +1,4 @@ -import { json } from '@remix-run/node'; -import type { ActionFunctionArgs } from '@remix-run/node'; +import { json, type ActionFunctionArgs } from '@remix-run/node'; export async function action({ request }: ActionFunctionArgs) { try { From 4b0eaf25ce17ca6d28c01ac1380d672c83024514 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Thu, 27 Mar 2025 18:52:13 +0000 Subject: [PATCH 3/3] add: add env masking extension for .env files Introduce a new extension for CodeMirror that masks sensitive values in .env files. This ensures that sensitive information like API keys or passwords is not displayed in plain text within the editor. The extension dynamically applies masking to values in lines matching the KEY=VALUE format, improving security during development. --- .../editor/codemirror/CodeMirrorEditor.tsx | 6 ++ .../editor/codemirror/EnvMasking.ts | 80 +++++++++++++++++++ app/routes/api.supabase.variables.ts | 2 +- 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 app/components/editor/codemirror/EnvMasking.ts diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx index 8e9f3a3..e222eab 100644 --- a/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -25,6 +25,7 @@ import { BinaryContent } from './BinaryContent'; import { getTheme, reconfigureTheme } from './cm-theme'; import { indentKeyBinding } from './indent'; import { getLanguage } from './languages'; +import { createEnvMaskingExtension } from './EnvMasking'; const logger = createScopedLogger('CodeMirrorEditor'); @@ -134,6 +135,9 @@ export const CodeMirrorEditor = memo( const [languageCompartment] = useState(new Compartment()); + // Add a compartment for the env masking extension + const [envMaskingCompartment] = useState(new Compartment()); + const containerRef = useRef(null); const viewRef = useRef(); const themeRef = useRef(); @@ -214,6 +218,7 @@ export const CodeMirrorEditor = memo( if (!doc) { const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [ languageCompartment.of([]), + envMaskingCompartment.of([]), ]); view.setState(state); @@ -236,6 +241,7 @@ export const CodeMirrorEditor = memo( if (!state) { state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [ languageCompartment.of([]), + envMaskingCompartment.of([createEnvMaskingExtension(() => docRef.current?.filePath)]), ]); editorStates.set(doc.filePath, state); diff --git a/app/components/editor/codemirror/EnvMasking.ts b/app/components/editor/codemirror/EnvMasking.ts new file mode 100644 index 0000000..5c9077d --- /dev/null +++ b/app/components/editor/codemirror/EnvMasking.ts @@ -0,0 +1,80 @@ +import { EditorView, Decoration, type DecorationSet, ViewPlugin, WidgetType } from '@codemirror/view'; + +// Create a proper WidgetType class for the masked text +class MaskedTextWidget extends WidgetType { + constructor(private readonly _value: string) { + super(); + } + + eq(other: MaskedTextWidget) { + return other._value === this._value; + } + + toDOM() { + const span = document.createElement('span'); + span.textContent = '*'.repeat(this._value.length); + span.className = 'cm-masked-text'; + + return span; + } + + ignoreEvent() { + return false; + } +} + +export function createEnvMaskingExtension(getFilePath: () => string | undefined) { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + + update(update: { docChanged: boolean; view: EditorView; viewportChanged: boolean }) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + + buildDecorations(view: EditorView) { + const filePath = getFilePath(); + const isEnvFile = filePath?.endsWith('.env') || filePath?.includes('.env.') || filePath?.includes('/.env'); + + if (!isEnvFile) { + return Decoration.none; + } + + const decorations: any[] = []; + const doc = view.state.doc; + + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i); + const text = line.text; + + // Match lines with KEY=VALUE format + const match = text.match(/^([^=]+)=(.+)$/); + + if (match && !text.trim().startsWith('#')) { + const [, key, value] = match; + const valueStart = line.from + key.length + 1; + + // Create a decoration that replaces the value with asterisks + decorations.push( + Decoration.replace({ + inclusive: true, + widget: new MaskedTextWidget(value), + }).range(valueStart, line.to), + ); + } + } + + return Decoration.set(decorations); + } + }, + { + decorations: (v) => v.decorations, + }, + ); +} diff --git a/app/routes/api.supabase.variables.ts b/app/routes/api.supabase.variables.ts index 55e796c..45eccb2 100644 --- a/app/routes/api.supabase.variables.ts +++ b/app/routes/api.supabase.variables.ts @@ -1,4 +1,4 @@ -import { json, type ActionFunctionArgs } from '@remix-run/node'; +import { json, type ActionFunctionArgs } from '@remix-run/cloudflare'; export async function action({ request }: ActionFunctionArgs) { try {