From c63732d2f47aabb00edba88879c3c205c6defc26 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Wed, 2 Apr 2025 16:47:04 +0100 Subject: [PATCH 1/3] fix: simplify the SHA-1 hash function in api.netlify-deploy.ts by using the crypto module directly this allows it to work in both dev and prod environments. also extract Netlify and Vercel deploy logic into separate components Move the Netlify and Vercel deployment logic from HeaderActionButtons.client.tsx into dedicated components (NetlifyDeploy.client.tsx and VercelDeploy.client.tsx) to improve code maintainability and reusability. --- .../deploy/NetlifyDeploy.client.tsx | 212 +++++++++++++ app/components/deploy/VercelDeploy.client.tsx | 168 ++++++++++ .../header/HeaderActionButtons.client.tsx | 289 +----------------- app/routes/api.netlify-deploy.ts | 12 +- vite.config.ts | 48 +-- 5 files changed, 401 insertions(+), 328 deletions(-) create mode 100644 app/components/deploy/NetlifyDeploy.client.tsx create mode 100644 app/components/deploy/VercelDeploy.client.tsx 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) { + )} + + + + + + + + ); +} diff --git a/app/components/deploy/NetlifyDeploy.client.tsx b/app/components/deploy/NetlifyDeploy.client.tsx index 7b7695a..f88751c 100644 --- a/app/components/deploy/NetlifyDeploy.client.tsx +++ b/app/components/deploy/NetlifyDeploy.client.tsx @@ -33,6 +33,21 @@ export function useNetlifyDeploy() { throw new Error('No active project found'); } + // Create a deployment artifact for visual feedback + const deploymentId = `deploy-artifact`; + workbenchStore.addArtifact({ + id: deploymentId, + messageId: deploymentId, + title: 'Netlify Deployment', + type: 'standalone', + }); + + const deployArtifact = workbenchStore.artifacts.get()[deploymentId]; + + // Notify that build is starting + deployArtifact.runner.handleDeployAction('building', 'running', { source: 'netlify' }); + + // Set up build action const actionId = 'build-' + Date.now(); const actionData: ActionCallbackData = { messageId: 'netlify build', @@ -51,9 +66,17 @@ export function useNetlifyDeploy() { await artifact.runner.runAction(actionData); if (!artifact.runner.buildOutput) { + // Notify that build failed + deployArtifact.runner.handleDeployAction('building', 'failed', { + error: 'Build failed. Check the terminal for details.', + source: 'netlify', + }); throw new Error('Build failed'); } + // Notify that build succeeded and deployment is starting + deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'netlify' }); + // Get the build files const container = await webcontainer; @@ -133,6 +156,12 @@ export function useNetlifyDeploy() { if (!response.ok || !data.deploy || !data.site) { console.error('Invalid deploy response:', data); + + // Notify that deployment failed + deployArtifact.runner.handleDeployAction('deploying', 'failed', { + error: data.error || 'Invalid deployment response', + source: 'netlify', + }); throw new Error(data.error || 'Invalid deployment response'); } @@ -158,6 +187,11 @@ export function useNetlifyDeploy() { } if (deploymentStatus.state === 'error') { + // Notify that deployment failed + deployArtifact.runner.handleDeployAction('deploying', 'failed', { + error: 'Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'), + source: 'netlify', + }); throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error')); } @@ -171,6 +205,11 @@ export function useNetlifyDeploy() { } if (attempts >= maxAttempts) { + // Notify that deployment timed out + deployArtifact.runner.handleDeployAction('deploying', 'failed', { + error: 'Deployment timed out', + source: 'netlify', + }); throw new Error('Deployment timed out'); } @@ -179,6 +218,12 @@ export function useNetlifyDeploy() { localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id); } + // Notify that deployment completed successfully + deployArtifact.runner.handleDeployAction('complete', 'complete', { + url: deploymentStatus.ssl_url || deploymentStatus.url, + source: 'netlify', + }); + toast.success(
Deployed successfully!{' '} diff --git a/app/components/deploy/VercelDeploy.client.tsx b/app/components/deploy/VercelDeploy.client.tsx index 74952f8..a6b53a4 100644 --- a/app/components/deploy/VercelDeploy.client.tsx +++ b/app/components/deploy/VercelDeploy.client.tsx @@ -33,6 +33,20 @@ export function useVercelDeploy() { throw new Error('No active project found'); } + // Create a deployment artifact for visual feedback + const deploymentId = `deploy-vercel-project`; + workbenchStore.addArtifact({ + id: deploymentId, + messageId: deploymentId, + title: 'Vercel Deployment', + type: 'standalone', + }); + + const deployArtifact = workbenchStore.artifacts.get()[deploymentId]; + + // Notify that build is starting + deployArtifact.runner.handleDeployAction('building', 'running', { source: 'vercel' }); + const actionId = 'build-' + Date.now(); const actionData: ActionCallbackData = { messageId: 'vercel build', @@ -51,9 +65,17 @@ export function useVercelDeploy() { await artifact.runner.runAction(actionData); if (!artifact.runner.buildOutput) { + // Notify that build failed + deployArtifact.runner.handleDeployAction('building', 'failed', { + error: 'Build failed. Check the terminal for details.', + source: 'vercel', + }); throw new Error('Build failed'); } + // Notify that build succeeded and deployment is starting + deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'vercel' }); + // Get the build files const container = await webcontainer; @@ -133,6 +155,12 @@ export function useVercelDeploy() { if (!response.ok || !data.deploy || !data.project) { console.error('Invalid deploy response:', data); + + // Notify that deployment failed + deployArtifact.runner.handleDeployAction('deploying', 'failed', { + error: data.error || 'Invalid deployment response', + source: 'vercel', + }); throw new Error(data.error || 'Invalid deployment response'); } @@ -140,6 +168,12 @@ export function useVercelDeploy() { localStorage.setItem(`vercel-project-${currentChatId}`, data.project.id); } + // Notify that deployment completed successfully + deployArtifact.runner.handleDeployAction('complete', 'complete', { + url: data.deploy.url, + source: 'vercel', + }); + toast.success(
Deployed successfully to Vercel!{' '} diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index a04a3de..a3436a9 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -1,7 +1,7 @@ import type { WebContainer } from '@webcontainer/api'; import { path as nodePath } from '~/utils/path'; import { atom, map, type MapStore } from 'nanostores'; -import type { ActionAlert, BoltAction, FileHistory, SupabaseAction, SupabaseAlert } from '~/types/actions'; +import type { ActionAlert, BoltAction, DeployAlert, FileHistory, SupabaseAction, SupabaseAlert } from '~/types/actions'; import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; import type { ActionCallbackData } from './message-parser'; @@ -71,6 +71,7 @@ export class ActionRunner { actions: ActionsMap = map({}); onAlert?: (alert: ActionAlert) => void; onSupabaseAlert?: (alert: SupabaseAlert) => void; + onDeployAlert?: (alert: DeployAlert) => void; buildOutput?: { path: string; exitCode: number; output: string }; constructor( @@ -78,11 +79,13 @@ export class ActionRunner { getShellTerminal: () => BoltShell, onAlert?: (alert: ActionAlert) => void, onSupabaseAlert?: (alert: SupabaseAlert) => void, + onDeployAlert?: (alert: DeployAlert) => void, ) { this.#webcontainer = webcontainerPromise; this.#shellTerminal = getShellTerminal; this.onAlert = onAlert; this.onSupabaseAlert = onSupabaseAlert; + this.onDeployAlert = onDeployAlert; } addAction(data: ActionCallbackData) { @@ -366,6 +369,17 @@ export class ActionRunner { unreachable('Expected build action'); } + // Trigger build started alert + this.onDeployAlert?.({ + type: 'info', + title: 'Building Application', + description: 'Building your application...', + stage: 'building', + buildStatus: 'running', + deployStatus: 'pending', + source: 'netlify', + }); + const webcontainer = await this.#webcontainer; // Create a new terminal specifically for the build @@ -383,11 +397,57 @@ export class ActionRunner { const exitCode = await buildProcess.exit; if (exitCode !== 0) { + // Trigger build failed alert + this.onDeployAlert?.({ + type: 'error', + title: 'Build Failed', + description: 'Your application build failed', + content: output || 'No build output available', + stage: 'building', + buildStatus: 'failed', + deployStatus: 'pending', + source: 'netlify', + }); + throw new ActionCommandError('Build Failed', output || 'No Output Available'); } - // Get the build output directory path - const buildDir = nodePath.join(webcontainer.workdir, 'dist'); + // Trigger build success alert + this.onDeployAlert?.({ + type: 'success', + title: 'Build Completed', + description: 'Your application was built successfully', + stage: 'deploying', + buildStatus: 'complete', + deployStatus: 'running', + source: 'netlify', + }); + + // Check for common build directories + const commonBuildDirs = ['dist', 'build', 'out', 'output', '.next', 'public']; + + let buildDir = ''; + + // Try to find the first existing build directory + for (const dir of commonBuildDirs) { + const dirPath = nodePath.join(webcontainer.workdir, dir); + + try { + await webcontainer.fs.readdir(dirPath); + buildDir = dirPath; + logger.debug(`Found build directory: ${buildDir}`); + break; + } catch (error) { + // Directory doesn't exist, try the next one + logger.debug(`Build directory ${dir} not found, trying next option. ${error}`); + } + } + + // If no build directory was found, use the default (dist) + if (!buildDir) { + buildDir = nodePath.join(webcontainer.workdir, 'dist'); + logger.debug(`No build directory found, defaulting to: ${buildDir}`); + } return { path: buildDir, @@ -441,4 +501,55 @@ export class ActionRunner { throw new Error(`Unknown operation: ${operation}`); } } + + // Add this method declaration to the class + handleDeployAction( + stage: 'building' | 'deploying' | 'complete', + status: ActionStatus, + details?: { + url?: string; + error?: string; + source?: 'netlify' | 'vercel' | 'github'; + }, + ): void { + if (!this.onDeployAlert) { + logger.debug('No deploy alert handler registered'); + return; + } + + const alertType = status === 'failed' ? 'error' : status === 'complete' ? 'success' : 'info'; + + const title = + stage === 'building' + ? 'Building Application' + : stage === 'deploying' + ? 'Deploying Application' + : 'Deployment Complete'; + + const description = + status === 'failed' + ? `${stage === 'building' ? 'Build' : 'Deployment'} failed` + : status === 'running' + ? `${stage === 'building' ? 'Building' : 'Deploying'} your application...` + : status === 'complete' + ? `${stage === 'building' ? 'Build' : 'Deployment'} completed successfully` + : `Preparing to ${stage === 'building' ? 'build' : 'deploy'} your application`; + + const buildStatus = + stage === 'building' ? status : stage === 'deploying' || stage === 'complete' ? 'complete' : 'pending'; + + const deployStatus = stage === 'building' ? 'pending' : status; + + this.onDeployAlert({ + type: alertType, + title, + description, + content: details?.error || '', + url: details?.url, + stage, + buildStatus: buildStatus as any, + deployStatus: deployStatus as any, + source: details?.source || 'netlify', + }); + } } diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index 051d45a..68d83e2 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -17,7 +17,7 @@ import { extractRelativePath } from '~/utils/diff'; import { description } from '~/lib/persistence'; import Cookies from 'js-cookie'; import { createSampler } from '~/utils/sampler'; -import type { ActionAlert, SupabaseAlert } from '~/types/actions'; +import type { ActionAlert, DeployAlert, SupabaseAlert } from '~/types/actions'; const { saveAs } = fileSaver; @@ -52,6 +52,8 @@ export class WorkbenchStore { import.meta.hot?.data.unsavedFiles ?? atom(undefined); supabaseAlert: WritableAtom = import.meta.hot?.data.unsavedFiles ?? atom(undefined); + deployAlert: WritableAtom = + import.meta.hot?.data.unsavedFiles ?? atom(undefined); modifiedFiles = new Set(); artifactIdList: string[] = []; #globalExecutionQueue = Promise.resolve(); @@ -63,6 +65,7 @@ export class WorkbenchStore { import.meta.hot.data.currentView = this.currentView; import.meta.hot.data.actionAlert = this.actionAlert; import.meta.hot.data.supabaseAlert = this.supabaseAlert; + import.meta.hot.data.deployAlert = this.deployAlert; // Ensure binary files are properly preserved across hot reloads const filesMap = this.files.get(); @@ -125,6 +128,14 @@ export class WorkbenchStore { this.supabaseAlert.set(undefined); } + get DeployAlert() { + return this.deployAlert; + } + + clearDeployAlert() { + this.deployAlert.set(undefined); + } + toggleTerminal(value?: boolean) { this.#terminalStore.toggleTerminal(value); } @@ -423,6 +434,13 @@ export class WorkbenchStore { this.supabaseAlert.set(alert); }, + (alert) => { + if (this.#reloadedMessages.has(messageId)) { + return; + } + + this.deployAlert.set(alert); + }, ), }); } diff --git a/app/types/actions.ts b/app/types/actions.ts index 63b8426..0e1411d 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -50,6 +50,18 @@ export interface SupabaseAlert { source?: 'supabase'; } +export interface DeployAlert { + type: 'success' | 'error' | 'info'; + title: string; + description: string; + content?: string; + url?: string; + stage?: 'building' | 'deploying' | 'complete'; + buildStatus?: 'pending' | 'running' | 'complete' | 'failed'; + deployStatus?: 'pending' | 'running' | 'complete' | 'failed'; + source?: 'vercel' | 'netlify' | 'github'; +} + export interface FileHistory { originalContent: string; lastModified: number;