feat: add netlify one-click deployment

This commit is contained in:
KevIsDev 2025-02-24 17:24:32 +00:00
parent 2a8472ed17
commit 4da13d1edc
9 changed files with 136 additions and 167 deletions

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { logStore } from '~/lib/stores/logs'; import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { GithubConnection } from './GithubConnection'; import { GithubConnection } from './GithubConnection';
@ -74,8 +73,6 @@ export default function ConnectionsTab() {
tokenType: 'classic', tokenType: 'classic',
}); });
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isConnecting, setIsConnecting] = useState(false);
const [isFetchingStats, setIsFetchingStats] = useState(false);
// Load saved connection on mount // Load saved connection on mount
useEffect(() => { useEffect(() => {
@ -101,8 +98,6 @@ export default function ConnectionsTab() {
const fetchGitHubStats = async (token: string) => { const fetchGitHubStats = async (token: string) => {
try { try {
setIsFetchingStats(true);
// Fetch repositories - only owned by the authenticated user // Fetch repositories - only owned by the authenticated user
const reposResponse = await fetch( const reposResponse = await fetch(
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator', 'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
@ -184,59 +179,9 @@ export default function ConnectionsTab() {
logStore.logError('Failed to fetch GitHub stats', { error }); logStore.logError('Failed to fetch GitHub stats', { error });
toast.error('Failed to fetch GitHub statistics'); toast.error('Failed to fetch GitHub statistics');
} finally { } finally {
setIsFetchingStats(false);
} }
}; };
const fetchGithubUser = async (token: string) => {
try {
setIsConnecting(true);
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Invalid token or unauthorized');
}
const data = (await response.json()) as GitHubUserResponse;
const newConnection: GitHubConnection = {
user: data,
token,
tokenType: connection.tokenType,
};
// Save connection
localStorage.setItem('github_connection', JSON.stringify(newConnection));
setConnection(newConnection);
// Fetch additional stats
await fetchGitHubStats(token);
toast.success('Successfully connected to GitHub');
} catch (error) {
logStore.logError('Failed to authenticate with GitHub', { error });
toast.error('Failed to connect to GitHub');
setConnection({ user: null, token: '', tokenType: 'classic' });
} finally {
setIsConnecting(false);
}
};
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
await fetchGithubUser(connection.token);
};
const handleDisconnect = () => {
localStorage.removeItem('github_connection');
setConnection({ user: null, token: '', tokenType: 'classic' });
toast.success('Disconnected from GitHub');
};
if (isLoading) { if (isLoading) {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
@ -259,9 +204,9 @@ export default function ConnectionsTab() {
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
{/* GitHub Connection */} {/* GitHub Connection */}
<GithubConnection/> <GithubConnection />
{/* Netlify Connection */} {/* Netlify Connection */}
<NetlifyConnection/> <NetlifyConnection />
</div> </div>
</div> </div>
); );

View File

@ -553,4 +553,3 @@ export function GithubConnection() {
</motion.div> </motion.div>
); );
} }

View File

@ -28,7 +28,7 @@ export function NetlifyConnection() {
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', { const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
@ -37,8 +37,8 @@ export function NetlifyConnection() {
throw new Error(`Failed to fetch sites: ${sitesResponse.status}`); throw new Error(`Failed to fetch sites: ${sitesResponse.status}`);
} }
const sites = await sitesResponse.json() as NetlifySite[]; const sites = (await sitesResponse.json()) as NetlifySite[];
const currentState = netlifyConnection.get(); const currentState = netlifyConnection.get();
updateNetlifyConnection({ updateNetlifyConnection({
...currentState, ...currentState,
@ -63,7 +63,7 @@ export function NetlifyConnection() {
try { try {
const response = await fetch('https://api.netlify.com/api/v1/user', { const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: { headers: {
'Authorization': `Bearer ${connection.token}`, Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
@ -72,12 +72,12 @@ export function NetlifyConnection() {
throw new Error('Invalid token or unauthorized'); throw new Error('Invalid token or unauthorized');
} }
const userData = await response.json() as NetlifyUser; const userData = (await response.json()) as NetlifyUser;
updateNetlifyConnection({ updateNetlifyConnection({
user: userData, user: userData,
token: connection.token, token: connection.token,
}); });
await fetchNetlifyStats(connection.token); await fetchNetlifyStats(connection.token);
toast.success('Successfully connected to Netlify'); toast.success('Successfully connected to Netlify');
} catch (error) { } catch (error) {
@ -105,7 +105,13 @@ export function NetlifyConnection() {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img className='w-5 h-5' height="24" width="24" crossOrigin='anonymous' src="https://cdn.simpleicons.org/netlify" /> <img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3> <h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
</div> </div>
</div> </div>
@ -113,9 +119,7 @@ export function NetlifyConnection() {
{!connection.user ? ( {!connection.user ? (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2"> <label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
Personal Access Token
</label>
<input <input
type="password" type="password"
value={connection.token} value={connection.token}
@ -190,12 +194,12 @@ export function NetlifyConnection() {
</div> </div>
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg"> <div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
<img <img
src={connection.user.avatar_url} src={connection.user.avatar_url}
referrerPolicy='no-referrer' referrerPolicy="no-referrer"
crossOrigin="anonymous" crossOrigin="anonymous"
alt={connection.user.full_name} alt={connection.user.full_name}
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]" className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
/> />
<div> <div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4> <h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
@ -231,7 +235,12 @@ export function NetlifyConnection() {
{site.name} {site.name}
</h5> </h5>
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary"> <div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
<a href={site.url} target="_blank" rel="noopener noreferrer" className="hover:text-[#00AD9F]"> <a
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#00AD9F]"
>
{site.url} {site.url}
</a> </a>
{site.published_deploy && ( {site.published_deploy && (
@ -270,4 +279,4 @@ export function NetlifyConnection() {
</div> </div>
</motion.div> </motion.div>
); );
} }

View File

@ -16,7 +16,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
const showWorkbench = useStore(workbenchStore.showWorkbench); const showWorkbench = useStore(workbenchStore.showWorkbench);
const { showChat } = useStore(chatStore); const { showChat } = useStore(chatStore);
const connection = useStore(netlifyConnection); const connection = useStore(netlifyConnection);
const [activePreviewIndex, setActivePreviewIndex] = useState(0); const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews); const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex]; const activePreview = previews[activePreviewIndex];
const [isDeploying, setIsDeploying] = useState(false); const [isDeploying, setIsDeploying] = useState(false);
@ -31,26 +31,27 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
try { try {
setIsDeploying(true); setIsDeploying(true);
const artifact = workbenchStore.firstArtifact; const artifact = workbenchStore.firstArtifact;
if (!artifact) { if (!artifact) {
throw new Error('No active project found'); throw new Error('No active project found');
} }
const actionId = 'build-' + Date.now(); const actionId = 'build-' + Date.now();
const actionData: ActionCallbackData = { const actionData: ActionCallbackData = {
messageId: "netlify build", messageId: 'netlify build',
artifactId: artifact.id, artifactId: artifact.id,
actionId, actionId,
action: { action: {
type: 'build' as const, type: 'build' as const,
content: 'npm run build', content: 'npm run build',
} },
}; };
// Add the action first // Add the action first
artifact.runner.addAction(actionData); artifact.runner.addAction(actionData);
// Then run it // Then run it
await artifact.runner.runAction(actionData); await artifact.runner.runAction(actionData);
@ -60,17 +61,21 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
// Get the build files // Get the build files
const container = await webcontainer; const container = await webcontainer;
// Remove /home/project from buildPath if it exists // Remove /home/project from buildPath if it exists
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', ''); const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
// Get all files recursively // Get all files recursively
async function getAllFiles(dirPath: string): Promise<Record<string, string>> { async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
const files: Record<string, string> = {}; const files: Record<string, string> = {};
const entries = await container.fs.readdir(dirPath, { withFileTypes: true }); const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name); const fullPath = path.join(dirPath, entry.name);
if (entry.isFile()) { if (entry.isFile()) {
const content = await container.fs.readFile(fullPath, 'utf-8'); const content = await container.fs.readFile(fullPath, 'utf-8');
// Remove /dist prefix from the path // Remove /dist prefix from the path
const deployPath = fullPath.replace(buildPath, ''); const deployPath = fullPath.replace(buildPath, '');
files[deployPath] = content; files[deployPath] = content;
@ -79,7 +84,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
Object.assign(files, subFiles); Object.assign(files, subFiles);
} }
} }
return files; return files;
} }
@ -96,12 +101,12 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
siteId: existingSiteId || undefined, siteId: existingSiteId || undefined,
files: fileContents, files: fileContents,
token: connection.token, token: connection.token,
chatId: artifact.id chatId: artifact.id,
}), }),
}); });
const data = await response.json() as any; const data = (await response.json()) as any;
if (!response.ok || !data.deploy || !data.site) { if (!response.ok || !data.deploy || !data.site) {
console.error('Invalid deploy response:', data); console.error('Invalid deploy response:', data);
throw new Error(data.error || 'Invalid deployment response'); throw new Error(data.error || 'Invalid deployment response');
@ -114,35 +119,38 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
while (attempts < maxAttempts) { while (attempts < maxAttempts) {
try { try {
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`, { const statusResponse = await fetch(
headers: { `https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`,
'Authorization': `Bearer ${connection.token}`, {
headers: {
Authorization: `Bearer ${connection.token}`,
},
}, },
}); );
deploymentStatus = await statusResponse.json() as any; deploymentStatus = (await statusResponse.json()) as any;
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') { if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
break; break;
} }
if (deploymentStatus.state === 'error') { if (deploymentStatus.state === 'error') {
throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error')); throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'));
} }
attempts++; attempts++;
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) { } catch (error) {
console.error('Status check error:', error); console.error('Status check error:', error);
attempts++; attempts++;
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
} }
} }
if (attempts >= maxAttempts) { if (attempts >= maxAttempts) {
throw new Error('Deployment timed out'); throw new Error('Deployment timed out');
} }
// Store the site ID if it's a new site // Store the site ID if it's a new site
if (data.site) { if (data.site) {
localStorage.setItem(`netlify-site-${artifact.id}`, data.site.id); localStorage.setItem(`netlify-site-${artifact.id}`, data.site.id);
@ -151,15 +159,15 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
toast.success( toast.success(
<div> <div>
Deployed successfully!{' '} Deployed successfully!{' '}
<a <a
href={deploymentStatus.ssl_url || deploymentStatus.url} href={deploymentStatus.ssl_url || deploymentStatus.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline" className="underline"
> >
View site View site
</a> </a>
</div> </div>,
); );
} catch (error) { } catch (error) {
console.error('Deploy error:', error); console.error('Deploy error:', error);
@ -172,11 +180,11 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
return ( return (
<div className="flex"> <div className="flex">
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm"> <div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
<Button <Button
active active
disabled={isDeploying || !activePreview} disabled={isDeploying || !activePreview}
onClick={handleDeploy} onClick={handleDeploy}
className='px-4 hover:bg-bolt-elements-item-backgroundActive' className="px-4 hover:bg-bolt-elements-item-backgroundActive"
> >
{isDeploying ? 'Deploying...' : 'Deploy'} {isDeploying ? 'Deploying...' : 'Deploy'}
</Button> </Button>
@ -222,15 +230,17 @@ interface ButtonProps {
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) { function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
return ( return (
<button <button
className={classNames('flex items-center p-1.5', { className={classNames(
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': 'flex items-center p-1.5',
!active, {
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled, 'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed': !active,
disabled, 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
}, 'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
className disabled,
)} },
className,
)}
onClick={onClick} onClick={onClick}
> >
{children} {children}

View File

@ -159,6 +159,7 @@ export class ActionRunner {
} }
case 'build': { case 'build': {
const buildOutput = await this.#runBuildAction(action); const buildOutput = await this.#runBuildAction(action);
// Store build output for deployment // Store build output for deployment
this.buildOutput = buildOutput; this.buildOutput = buildOutput;
break; break;
@ -318,16 +319,17 @@ export class ActionRunner {
} }
const webcontainer = await this.#webcontainer; const webcontainer = await this.#webcontainer;
// Create a new terminal specifically for the build // Create a new terminal specifically for the build
const buildProcess = await webcontainer.spawn('npm', ['run', 'build']); const buildProcess = await webcontainer.spawn('npm', ['run', 'build']);
let output = ''; let output = '';
buildProcess.output.pipeTo( buildProcess.output.pipeTo(
new WritableStream({ new WritableStream({
write(data) { write(data) {
output += data; output += data;
}, },
}) }),
); );
const exitCode = await buildProcess.exit; const exitCode = await buildProcess.exit;
@ -342,7 +344,7 @@ export class ActionRunner {
return { return {
path: buildDir, path: buildDir,
exitCode, exitCode,
output output,
}; };
} }
} }

View File

@ -24,4 +24,4 @@ export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) =>
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.setItem('netlify_connection', JSON.stringify(newState)); localStorage.setItem('netlify_connection', JSON.stringify(newState));
} }
}; };

View File

@ -10,7 +10,8 @@ interface DeployRequestBody {
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
try { try {
const { siteId, files, token, chatId } = await request.json() as DeployRequestBody & { token: string }; const { siteId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string };
if (!token) { if (!token) {
return json({ error: 'Not connected to Netlify' }, { status: 401 }); return json({ error: 'Not connected to Netlify' }, { status: 401 });
} }
@ -24,81 +25,82 @@ export async function action({ request }: ActionFunctionArgs) {
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', { const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
name: siteName, name: siteName,
custom_domain: null, custom_domain: null,
}) }),
}); });
if (!createSiteResponse.ok) { if (!createSiteResponse.ok) {
return json({ error: 'Failed to create site' }, { status: 400 }); return json({ error: 'Failed to create site' }, { status: 400 });
} }
const newSite = await createSiteResponse.json() as any; const newSite = (await createSiteResponse.json()) as any;
targetSiteId = newSite.id; targetSiteId = newSite.id;
siteInfo = { siteInfo = {
id: newSite.id, id: newSite.id,
name: newSite.name, name: newSite.name,
url: newSite.url, url: newSite.url,
chatId chatId,
}; };
} else { } else {
// Get existing site info // Get existing site info
if (targetSiteId) { if (targetSiteId) {
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}`, { const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}`, {
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}); });
if (siteResponse.ok) { if (siteResponse.ok) {
const existingSite = await siteResponse.json() as any; const existingSite = (await siteResponse.json()) as any;
siteInfo = { siteInfo = {
id: existingSite.id, id: existingSite.id,
name: existingSite.name, name: existingSite.name,
url: existingSite.url, url: existingSite.url,
chatId chatId,
}; };
} else { } else {
targetSiteId = undefined; targetSiteId = undefined;
} }
} }
// If no siteId provided or site doesn't exist, create a new site // If no siteId provided or site doesn't exist, create a new site
if (!targetSiteId) { if (!targetSiteId) {
const siteName = `bolt-diy-${chatId}-${Date.now()}`; const siteName = `bolt-diy-${chatId}-${Date.now()}`;
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', { const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
name: siteName, name: siteName,
custom_domain: null, custom_domain: null,
}) }),
}); });
if (!createSiteResponse.ok) { if (!createSiteResponse.ok) {
return json({ error: 'Failed to create site' }, { status: 400 }); return json({ error: 'Failed to create site' }, { status: 400 });
} }
const newSite = await createSiteResponse.json() as any; const newSite = (await createSiteResponse.json()) as any;
targetSiteId = newSite.id; targetSiteId = newSite.id;
siteInfo = { siteInfo = {
id: newSite.id, id: newSite.id,
name: newSite.name, name: newSite.name,
url: newSite.url, url: newSite.url,
chatId chatId,
}; };
} }
} }
// Create file digests // Create file digests
const fileDigests: Record<string, string> = {}; const fileDigests: Record<string, string> = {};
for (const [filePath, content] of Object.entries(files)) { for (const [filePath, content] of Object.entries(files)) {
// Ensure file path starts with a forward slash // Ensure file path starts with a forward slash
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath; const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
@ -110,7 +112,7 @@ export async function action({ request }: ActionFunctionArgs) {
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys`, { const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -120,15 +122,15 @@ export async function action({ request }: ActionFunctionArgs) {
draft: false, // Change this to false for production deployments draft: false, // Change this to false for production deployments
function_schedules: [], function_schedules: [],
required: Object.keys(fileDigests), // Add this line required: Object.keys(fileDigests), // Add this line
framework: null framework: null,
}) }),
}); });
if (!deployResponse.ok) { if (!deployResponse.ok) {
return json({ error: 'Failed to create deployment' }, { status: 400 }); return json({ error: 'Failed to create deployment' }, { status: 400 });
} }
const deploy = await deployResponse.json() as any; const deploy = (await deployResponse.json()) as any;
let retryCount = 0; let retryCount = 0;
const maxRetries = 60; const maxRetries = 60;
@ -136,41 +138,45 @@ export async function action({ request }: ActionFunctionArgs) {
while (retryCount < maxRetries) { while (retryCount < maxRetries) {
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, { const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}); });
const status = await statusResponse.json() as any; const status = (await statusResponse.json()) as any;
if (status.state === 'prepared' || status.state === 'uploaded') { if (status.state === 'prepared' || status.state === 'uploaded') {
// Upload all files regardless of required array // Upload all files regardless of required array
for (const [filePath, content] of Object.entries(files)) { for (const [filePath, content] of Object.entries(files)) {
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath; const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
let uploadSuccess = false; let uploadSuccess = false;
let uploadRetries = 0; let uploadRetries = 0;
while (!uploadSuccess && uploadRetries < 3) { while (!uploadSuccess && uploadRetries < 3) {
try { try {
const uploadResponse = await fetch(`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${normalizedPath}`, { const uploadResponse = await fetch(
method: 'PUT', `https://api.netlify.com/api/v1/deploys/${deploy.id}/files${normalizedPath}`,
headers: { {
'Authorization': `Bearer ${token}`, method: 'PUT',
'Content-Type': 'application/octet-stream', headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/octet-stream',
},
body: content,
}, },
body: content, );
});
uploadSuccess = uploadResponse.ok; uploadSuccess = uploadResponse.ok;
if (!uploadSuccess) { if (!uploadSuccess) {
console.error('Upload failed:', await uploadResponse.text()); console.error('Upload failed:', await uploadResponse.text());
uploadRetries++; uploadRetries++;
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
} }
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
uploadRetries++; uploadRetries++;
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
} }
} }
@ -179,18 +185,18 @@ export async function action({ request }: ActionFunctionArgs) {
} }
} }
} }
if (status.state === 'ready') { if (status.state === 'ready') {
// Only return after files are uploaded // Only return after files are uploaded
if (Object.keys(files).length === 0 || status.summary?.status === 'ready') { if (Object.keys(files).length === 0 || status.summary?.status === 'ready') {
return json({ return json({
success: true, success: true,
deploy: { deploy: {
id: status.id, id: status.id,
state: status.state, state: status.state,
url: status.ssl_url || status.url url: status.ssl_url || status.url,
}, },
site: siteInfo site: siteInfo,
}); });
} }
} }
@ -200,7 +206,7 @@ export async function action({ request }: ActionFunctionArgs) {
} }
retryCount++; retryCount++;
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
} }
if (retryCount >= maxRetries) { if (retryCount >= maxRetries) {
@ -208,16 +214,16 @@ export async function action({ request }: ActionFunctionArgs) {
} }
// Make sure we're returning the deploy ID and site info // Make sure we're returning the deploy ID and site info
return json({ return json({
success: true, success: true,
deploy: { deploy: {
id: deploy.id, id: deploy.id,
state: deploy.state, state: deploy.state,
}, },
site: siteInfo site: siteInfo,
}); });
} catch (error) { } catch (error) {
console.error('Deploy error:', error); console.error('Deploy error:', error);
return json({ error: 'Deployment failed' }, { status: 500 }); return json({ error: 'Deployment failed' }, { status: 500 });
} }
} }

View File

@ -38,4 +38,4 @@ export interface NetlifySiteInfo {
name: string; name: string;
url: string; url: string;
chatId: string; chatId: string;
} }

View File

@ -98,9 +98,7 @@ const COLOR_PRIMITIVES = {
}; };
export default defineConfig({ export default defineConfig({
safelist: [ safelist: [...Object.keys(customIconCollection[collectionName] || {}).map((x) => `i-bolt:${x}`)],
...Object.keys(customIconCollection[collectionName] || {}).map(x => `i-bolt:${x}`)
],
shortcuts: { shortcuts: {
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]', 'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier', 'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',