diff --git a/app/components/deploy/NetlifyDeploy.client.tsx b/app/components/deploy/NetlifyDeploy.client.tsx new file mode 100644 index 0000000..7b7695a --- /dev/null +++ b/app/components/deploy/NetlifyDeploy.client.tsx @@ -0,0 +1,212 @@ +import { toast } from 'react-toastify'; +import { useStore } from '@nanostores/react'; +import { netlifyConnection } from '~/lib/stores/netlify'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { webcontainer } from '~/lib/webcontainer'; +import { path } from '~/utils/path'; +import { useState } from 'react'; +import type { ActionCallbackData } from '~/lib/runtime/message-parser'; +import { chatId } from '~/lib/persistence/useChatHistory'; + +export function useNetlifyDeploy() { + const [isDeploying, setIsDeploying] = useState(false); + const netlifyConn = useStore(netlifyConnection); + const currentChatId = useStore(chatId); + + const handleNetlifyDeploy = async () => { + if (!netlifyConn.user || !netlifyConn.token) { + toast.error('Please connect to Netlify first in the settings tab!'); + return false; + } + + if (!currentChatId) { + toast.error('No active chat found'); + return false; + } + + try { + setIsDeploying(true); + + const artifact = workbenchStore.firstArtifact; + + if (!artifact) { + throw new Error('No active project found'); + } + + const actionId = 'build-' + Date.now(); + const actionData: ActionCallbackData = { + messageId: 'netlify 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', ''); + + console.log('Original buildPath', buildPath); + + // Check if the build path exists + let finalBuildPath = buildPath; + + // List of common output directories to check if the specified build path doesn't exist + const commonOutputDirs = [buildPath, '/dist', '/build', '/out', '/output', '/.next', '/public']; + + // Verify the build path exists, or try to find an alternative + let buildPathExists = false; + + for (const dir of commonOutputDirs) { + try { + await container.fs.readdir(dir); + finalBuildPath = dir; + buildPathExists = true; + console.log(`Using build directory: ${finalBuildPath}`); + break; + } catch (error) { + // Directory doesn't exist, try the next one + console.log(`Directory ${dir} doesn't exist, trying next option. ${error}`); + continue; + } + } + + if (!buildPathExists) { + throw new Error('Could not find build output directory. Please check your build configuration.'); + } + + 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 build path prefix from the path + const deployPath = fullPath.replace(finalBuildPath, ''); + files[deployPath] = content; + } else if (entry.isDirectory()) { + const subFiles = await getAllFiles(fullPath); + Object.assign(files, subFiles); + } + } + + return files; + } + + const fileContents = await getAllFiles(finalBuildPath); + + // Use chatId instead of artifact.id + const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`); + + const response = await fetch('/api/netlify-deploy', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + siteId: existingSiteId || undefined, + files: fileContents, + token: netlifyConn.token, + chatId: currentChatId, + }), + }); + + const data = (await response.json()) as any; + + if (!response.ok || !data.deploy || !data.site) { + console.error('Invalid deploy response:', data); + throw new Error(data.error || 'Invalid deployment response'); + } + + const maxAttempts = 20; // 2 minutes timeout + let attempts = 0; + let deploymentStatus; + + while (attempts < maxAttempts) { + try { + const statusResponse = await fetch( + `https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`, + { + headers: { + Authorization: `Bearer ${netlifyConn.token}`, + }, + }, + ); + + deploymentStatus = (await statusResponse.json()) as any; + + if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') { + break; + } + + if (deploymentStatus.state === 'error') { + throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error')); + } + + attempts++; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } catch (error) { + console.error('Status check error:', error); + attempts++; + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + + if (attempts >= maxAttempts) { + throw new Error('Deployment timed out'); + } + + // Store the site ID if it's a new site + if (data.site) { + localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id); + } + + toast.success( +
+ Deployed successfully!{' '} + + View site + +
, + ); + + return true; + } catch (error) { + console.error('Deploy error:', error); + toast.error(error instanceof Error ? error.message : 'Deployment failed'); + + return false; + } finally { + setIsDeploying(false); + } + }; + + return { + isDeploying, + handleNetlifyDeploy, + isConnected: !!netlifyConn.user, + }; +} diff --git a/app/components/deploy/VercelDeploy.client.tsx b/app/components/deploy/VercelDeploy.client.tsx new file mode 100644 index 0000000..74952f8 --- /dev/null +++ b/app/components/deploy/VercelDeploy.client.tsx @@ -0,0 +1,168 @@ +import { toast } from 'react-toastify'; +import { useStore } from '@nanostores/react'; +import { vercelConnection } from '~/lib/stores/vercel'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { webcontainer } from '~/lib/webcontainer'; +import { path } from '~/utils/path'; +import { useState } from 'react'; +import type { ActionCallbackData } from '~/lib/runtime/message-parser'; +import { chatId } from '~/lib/persistence/useChatHistory'; + +export function useVercelDeploy() { + const [isDeploying, setIsDeploying] = useState(false); + const vercelConn = useStore(vercelConnection); + const currentChatId = useStore(chatId); + + const handleVercelDeploy = async () => { + if (!vercelConn.user || !vercelConn.token) { + toast.error('Please connect to Vercel first in the settings tab!'); + return false; + } + + if (!currentChatId) { + toast.error('No active chat found'); + return false; + } + + try { + setIsDeploying(true); + + 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', ''); + + // Check if the build path exists + let finalBuildPath = buildPath; + + // List of common output directories to check if the specified build path doesn't exist + const commonOutputDirs = [buildPath, '/dist', '/build', '/out', '/output', '/.next', '/public']; + + // Verify the build path exists, or try to find an alternative + let buildPathExists = false; + + for (const dir of commonOutputDirs) { + try { + await container.fs.readdir(dir); + finalBuildPath = dir; + buildPathExists = true; + console.log(`Using build directory: ${finalBuildPath}`); + break; + } catch (error) { + console.log(`Directory ${dir} doesn't exist, trying next option. ${error}`); + + // Directory doesn't exist, try the next one + continue; + } + } + + if (!buildPathExists) { + throw new Error('Could not find build output directory. Please check your build configuration.'); + } + + // 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 build path prefix from the path + const deployPath = fullPath.replace(finalBuildPath, ''); + files[deployPath] = content; + } else if (entry.isDirectory()) { + const subFiles = await getAllFiles(fullPath); + Object.assign(files, subFiles); + } + } + + return files; + } + + const fileContents = await getAllFiles(finalBuildPath); + + // Use chatId instead of artifact.id + const existingProjectId = localStorage.getItem(`vercel-project-${currentChatId}`); + + 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'); + } + + if (data.project) { + localStorage.setItem(`vercel-project-${currentChatId}`, data.project.id); + } + + toast.success( +
+ Deployed successfully to Vercel!{' '} + + View site + +
, + ); + + return true; + } catch (err) { + console.error('Vercel deploy error:', err); + toast.error(err instanceof Error ? err.message : 'Vercel deployment failed'); + + return false; + } finally { + setIsDeploying(false); + } + }; + + return { + isDeploying, + handleVercelDeploy, + isConnected: !!vercelConn.user, + }; +} diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index 56945c9..ff211f3 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -1,19 +1,16 @@ import { useStore } from '@nanostores/react'; -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'; import { streamingState } from '~/lib/stores/streaming'; import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client'; import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client'; +import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client'; +import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client'; interface HeaderActionButtonsProps {} @@ -32,6 +29,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { const [isDropdownOpen, setIsDropdownOpen] = useState(false); const dropdownRef = useRef(null); const isStreaming = useStore(streamingState); + const { handleVercelDeploy } = useVercelDeploy(); + const { handleNetlifyDeploy } = useNetlifyDeploy(); useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -44,282 +43,24 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - const currentChatId = useStore(chatId); - - const handleNetlifyDeploy = async () => { - if (!netlifyConn.user || !netlifyConn.token) { - toast.error('Please connect to Netlify first in the settings tab!'); - return; - } - - if (!currentChatId) { - toast.error('No active chat found'); - return; - } + const onVercelDeploy = async () => { + setIsDeploying(true); + setDeployingTo('vercel'); try { - setIsDeploying(true); - - const artifact = workbenchStore.firstArtifact; - - if (!artifact) { - throw new Error('No active project found'); - } - - const actionId = 'build-' + Date.now(); - const actionData: ActionCallbackData = { - messageId: 'netlify 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 existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`); - - // Deploy using the API route with file contents - const response = await fetch('/api/netlify-deploy', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - siteId: existingSiteId || undefined, - files: fileContents, - token: netlifyConn.token, - chatId: currentChatId, // Use chatId instead of artifact.id - }), - }); - - const data = (await response.json()) as any; - - if (!response.ok || !data.deploy || !data.site) { - console.error('Invalid deploy response:', data); - throw new Error(data.error || 'Invalid deployment response'); - } - - // Poll for deployment status - const maxAttempts = 20; // 2 minutes timeout - let attempts = 0; - let deploymentStatus; - - while (attempts < maxAttempts) { - try { - const statusResponse = await fetch( - `https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`, - { - headers: { - Authorization: `Bearer ${netlifyConn.token}`, - }, - }, - ); - - deploymentStatus = (await statusResponse.json()) as any; - - if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') { - break; - } - - if (deploymentStatus.state === 'error') { - throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error')); - } - - attempts++; - await new Promise((resolve) => setTimeout(resolve, 1000)); - } catch (error) { - console.error('Status check error:', error); - attempts++; - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - } - - if (attempts >= maxAttempts) { - throw new Error('Deployment timed out'); - } - - // Store the site ID if it's a new site - if (data.site) { - localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id); - } - - toast.success( -
- Deployed successfully!{' '} - - View site - -
, - ); - } catch (error) { - console.error('Deploy error:', error); - toast.error(error instanceof Error ? error.message : 'Deployment failed'); + await handleVercelDeploy(); } finally { setIsDeploying(false); + setDeployingTo(null); } }; - 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; - } + const onNetlifyDeploy = async () => { + setIsDeploying(true); + setDeployingTo('netlify'); 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'); + await handleNetlifyDeploy(); } finally { setIsDeploying(false); setDeployingTo(null); @@ -348,7 +89,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {