This commit is contained in:
Stijnus 2025-04-24 20:57:30 +02:00
parent 806b1f63c4
commit d510a33ac1
6 changed files with 898 additions and 0 deletions

View File

@ -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 (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4 mb-2`}
>
<div className="flex items-start">
{/* Icon */}
<motion.div
className="flex-shrink-0"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2 }}
>
<div
className={classNames(
'text-xl',
type === 'success'
? 'i-ph:check-circle-duotone text-bolt-elements-icon-success'
: type === 'error'
? 'i-ph:warning-duotone text-bolt-elements-button-danger-text'
: 'i-ph:info-duotone text-bolt-elements-loader-progress',
)}
></div>
</motion.div>
{/* Content */}
<div className="ml-3 flex-1">
<motion.h3
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className={`text-sm font-medium text-bolt-elements-textPrimary`}
>
{title}
</motion.h3>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className={`mt-2 text-sm text-bolt-elements-textSecondary`}
>
<p>{description}</p>
{/* Deployment Progress Visualization */}
{showProgress && (
<div className="mt-4 mb-2">
<div className="flex items-center space-x-2 mb-3">
{/* Build Step */}
<div className="flex items-center">
<div
className={classNames(
'w-6 h-6 rounded-full flex items-center justify-center',
buildStatus === 'running'
? 'bg-bolt-elements-loader-progress'
: buildStatus === 'complete'
? 'bg-bolt-elements-icon-success'
: buildStatus === 'failed'
? 'bg-bolt-elements-button-danger-background'
: 'bg-bolt-elements-textTertiary',
)}
>
{buildStatus === 'running' ? (
<div className="i-svg-spinners:90-ring-with-bg text-white text-xs"></div>
) : buildStatus === 'complete' ? (
<div className="i-ph:check text-white text-xs"></div>
) : buildStatus === 'failed' ? (
<div className="i-ph:x text-white text-xs"></div>
) : (
<span className="text-white text-xs">1</span>
)}
</div>
<span className="ml-2">Build</span>
</div>
{/* Connector Line */}
<div
className={classNames(
'h-0.5 w-8',
buildStatus === 'complete' ? 'bg-bolt-elements-icon-success' : 'bg-bolt-elements-textTertiary',
)}
></div>
{/* Deploy Step */}
<div className="flex items-center">
<div
className={classNames(
'w-6 h-6 rounded-full flex items-center justify-center',
deployStatus === 'running'
? 'bg-bolt-elements-loader-progress'
: deployStatus === 'complete'
? 'bg-bolt-elements-icon-success'
: deployStatus === 'failed'
? 'bg-bolt-elements-button-danger-background'
: 'bg-bolt-elements-textTertiary',
)}
>
{deployStatus === 'running' ? (
<div className="i-svg-spinners:90-ring-with-bg text-white text-xs"></div>
) : deployStatus === 'complete' ? (
<div className="i-ph:check text-white text-xs"></div>
) : deployStatus === 'failed' ? (
<div className="i-ph:x text-white text-xs"></div>
) : (
<span className="text-white text-xs">2</span>
)}
</div>
<span className="ml-2">Deploy</span>
</div>
</div>
</div>
)}
{content && (
<div className="text-xs text-bolt-elements-textSecondary p-2 bg-bolt-elements-background-depth-3 rounded mt-4 mb-4">
{content}
</div>
)}
{url && type === 'success' && (
<div className="mt-2">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-item-contentAccent hover:underline flex items-center"
>
<span className="mr-1">View deployed site</span>
<div className="i-ph:arrow-square-out"></div>
</a>
</div>
)}
</motion.div>
{/* Actions */}
<motion.div
className="mt-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className={classNames('flex gap-2')}>
{type === 'error' && (
<button
onClick={() =>
postMessage(`*Fix this deployment error*\n\`\`\`\n${content || description}\n\`\`\`\n`)
}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-bolt-elements-button-primary-background',
'hover:bg-bolt-elements-button-primary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-danger-background',
'text-bolt-elements-button-primary-text',
'flex items-center gap-1.5',
)}
>
<div className="i-ph:chat-circle-duotone"></div>
Ask Bolt
</button>
)}
<button
onClick={clearAlert}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-bolt-elements-button-secondary-background',
'hover:bg-bolt-elements-button-secondary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-secondary-background',
'text-bolt-elements-button-secondary-text',
)}
>
Dismiss
</button>
</div>
</motion.div>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View File

@ -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<Record<string, string>> {
const files: Record<string, string> = {};
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,
};
}

View File

@ -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<Record<string, string>> {
const files: Record<string, string> = {};
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,
};
}

View File

@ -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<string, any> = {}) {
// 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<WebContainer>;
#currentExecutionPromise: Promise<void> = Promise.resolve();
#shellTerminal: () => BoltShell;

View File

@ -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;

229
app/utils/file-watcher.ts Normal file
View File

@ -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<string>(),
callbacks: new Map<string, Set<() => 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);
},
};
}
}