From d510a33ac16bd8e76e2816f038a31e75892125be Mon Sep 17 00:00:00 2001 From: Stijnus <72551117+Stijnus@users.noreply.github.com> Date: Thu, 24 Apr 2025 20:57:30 +0200 Subject: [PATCH] update --- app/components/deploy/DeployAlert.tsx | 197 ++++++++++++++ .../deploy/NetlifyDeploy.client.tsx | 243 ++++++++++++++++++ app/components/deploy/VercelDeploy.client.tsx | 193 ++++++++++++++ app/lib/runtime/action-runner.ts | 25 ++ app/types/actions.ts | 11 + app/utils/file-watcher.ts | 229 +++++++++++++++++ 6 files changed, 898 insertions(+) create mode 100644 app/components/deploy/DeployAlert.tsx create mode 100644 app/components/deploy/NetlifyDeploy.client.tsx create mode 100644 app/components/deploy/VercelDeploy.client.tsx create mode 100644 app/utils/file-watcher.ts diff --git a/app/components/deploy/DeployAlert.tsx b/app/components/deploy/DeployAlert.tsx new file mode 100644 index 00000000..adedb77b --- /dev/null +++ b/app/components/deploy/DeployAlert.tsx @@ -0,0 +1,197 @@ +import { AnimatePresence, motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; +import type { DeployAlert } from '~/types/actions'; + +interface DeployAlertProps { + alert: DeployAlert; + clearAlert: () => void; + postMessage: (message: string) => void; +} + +export default function DeployChatAlert({ alert, clearAlert, postMessage }: DeployAlertProps) { + const { type, title, description, content, url, stage, buildStatus, deployStatus } = alert; + + // Determine if we should show the deployment progress + const showProgress = stage && (buildStatus || deployStatus); + + return ( + + +
+ {/* Icon */} + +
+
+ {/* Content */} +
+ + {title} + + +

{description}

+ + {/* Deployment Progress Visualization */} + {showProgress && ( +
+
+ {/* Build Step */} +
+
+ {buildStatus === 'running' ? ( +
+ ) : buildStatus === 'complete' ? ( +
+ ) : buildStatus === 'failed' ? ( +
+ ) : ( + 1 + )} +
+ Build +
+ + {/* Connector Line */} +
+ + {/* Deploy Step */} +
+
+ {deployStatus === 'running' ? ( +
+ ) : deployStatus === 'complete' ? ( +
+ ) : deployStatus === 'failed' ? ( +
+ ) : ( + 2 + )} +
+ Deploy +
+
+
+ )} + + {content && ( +
+ {content} +
+ )} + {url && type === 'success' && ( + + )} +
+ + {/* Actions */} + +
+ {type === 'error' && ( + + )} + +
+
+
+
+
+
+ ); +} diff --git a/app/components/deploy/NetlifyDeploy.client.tsx b/app/components/deploy/NetlifyDeploy.client.tsx new file mode 100644 index 00000000..52382487 --- /dev/null +++ b/app/components/deploy/NetlifyDeploy.client.tsx @@ -0,0 +1,243 @@ +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'); + } + + // 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.updateActionStatus('building', 'running', { source: 'netlify' }); + + // Set up build action + 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) { + // Notify that build failed + deployArtifact.runner.updateActionStatus('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.updateActionStatus('deploying', 'running', { source: 'netlify' }); + + // 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); + + // Notify that deployment failed + deployArtifact.runner.updateActionStatus('deploying', 'failed', { + error: data.error || 'Invalid deployment response', + source: 'netlify', + }); + 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') { + // Notify that deployment failed + deployArtifact.runner.updateActionStatus('deploying', 'failed', { + error: 'Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'), + source: 'netlify', + }); + 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) { + // Notify that deployment timed out + deployArtifact.runner.updateActionStatus('deploying', 'failed', { + error: 'Deployment timed out', + source: 'netlify', + }); + 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); + } + + // Notify that deployment completed successfully + deployArtifact.runner.updateActionStatus('complete', 'complete', { + url: deploymentStatus.ssl_url || deploymentStatus.url, + source: 'netlify', + }); + + 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 00000000..868c7309 --- /dev/null +++ b/app/components/deploy/VercelDeploy.client.tsx @@ -0,0 +1,193 @@ +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'); + } + + // 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.updateActionStatus('building', 'running', { source: 'vercel' }); + + 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) { + // Notify that build failed + deployArtifact.runner.updateActionStatus('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.updateActionStatus('deploying', 'running', { source: 'vercel' }); + + // 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); + + // Notify that deployment failed + deployArtifact.runner.updateActionStatus('deploying', 'failed', { + error: data.error || 'Invalid deployment response', + source: 'vercel', + }); + throw new Error(data.error || 'Invalid deployment response'); + } + + if (data.project) { + localStorage.setItem(`vercel-project-${currentChatId}`, data.project.id); + } + + // Notify that deployment completed successfully + deployArtifact.runner.updateActionStatus('complete', 'complete', { + url: data.deploy.url, + source: 'vercel', + }); + + 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/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index 65e74647..29cac6e3 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -150,6 +150,31 @@ class ActionCommandError extends Error { } export class ActionRunner { + /** + * Public method to update the status and extra data of an action. + * Used by deployment components to signal status changes. + */ + updateActionStatus(actionId: string, status: ActionStatus, extra: Record = {}) { + // Only allow updating if action exists + const action = this.actions.get()[actionId]; + + if (!action) { + return; + } + + // Remove status from extra to avoid accidental override + const { status: _ignored, ...rest } = extra; + + // If setting status to 'failed', ensure an error string is provided + if (status === 'failed') { + const error = typeof rest.error === 'string' ? rest.error : 'Unknown error'; + this.#updateAction(actionId, { status: 'failed', error, ...rest }); + } else { + // Remove error if present for non-failed statuses + const { error, ...restWithoutError } = rest; + this.#updateAction(actionId, { status, ...restWithoutError }); + } + } #webcontainer: Promise; #currentExecutionPromise: Promise = Promise.resolve(); #shellTerminal: () => BoltShell; diff --git a/app/types/actions.ts b/app/types/actions.ts index 63b84269..e0b4c858 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -50,6 +50,17 @@ export interface SupabaseAlert { source?: 'supabase'; } +export interface DeployAlert { + type: string; + title: string; + description: string; + content?: string; + url?: string; + stage?: string; + buildStatus?: 'running' | 'complete' | 'failed'; + deployStatus?: 'running' | 'complete' | 'failed'; +} + export interface FileHistory { originalContent: string; lastModified: number; diff --git a/app/utils/file-watcher.ts b/app/utils/file-watcher.ts new file mode 100644 index 00000000..6917efb3 --- /dev/null +++ b/app/utils/file-watcher.ts @@ -0,0 +1,229 @@ +import type { WebContainer } from '@webcontainer/api'; +import { WORK_DIR } from './constants'; + +// Global object to track watcher state +const watcherState = { + fallbackEnabled: tryLoadFallbackState(), + watchingPaths: new Set(), + callbacks: new Map void>>(), + pollingInterval: null as NodeJS.Timeout | null, +}; + +// Try to load the fallback state from localStorage +function tryLoadFallbackState(): boolean { + try { + if (typeof localStorage !== 'undefined') { + const state = localStorage.getItem('bolt-file-watcher-fallback'); + return state === 'true'; + } + } catch { + console.warn('[FileWatcher] Failed to load fallback state from localStorage'); + } + return false; +} + +// Save the fallback state to localStorage +function saveFallbackState(state: boolean) { + try { + if (typeof localStorage !== 'undefined') { + localStorage.setItem('bolt-file-watcher-fallback', state ? 'true' : 'false'); + } + } catch { + console.warn('[FileWatcher] Failed to save fallback state to localStorage'); + } +} + +/** + * Safe file watcher that falls back to polling when native file watching fails + * + * @param webcontainer The WebContainer instance + * @param pattern File pattern to watch + * @param callback Function to call when files change + * @returns An object with a close method + */ +export async function safeWatch(webcontainer: WebContainer, pattern: string = '**/*', callback: () => void) { + // Register the callback + if (!watcherState.callbacks.has(pattern)) { + watcherState.callbacks.set(pattern, new Set()); + } + + watcherState.callbacks.get(pattern)!.add(callback); + + // If we're already using fallback mode, don't try native watchers again + if (watcherState.fallbackEnabled) { + // Make sure polling is active + ensurePollingActive(); + + // Return a cleanup function + return { + close: () => { + const callbacks = watcherState.callbacks.get(pattern); + + if (callbacks) { + callbacks.delete(callback); + + if (callbacks.size === 0) { + watcherState.callbacks.delete(pattern); + } + } + }, + }; + } + + // Try to use native file watching + try { + const watcher = await webcontainer.fs.watch(pattern, { persistent: true }); + watcherState.watchingPaths.add(pattern); + + // Use the native watch events + (watcher as any).addEventListener('change', () => { + // Call all callbacks for this pattern + const callbacks = watcherState.callbacks.get(pattern); + + if (callbacks) { + callbacks.forEach((cb) => cb()); + } + }); + + // Return an object with a close method + return { + close: () => { + try { + watcher.close(); + watcherState.watchingPaths.delete(pattern); + + const callbacks = watcherState.callbacks.get(pattern); + + if (callbacks) { + callbacks.delete(callback); + + if (callbacks.size === 0) { + watcherState.callbacks.delete(pattern); + } + } + } catch (error) { + console.warn('[FileWatcher] Error closing watcher:', error); + } + }, + }; + } catch (error) { + console.warn('[FileWatcher] Native file watching failed:', error); + console.info('[FileWatcher] Falling back to polling mechanism for file changes'); + + // Switch to fallback mode for all future watches + watcherState.fallbackEnabled = true; + saveFallbackState(true); + + // Start polling + ensurePollingActive(); + + // Return a mock watcher object + return { + close: () => { + const callbacks = watcherState.callbacks.get(pattern); + + if (callbacks) { + callbacks.delete(callback); + + if (callbacks.size === 0) { + watcherState.callbacks.delete(pattern); + } + } + + // If no more callbacks, stop polling + if (watcherState.callbacks.size === 0 && watcherState.pollingInterval) { + clearInterval(watcherState.pollingInterval); + watcherState.pollingInterval = null; + } + }, + }; + } +} + +// Ensure polling is active +function ensurePollingActive() { + if (watcherState.pollingInterval) { + return; + } + + // Set up a polling interval that calls all callbacks + watcherState.pollingInterval = setInterval(() => { + // Call all registered callbacks + for (const [, callbacks] of watcherState.callbacks.entries()) { + callbacks.forEach((callback) => callback()); + } + }, 3000); // Poll every 3 seconds + + // Clean up interval when window unloads + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + if (watcherState.pollingInterval) { + clearInterval(watcherState.pollingInterval); + watcherState.pollingInterval = null; + } + }); + } +} + +// SafeWatchPaths mimics the webcontainer.internal.watchPaths method but with fallback +export function safeWatchPaths( + webcontainer: WebContainer, + config: { include: string[]; exclude?: string[]; includeContent?: boolean }, + callback: any, +) { + // Create a valid mock event to prevent undefined errors + const createMockEvent = () => ({ + type: 'change', + path: `${WORK_DIR}/mock-path.txt`, + buffer: new Uint8Array(0), + }); + + // Start with polling if we already know native watching doesn't work + if (watcherState.fallbackEnabled) { + console.info('[FileWatcher] Using fallback polling for watchPaths'); + ensurePollingActive(); + + const interval = setInterval(() => { + // Use our helper to create a valid event + const mockEvent = createMockEvent(); + + // Wrap in the expected structure of nested arrays + callback([[mockEvent]]); + }, 3000); + + return { + close: () => { + clearInterval(interval); + }, + }; + } + + // Try native watching + try { + return webcontainer.internal.watchPaths(config, callback); + } catch (error) { + console.warn('[FileWatcher] Native watchPaths failed:', error); + console.info('[FileWatcher] Using fallback polling for watchPaths'); + + // Mark as using fallback + watcherState.fallbackEnabled = true; + saveFallbackState(true); + + // Set up polling + ensurePollingActive(); + + const interval = setInterval(() => { + // Use our helper to create a valid event + const mockEvent = createMockEvent(); + + // Wrap in the expected structure of nested arrays + callback([[mockEvent]]); + }, 3000); + + return { + close: () => { + clearInterval(interval); + }, + }; + } +}