KevIsDev
commited on
Commit
·
4da13d1
1
Parent(s):
2a8472e
feat: add netlify one-click deployment
Browse files- app/components/@settings/tabs/connections/ConnectionsTab.tsx +2 -57
- app/components/@settings/tabs/connections/GithubConnection.tsx +0 -1
- app/components/@settings/tabs/connections/NetlifyConnection.tsx +27 -18
- app/components/header/HeaderActionButtons.client.tsx +48 -38
- app/lib/runtime/action-runner.ts +5 -3
- app/lib/stores/netlify.ts +1 -1
- app/routes/api.deploy.ts +51 -45
- app/types/netlify.ts +1 -1
- uno.config.ts +1 -3
app/components/@settings/tabs/connections/ConnectionsTab.tsx
CHANGED
@@ -1,6 +1,5 @@
|
|
1 |
import React, { useState, useEffect } from 'react';
|
2 |
import { logStore } from '~/lib/stores/logs';
|
3 |
-
import { classNames } from '~/utils/classNames';
|
4 |
import { motion } from 'framer-motion';
|
5 |
import { toast } from 'react-toastify';
|
6 |
import { GithubConnection } from './GithubConnection';
|
@@ -74,8 +73,6 @@ export default function ConnectionsTab() {
|
|
74 |
tokenType: 'classic',
|
75 |
});
|
76 |
const [isLoading, setIsLoading] = useState(true);
|
77 |
-
const [isConnecting, setIsConnecting] = useState(false);
|
78 |
-
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
79 |
|
80 |
// Load saved connection on mount
|
81 |
useEffect(() => {
|
@@ -101,8 +98,6 @@ export default function ConnectionsTab() {
|
|
101 |
|
102 |
const fetchGitHubStats = async (token: string) => {
|
103 |
try {
|
104 |
-
setIsFetchingStats(true);
|
105 |
-
|
106 |
// Fetch repositories - only owned by the authenticated user
|
107 |
const reposResponse = await fetch(
|
108 |
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
|
@@ -184,59 +179,9 @@ export default function ConnectionsTab() {
|
|
184 |
logStore.logError('Failed to fetch GitHub stats', { error });
|
185 |
toast.error('Failed to fetch GitHub statistics');
|
186 |
} finally {
|
187 |
-
setIsFetchingStats(false);
|
188 |
-
}
|
189 |
-
};
|
190 |
-
|
191 |
-
const fetchGithubUser = async (token: string) => {
|
192 |
-
try {
|
193 |
-
setIsConnecting(true);
|
194 |
-
|
195 |
-
const response = await fetch('https://api.github.com/user', {
|
196 |
-
headers: {
|
197 |
-
Authorization: `Bearer ${token}`,
|
198 |
-
},
|
199 |
-
});
|
200 |
-
|
201 |
-
if (!response.ok) {
|
202 |
-
throw new Error('Invalid token or unauthorized');
|
203 |
-
}
|
204 |
-
|
205 |
-
const data = (await response.json()) as GitHubUserResponse;
|
206 |
-
const newConnection: GitHubConnection = {
|
207 |
-
user: data,
|
208 |
-
token,
|
209 |
-
tokenType: connection.tokenType,
|
210 |
-
};
|
211 |
-
|
212 |
-
// Save connection
|
213 |
-
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
214 |
-
setConnection(newConnection);
|
215 |
-
|
216 |
-
// Fetch additional stats
|
217 |
-
await fetchGitHubStats(token);
|
218 |
-
|
219 |
-
toast.success('Successfully connected to GitHub');
|
220 |
-
} catch (error) {
|
221 |
-
logStore.logError('Failed to authenticate with GitHub', { error });
|
222 |
-
toast.error('Failed to connect to GitHub');
|
223 |
-
setConnection({ user: null, token: '', tokenType: 'classic' });
|
224 |
-
} finally {
|
225 |
-
setIsConnecting(false);
|
226 |
}
|
227 |
};
|
228 |
|
229 |
-
const handleConnect = async (event: React.FormEvent) => {
|
230 |
-
event.preventDefault();
|
231 |
-
await fetchGithubUser(connection.token);
|
232 |
-
};
|
233 |
-
|
234 |
-
const handleDisconnect = () => {
|
235 |
-
localStorage.removeItem('github_connection');
|
236 |
-
setConnection({ user: null, token: '', tokenType: 'classic' });
|
237 |
-
toast.success('Disconnected from GitHub');
|
238 |
-
};
|
239 |
-
|
240 |
if (isLoading) {
|
241 |
return <LoadingSpinner />;
|
242 |
}
|
@@ -259,9 +204,9 @@ export default function ConnectionsTab() {
|
|
259 |
|
260 |
<div className="grid grid-cols-1 gap-4">
|
261 |
{/* GitHub Connection */}
|
262 |
-
<GithubConnection/>
|
263 |
{/* Netlify Connection */}
|
264 |
-
<NetlifyConnection/>
|
265 |
</div>
|
266 |
</div>
|
267 |
);
|
|
|
1 |
import React, { useState, useEffect } from 'react';
|
2 |
import { logStore } from '~/lib/stores/logs';
|
|
|
3 |
import { motion } from 'framer-motion';
|
4 |
import { toast } from 'react-toastify';
|
5 |
import { GithubConnection } from './GithubConnection';
|
|
|
73 |
tokenType: 'classic',
|
74 |
});
|
75 |
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
76 |
|
77 |
// Load saved connection on mount
|
78 |
useEffect(() => {
|
|
|
98 |
|
99 |
const fetchGitHubStats = async (token: string) => {
|
100 |
try {
|
|
|
|
|
101 |
// Fetch repositories - only owned by the authenticated user
|
102 |
const reposResponse = await fetch(
|
103 |
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
|
|
|
179 |
logStore.logError('Failed to fetch GitHub stats', { error });
|
180 |
toast.error('Failed to fetch GitHub statistics');
|
181 |
} finally {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
182 |
}
|
183 |
};
|
184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
185 |
if (isLoading) {
|
186 |
return <LoadingSpinner />;
|
187 |
}
|
|
|
204 |
|
205 |
<div className="grid grid-cols-1 gap-4">
|
206 |
{/* GitHub Connection */}
|
207 |
+
<GithubConnection />
|
208 |
{/* Netlify Connection */}
|
209 |
+
<NetlifyConnection />
|
210 |
</div>
|
211 |
</div>
|
212 |
);
|
app/components/@settings/tabs/connections/GithubConnection.tsx
CHANGED
@@ -553,4 +553,3 @@ export function GithubConnection() {
|
|
553 |
</motion.div>
|
554 |
);
|
555 |
}
|
556 |
-
|
|
|
553 |
</motion.div>
|
554 |
);
|
555 |
}
|
|
app/components/@settings/tabs/connections/NetlifyConnection.tsx
CHANGED
@@ -28,7 +28,7 @@ export function NetlifyConnection() {
|
|
28 |
|
29 |
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
30 |
headers: {
|
31 |
-
|
32 |
'Content-Type': 'application/json',
|
33 |
},
|
34 |
});
|
@@ -37,8 +37,8 @@ export function NetlifyConnection() {
|
|
37 |
throw new Error(`Failed to fetch sites: ${sitesResponse.status}`);
|
38 |
}
|
39 |
|
40 |
-
const sites = await sitesResponse.json() as NetlifySite[];
|
41 |
-
|
42 |
const currentState = netlifyConnection.get();
|
43 |
updateNetlifyConnection({
|
44 |
...currentState,
|
@@ -63,7 +63,7 @@ export function NetlifyConnection() {
|
|
63 |
try {
|
64 |
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
65 |
headers: {
|
66 |
-
|
67 |
'Content-Type': 'application/json',
|
68 |
},
|
69 |
});
|
@@ -72,12 +72,12 @@ export function NetlifyConnection() {
|
|
72 |
throw new Error('Invalid token or unauthorized');
|
73 |
}
|
74 |
|
75 |
-
const userData = await response.json() as NetlifyUser;
|
76 |
updateNetlifyConnection({
|
77 |
user: userData,
|
78 |
token: connection.token,
|
79 |
});
|
80 |
-
|
81 |
await fetchNetlifyStats(connection.token);
|
82 |
toast.success('Successfully connected to Netlify');
|
83 |
} catch (error) {
|
@@ -105,7 +105,13 @@ export function NetlifyConnection() {
|
|
105 |
<div className="p-6 space-y-6">
|
106 |
<div className="flex items-center justify-between">
|
107 |
<div className="flex items-center gap-2">
|
108 |
-
<img
|
|
|
|
|
|
|
|
|
|
|
|
|
109 |
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
|
110 |
</div>
|
111 |
</div>
|
@@ -113,9 +119,7 @@ export function NetlifyConnection() {
|
|
113 |
{!connection.user ? (
|
114 |
<div className="space-y-4">
|
115 |
<div>
|
116 |
-
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
117 |
-
Personal Access Token
|
118 |
-
</label>
|
119 |
<input
|
120 |
type="password"
|
121 |
value={connection.token}
|
@@ -190,12 +194,12 @@ export function NetlifyConnection() {
|
|
190 |
</div>
|
191 |
|
192 |
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
193 |
-
<img
|
194 |
-
src={connection.user.avatar_url}
|
195 |
-
referrerPolicy=
|
196 |
-
crossOrigin="anonymous"
|
197 |
-
alt={connection.user.full_name}
|
198 |
-
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
|
199 |
/>
|
200 |
<div>
|
201 |
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
|
@@ -231,7 +235,12 @@ export function NetlifyConnection() {
|
|
231 |
{site.name}
|
232 |
</h5>
|
233 |
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
234 |
-
<a
|
|
|
|
|
|
|
|
|
|
|
235 |
{site.url}
|
236 |
</a>
|
237 |
{site.published_deploy && (
|
@@ -270,4 +279,4 @@ export function NetlifyConnection() {
|
|
270 |
</div>
|
271 |
</motion.div>
|
272 |
);
|
273 |
-
}
|
|
|
28 |
|
29 |
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
30 |
headers: {
|
31 |
+
Authorization: `Bearer ${token}`,
|
32 |
'Content-Type': 'application/json',
|
33 |
},
|
34 |
});
|
|
|
37 |
throw new Error(`Failed to fetch sites: ${sitesResponse.status}`);
|
38 |
}
|
39 |
|
40 |
+
const sites = (await sitesResponse.json()) as NetlifySite[];
|
41 |
+
|
42 |
const currentState = netlifyConnection.get();
|
43 |
updateNetlifyConnection({
|
44 |
...currentState,
|
|
|
63 |
try {
|
64 |
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
65 |
headers: {
|
66 |
+
Authorization: `Bearer ${connection.token}`,
|
67 |
'Content-Type': 'application/json',
|
68 |
},
|
69 |
});
|
|
|
72 |
throw new Error('Invalid token or unauthorized');
|
73 |
}
|
74 |
|
75 |
+
const userData = (await response.json()) as NetlifyUser;
|
76 |
updateNetlifyConnection({
|
77 |
user: userData,
|
78 |
token: connection.token,
|
79 |
});
|
80 |
+
|
81 |
await fetchNetlifyStats(connection.token);
|
82 |
toast.success('Successfully connected to Netlify');
|
83 |
} catch (error) {
|
|
|
105 |
<div className="p-6 space-y-6">
|
106 |
<div className="flex items-center justify-between">
|
107 |
<div className="flex items-center gap-2">
|
108 |
+
<img
|
109 |
+
className="w-5 h-5"
|
110 |
+
height="24"
|
111 |
+
width="24"
|
112 |
+
crossOrigin="anonymous"
|
113 |
+
src="https://cdn.simpleicons.org/netlify"
|
114 |
+
/>
|
115 |
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
|
116 |
</div>
|
117 |
</div>
|
|
|
119 |
{!connection.user ? (
|
120 |
<div className="space-y-4">
|
121 |
<div>
|
122 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
|
|
|
|
|
123 |
<input
|
124 |
type="password"
|
125 |
value={connection.token}
|
|
|
194 |
</div>
|
195 |
|
196 |
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
197 |
+
<img
|
198 |
+
src={connection.user.avatar_url}
|
199 |
+
referrerPolicy="no-referrer"
|
200 |
+
crossOrigin="anonymous"
|
201 |
+
alt={connection.user.full_name}
|
202 |
+
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
|
203 |
/>
|
204 |
<div>
|
205 |
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
|
|
|
235 |
{site.name}
|
236 |
</h5>
|
237 |
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
238 |
+
<a
|
239 |
+
href={site.url}
|
240 |
+
target="_blank"
|
241 |
+
rel="noopener noreferrer"
|
242 |
+
className="hover:text-[#00AD9F]"
|
243 |
+
>
|
244 |
{site.url}
|
245 |
</a>
|
246 |
{site.published_deploy && (
|
|
|
279 |
</div>
|
280 |
</motion.div>
|
281 |
);
|
282 |
+
}
|
app/components/header/HeaderActionButtons.client.tsx
CHANGED
@@ -16,7 +16,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
|
16 |
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
17 |
const { showChat } = useStore(chatStore);
|
18 |
const connection = useStore(netlifyConnection);
|
19 |
-
const [activePreviewIndex
|
20 |
const previews = useStore(workbenchStore.previews);
|
21 |
const activePreview = previews[activePreviewIndex];
|
22 |
const [isDeploying, setIsDeploying] = useState(false);
|
@@ -31,26 +31,27 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
|
31 |
|
32 |
try {
|
33 |
setIsDeploying(true);
|
|
|
34 |
const artifact = workbenchStore.firstArtifact;
|
35 |
-
|
36 |
if (!artifact) {
|
37 |
throw new Error('No active project found');
|
38 |
}
|
39 |
|
40 |
const actionId = 'build-' + Date.now();
|
41 |
const actionData: ActionCallbackData = {
|
42 |
-
messageId:
|
43 |
artifactId: artifact.id,
|
44 |
actionId,
|
45 |
action: {
|
46 |
type: 'build' as const,
|
47 |
content: 'npm run build',
|
48 |
-
}
|
49 |
};
|
50 |
|
51 |
// Add the action first
|
52 |
artifact.runner.addAction(actionData);
|
53 |
-
|
54 |
// Then run it
|
55 |
await artifact.runner.runAction(actionData);
|
56 |
|
@@ -60,17 +61,21 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
|
60 |
|
61 |
// Get the build files
|
62 |
const container = await webcontainer;
|
|
|
63 |
// Remove /home/project from buildPath if it exists
|
64 |
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
|
|
|
65 |
// Get all files recursively
|
66 |
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
|
67 |
const files: Record<string, string> = {};
|
68 |
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
|
69 |
-
|
70 |
for (const entry of entries) {
|
71 |
const fullPath = path.join(dirPath, entry.name);
|
|
|
72 |
if (entry.isFile()) {
|
73 |
const content = await container.fs.readFile(fullPath, 'utf-8');
|
|
|
74 |
// Remove /dist prefix from the path
|
75 |
const deployPath = fullPath.replace(buildPath, '');
|
76 |
files[deployPath] = content;
|
@@ -79,7 +84,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
|
79 |
Object.assign(files, subFiles);
|
80 |
}
|
81 |
}
|
82 |
-
|
83 |
return files;
|
84 |
}
|
85 |
|
@@ -96,12 +101,12 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
|
96 |
siteId: existingSiteId || undefined,
|
97 |
files: fileContents,
|
98 |
token: connection.token,
|
99 |
-
chatId: artifact.id
|
100 |
}),
|
101 |
});
|
102 |
|
103 |
-
const data = await response.json() as any;
|
104 |
-
|
105 |
if (!response.ok || !data.deploy || !data.site) {
|
106 |
console.error('Invalid deploy response:', data);
|
107 |
throw new Error(data.error || 'Invalid deployment response');
|
@@ -114,35 +119,38 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
|
114 |
|
115 |
while (attempts < maxAttempts) {
|
116 |
try {
|
117 |
-
const statusResponse = await fetch(
|
118 |
-
|
119 |
-
|
|
|
|
|
|
|
120 |
},
|
121 |
-
|
122 |
-
|
123 |
-
deploymentStatus = await statusResponse.json() as any;
|
124 |
-
|
125 |
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
|
126 |
break;
|
127 |
}
|
128 |
-
|
129 |
if (deploymentStatus.state === 'error') {
|
130 |
throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'));
|
131 |
}
|
132 |
-
|
133 |
attempts++;
|
134 |
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
135 |
} catch (error) {
|
136 |
console.error('Status check error:', error);
|
137 |
attempts++;
|
138 |
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
139 |
}
|
140 |
}
|
141 |
|
142 |
if (attempts >= maxAttempts) {
|
143 |
throw new Error('Deployment timed out');
|
144 |
}
|
145 |
-
|
146 |
// Store the site ID if it's a new site
|
147 |
if (data.site) {
|
148 |
localStorage.setItem(`netlify-site-${artifact.id}`, data.site.id);
|
@@ -151,15 +159,15 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
|
151 |
toast.success(
|
152 |
<div>
|
153 |
Deployed successfully!{' '}
|
154 |
-
<a
|
155 |
-
href={deploymentStatus.ssl_url || deploymentStatus.url}
|
156 |
-
target="_blank"
|
157 |
rel="noopener noreferrer"
|
158 |
className="underline"
|
159 |
>
|
160 |
View site
|
161 |
</a>
|
162 |
-
</div
|
163 |
);
|
164 |
} catch (error) {
|
165 |
console.error('Deploy error:', error);
|
@@ -172,11 +180,11 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
|
172 |
return (
|
173 |
<div className="flex">
|
174 |
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
|
175 |
-
<Button
|
176 |
-
active
|
177 |
disabled={isDeploying || !activePreview}
|
178 |
onClick={handleDeploy}
|
179 |
-
className=
|
180 |
>
|
181 |
{isDeploying ? 'Deploying...' : 'Deploy'}
|
182 |
</Button>
|
@@ -222,15 +230,17 @@ interface ButtonProps {
|
|
222 |
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
223 |
return (
|
224 |
<button
|
225 |
-
className={classNames(
|
226 |
-
'
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
disabled,
|
231 |
-
|
232 |
-
|
233 |
-
|
|
|
|
|
234 |
onClick={onClick}
|
235 |
>
|
236 |
{children}
|
|
|
16 |
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
17 |
const { showChat } = useStore(chatStore);
|
18 |
const connection = useStore(netlifyConnection);
|
19 |
+
const [activePreviewIndex] = useState(0);
|
20 |
const previews = useStore(workbenchStore.previews);
|
21 |
const activePreview = previews[activePreviewIndex];
|
22 |
const [isDeploying, setIsDeploying] = useState(false);
|
|
|
31 |
|
32 |
try {
|
33 |
setIsDeploying(true);
|
34 |
+
|
35 |
const artifact = workbenchStore.firstArtifact;
|
36 |
+
|
37 |
if (!artifact) {
|
38 |
throw new Error('No active project found');
|
39 |
}
|
40 |
|
41 |
const actionId = 'build-' + Date.now();
|
42 |
const actionData: ActionCallbackData = {
|
43 |
+
messageId: 'netlify build',
|
44 |
artifactId: artifact.id,
|
45 |
actionId,
|
46 |
action: {
|
47 |
type: 'build' as const,
|
48 |
content: 'npm run build',
|
49 |
+
},
|
50 |
};
|
51 |
|
52 |
// Add the action first
|
53 |
artifact.runner.addAction(actionData);
|
54 |
+
|
55 |
// Then run it
|
56 |
await artifact.runner.runAction(actionData);
|
57 |
|
|
|
61 |
|
62 |
// Get the build files
|
63 |
const container = await webcontainer;
|
64 |
+
|
65 |
// Remove /home/project from buildPath if it exists
|
66 |
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
|
67 |
+
|
68 |
// Get all files recursively
|
69 |
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
|
70 |
const files: Record<string, string> = {};
|
71 |
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
|
72 |
+
|
73 |
for (const entry of entries) {
|
74 |
const fullPath = path.join(dirPath, entry.name);
|
75 |
+
|
76 |
if (entry.isFile()) {
|
77 |
const content = await container.fs.readFile(fullPath, 'utf-8');
|
78 |
+
|
79 |
// Remove /dist prefix from the path
|
80 |
const deployPath = fullPath.replace(buildPath, '');
|
81 |
files[deployPath] = content;
|
|
|
84 |
Object.assign(files, subFiles);
|
85 |
}
|
86 |
}
|
87 |
+
|
88 |
return files;
|
89 |
}
|
90 |
|
|
|
101 |
siteId: existingSiteId || undefined,
|
102 |
files: fileContents,
|
103 |
token: connection.token,
|
104 |
+
chatId: artifact.id,
|
105 |
}),
|
106 |
});
|
107 |
|
108 |
+
const data = (await response.json()) as any;
|
109 |
+
|
110 |
if (!response.ok || !data.deploy || !data.site) {
|
111 |
console.error('Invalid deploy response:', data);
|
112 |
throw new Error(data.error || 'Invalid deployment response');
|
|
|
119 |
|
120 |
while (attempts < maxAttempts) {
|
121 |
try {
|
122 |
+
const statusResponse = await fetch(
|
123 |
+
`https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`,
|
124 |
+
{
|
125 |
+
headers: {
|
126 |
+
Authorization: `Bearer ${connection.token}`,
|
127 |
+
},
|
128 |
},
|
129 |
+
);
|
130 |
+
|
131 |
+
deploymentStatus = (await statusResponse.json()) as any;
|
132 |
+
|
133 |
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
|
134 |
break;
|
135 |
}
|
136 |
+
|
137 |
if (deploymentStatus.state === 'error') {
|
138 |
throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'));
|
139 |
}
|
140 |
+
|
141 |
attempts++;
|
142 |
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
143 |
} catch (error) {
|
144 |
console.error('Status check error:', error);
|
145 |
attempts++;
|
146 |
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
147 |
}
|
148 |
}
|
149 |
|
150 |
if (attempts >= maxAttempts) {
|
151 |
throw new Error('Deployment timed out');
|
152 |
}
|
153 |
+
|
154 |
// Store the site ID if it's a new site
|
155 |
if (data.site) {
|
156 |
localStorage.setItem(`netlify-site-${artifact.id}`, data.site.id);
|
|
|
159 |
toast.success(
|
160 |
<div>
|
161 |
Deployed successfully!{' '}
|
162 |
+
<a
|
163 |
+
href={deploymentStatus.ssl_url || deploymentStatus.url}
|
164 |
+
target="_blank"
|
165 |
rel="noopener noreferrer"
|
166 |
className="underline"
|
167 |
>
|
168 |
View site
|
169 |
</a>
|
170 |
+
</div>,
|
171 |
);
|
172 |
} catch (error) {
|
173 |
console.error('Deploy error:', error);
|
|
|
180 |
return (
|
181 |
<div className="flex">
|
182 |
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
|
183 |
+
<Button
|
184 |
+
active
|
185 |
disabled={isDeploying || !activePreview}
|
186 |
onClick={handleDeploy}
|
187 |
+
className="px-4 hover:bg-bolt-elements-item-backgroundActive"
|
188 |
>
|
189 |
{isDeploying ? 'Deploying...' : 'Deploy'}
|
190 |
</Button>
|
|
|
230 |
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
231 |
return (
|
232 |
<button
|
233 |
+
className={classNames(
|
234 |
+
'flex items-center p-1.5',
|
235 |
+
{
|
236 |
+
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
|
237 |
+
!active,
|
238 |
+
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
|
239 |
+
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
240 |
+
disabled,
|
241 |
+
},
|
242 |
+
className,
|
243 |
+
)}
|
244 |
onClick={onClick}
|
245 |
>
|
246 |
{children}
|
app/lib/runtime/action-runner.ts
CHANGED
@@ -159,6 +159,7 @@ export class ActionRunner {
|
|
159 |
}
|
160 |
case 'build': {
|
161 |
const buildOutput = await this.#runBuildAction(action);
|
|
|
162 |
// Store build output for deployment
|
163 |
this.buildOutput = buildOutput;
|
164 |
break;
|
@@ -318,16 +319,17 @@ export class ActionRunner {
|
|
318 |
}
|
319 |
|
320 |
const webcontainer = await this.#webcontainer;
|
|
|
321 |
// Create a new terminal specifically for the build
|
322 |
const buildProcess = await webcontainer.spawn('npm', ['run', 'build']);
|
323 |
-
|
324 |
let output = '';
|
325 |
buildProcess.output.pipeTo(
|
326 |
new WritableStream({
|
327 |
write(data) {
|
328 |
output += data;
|
329 |
},
|
330 |
-
})
|
331 |
);
|
332 |
|
333 |
const exitCode = await buildProcess.exit;
|
@@ -342,7 +344,7 @@ export class ActionRunner {
|
|
342 |
return {
|
343 |
path: buildDir,
|
344 |
exitCode,
|
345 |
-
output
|
346 |
};
|
347 |
}
|
348 |
}
|
|
|
159 |
}
|
160 |
case 'build': {
|
161 |
const buildOutput = await this.#runBuildAction(action);
|
162 |
+
|
163 |
// Store build output for deployment
|
164 |
this.buildOutput = buildOutput;
|
165 |
break;
|
|
|
319 |
}
|
320 |
|
321 |
const webcontainer = await this.#webcontainer;
|
322 |
+
|
323 |
// Create a new terminal specifically for the build
|
324 |
const buildProcess = await webcontainer.spawn('npm', ['run', 'build']);
|
325 |
+
|
326 |
let output = '';
|
327 |
buildProcess.output.pipeTo(
|
328 |
new WritableStream({
|
329 |
write(data) {
|
330 |
output += data;
|
331 |
},
|
332 |
+
}),
|
333 |
);
|
334 |
|
335 |
const exitCode = await buildProcess.exit;
|
|
|
344 |
return {
|
345 |
path: buildDir,
|
346 |
exitCode,
|
347 |
+
output,
|
348 |
};
|
349 |
}
|
350 |
}
|
app/lib/stores/netlify.ts
CHANGED
@@ -24,4 +24,4 @@ export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) =>
|
|
24 |
if (typeof window !== 'undefined') {
|
25 |
localStorage.setItem('netlify_connection', JSON.stringify(newState));
|
26 |
}
|
27 |
-
};
|
|
|
24 |
if (typeof window !== 'undefined') {
|
25 |
localStorage.setItem('netlify_connection', JSON.stringify(newState));
|
26 |
}
|
27 |
+
};
|
app/routes/api.deploy.ts
CHANGED
@@ -10,7 +10,8 @@ interface DeployRequestBody {
|
|
10 |
|
11 |
export async function action({ request }: ActionFunctionArgs) {
|
12 |
try {
|
13 |
-
const { siteId, files, token, chatId } = await request.json() as DeployRequestBody & { token: string };
|
|
|
14 |
if (!token) {
|
15 |
return json({ error: 'Not connected to Netlify' }, { status: 401 });
|
16 |
}
|
@@ -24,81 +25,82 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
24 |
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
25 |
method: 'POST',
|
26 |
headers: {
|
27 |
-
|
28 |
'Content-Type': 'application/json',
|
29 |
},
|
30 |
body: JSON.stringify({
|
31 |
name: siteName,
|
32 |
custom_domain: null,
|
33 |
-
})
|
34 |
});
|
35 |
|
36 |
if (!createSiteResponse.ok) {
|
37 |
return json({ error: 'Failed to create site' }, { status: 400 });
|
38 |
}
|
39 |
|
40 |
-
const newSite = await createSiteResponse.json() as any;
|
41 |
targetSiteId = newSite.id;
|
42 |
siteInfo = {
|
43 |
id: newSite.id,
|
44 |
name: newSite.name,
|
45 |
url: newSite.url,
|
46 |
-
chatId
|
47 |
};
|
48 |
} else {
|
49 |
// Get existing site info
|
50 |
if (targetSiteId) {
|
51 |
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}`, {
|
52 |
headers: {
|
53 |
-
|
54 |
},
|
55 |
});
|
56 |
-
|
57 |
if (siteResponse.ok) {
|
58 |
-
const existingSite = await siteResponse.json() as any;
|
59 |
siteInfo = {
|
60 |
id: existingSite.id,
|
61 |
name: existingSite.name,
|
62 |
url: existingSite.url,
|
63 |
-
chatId
|
64 |
};
|
65 |
} else {
|
66 |
targetSiteId = undefined;
|
67 |
}
|
68 |
}
|
69 |
-
|
70 |
// If no siteId provided or site doesn't exist, create a new site
|
71 |
if (!targetSiteId) {
|
72 |
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
|
73 |
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
74 |
method: 'POST',
|
75 |
headers: {
|
76 |
-
|
77 |
'Content-Type': 'application/json',
|
78 |
},
|
79 |
body: JSON.stringify({
|
80 |
name: siteName,
|
81 |
custom_domain: null,
|
82 |
-
})
|
83 |
});
|
84 |
-
|
85 |
if (!createSiteResponse.ok) {
|
86 |
return json({ error: 'Failed to create site' }, { status: 400 });
|
87 |
}
|
88 |
-
|
89 |
-
const newSite = await createSiteResponse.json() as any;
|
90 |
targetSiteId = newSite.id;
|
91 |
siteInfo = {
|
92 |
id: newSite.id,
|
93 |
name: newSite.name,
|
94 |
url: newSite.url,
|
95 |
-
chatId
|
96 |
};
|
97 |
}
|
98 |
}
|
99 |
|
100 |
// Create file digests
|
101 |
const fileDigests: Record<string, string> = {};
|
|
|
102 |
for (const [filePath, content] of Object.entries(files)) {
|
103 |
// Ensure file path starts with a forward slash
|
104 |
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
@@ -110,7 +112,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
110 |
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys`, {
|
111 |
method: 'POST',
|
112 |
headers: {
|
113 |
-
|
114 |
'Content-Type': 'application/json',
|
115 |
},
|
116 |
body: JSON.stringify({
|
@@ -120,15 +122,15 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
120 |
draft: false, // Change this to false for production deployments
|
121 |
function_schedules: [],
|
122 |
required: Object.keys(fileDigests), // Add this line
|
123 |
-
framework: null
|
124 |
-
})
|
125 |
});
|
126 |
|
127 |
if (!deployResponse.ok) {
|
128 |
return json({ error: 'Failed to create deployment' }, { status: 400 });
|
129 |
}
|
130 |
|
131 |
-
const deploy = await deployResponse.json() as any;
|
132 |
let retryCount = 0;
|
133 |
const maxRetries = 60;
|
134 |
|
@@ -136,41 +138,45 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
136 |
while (retryCount < maxRetries) {
|
137 |
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
|
138 |
headers: {
|
139 |
-
|
140 |
},
|
141 |
});
|
142 |
-
|
143 |
-
const status = await statusResponse.json() as any;
|
144 |
-
|
145 |
if (status.state === 'prepared' || status.state === 'uploaded') {
|
146 |
// Upload all files regardless of required array
|
147 |
for (const [filePath, content] of Object.entries(files)) {
|
148 |
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
149 |
-
|
150 |
let uploadSuccess = false;
|
151 |
let uploadRetries = 0;
|
152 |
-
|
153 |
while (!uploadSuccess && uploadRetries < 3) {
|
154 |
try {
|
155 |
-
const uploadResponse = await fetch(
|
156 |
-
|
157 |
-
|
158 |
-
'
|
159 |
-
|
|
|
|
|
|
|
|
|
160 |
},
|
161 |
-
|
162 |
-
});
|
163 |
|
164 |
uploadSuccess = uploadResponse.ok;
|
|
|
165 |
if (!uploadSuccess) {
|
166 |
console.error('Upload failed:', await uploadResponse.text());
|
167 |
uploadRetries++;
|
168 |
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
169 |
}
|
170 |
} catch (error) {
|
171 |
console.error('Upload error:', error);
|
172 |
uploadRetries++;
|
173 |
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
174 |
}
|
175 |
}
|
176 |
|
@@ -179,18 +185,18 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
179 |
}
|
180 |
}
|
181 |
}
|
182 |
-
|
183 |
if (status.state === 'ready') {
|
184 |
// Only return after files are uploaded
|
185 |
if (Object.keys(files).length === 0 || status.summary?.status === 'ready') {
|
186 |
-
return json({
|
187 |
-
success: true,
|
188 |
deploy: {
|
189 |
id: status.id,
|
190 |
state: status.state,
|
191 |
-
url: status.ssl_url || status.url
|
192 |
},
|
193 |
-
site: siteInfo
|
194 |
});
|
195 |
}
|
196 |
}
|
@@ -200,7 +206,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
200 |
}
|
201 |
|
202 |
retryCount++;
|
203 |
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
204 |
}
|
205 |
|
206 |
if (retryCount >= maxRetries) {
|
@@ -208,16 +214,16 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
208 |
}
|
209 |
|
210 |
// Make sure we're returning the deploy ID and site info
|
211 |
-
return json({
|
212 |
-
success: true,
|
213 |
deploy: {
|
214 |
id: deploy.id,
|
215 |
state: deploy.state,
|
216 |
},
|
217 |
-
site: siteInfo
|
218 |
});
|
219 |
} catch (error) {
|
220 |
console.error('Deploy error:', error);
|
221 |
return json({ error: 'Deployment failed' }, { status: 500 });
|
222 |
}
|
223 |
-
}
|
|
|
10 |
|
11 |
export async function action({ request }: ActionFunctionArgs) {
|
12 |
try {
|
13 |
+
const { siteId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string };
|
14 |
+
|
15 |
if (!token) {
|
16 |
return json({ error: 'Not connected to Netlify' }, { status: 401 });
|
17 |
}
|
|
|
25 |
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
26 |
method: 'POST',
|
27 |
headers: {
|
28 |
+
Authorization: `Bearer ${token}`,
|
29 |
'Content-Type': 'application/json',
|
30 |
},
|
31 |
body: JSON.stringify({
|
32 |
name: siteName,
|
33 |
custom_domain: null,
|
34 |
+
}),
|
35 |
});
|
36 |
|
37 |
if (!createSiteResponse.ok) {
|
38 |
return json({ error: 'Failed to create site' }, { status: 400 });
|
39 |
}
|
40 |
|
41 |
+
const newSite = (await createSiteResponse.json()) as any;
|
42 |
targetSiteId = newSite.id;
|
43 |
siteInfo = {
|
44 |
id: newSite.id,
|
45 |
name: newSite.name,
|
46 |
url: newSite.url,
|
47 |
+
chatId,
|
48 |
};
|
49 |
} else {
|
50 |
// Get existing site info
|
51 |
if (targetSiteId) {
|
52 |
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}`, {
|
53 |
headers: {
|
54 |
+
Authorization: `Bearer ${token}`,
|
55 |
},
|
56 |
});
|
57 |
+
|
58 |
if (siteResponse.ok) {
|
59 |
+
const existingSite = (await siteResponse.json()) as any;
|
60 |
siteInfo = {
|
61 |
id: existingSite.id,
|
62 |
name: existingSite.name,
|
63 |
url: existingSite.url,
|
64 |
+
chatId,
|
65 |
};
|
66 |
} else {
|
67 |
targetSiteId = undefined;
|
68 |
}
|
69 |
}
|
70 |
+
|
71 |
// If no siteId provided or site doesn't exist, create a new site
|
72 |
if (!targetSiteId) {
|
73 |
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
|
74 |
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
|
75 |
method: 'POST',
|
76 |
headers: {
|
77 |
+
Authorization: `Bearer ${token}`,
|
78 |
'Content-Type': 'application/json',
|
79 |
},
|
80 |
body: JSON.stringify({
|
81 |
name: siteName,
|
82 |
custom_domain: null,
|
83 |
+
}),
|
84 |
});
|
85 |
+
|
86 |
if (!createSiteResponse.ok) {
|
87 |
return json({ error: 'Failed to create site' }, { status: 400 });
|
88 |
}
|
89 |
+
|
90 |
+
const newSite = (await createSiteResponse.json()) as any;
|
91 |
targetSiteId = newSite.id;
|
92 |
siteInfo = {
|
93 |
id: newSite.id,
|
94 |
name: newSite.name,
|
95 |
url: newSite.url,
|
96 |
+
chatId,
|
97 |
};
|
98 |
}
|
99 |
}
|
100 |
|
101 |
// Create file digests
|
102 |
const fileDigests: Record<string, string> = {};
|
103 |
+
|
104 |
for (const [filePath, content] of Object.entries(files)) {
|
105 |
// Ensure file path starts with a forward slash
|
106 |
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
|
|
112 |
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys`, {
|
113 |
method: 'POST',
|
114 |
headers: {
|
115 |
+
Authorization: `Bearer ${token}`,
|
116 |
'Content-Type': 'application/json',
|
117 |
},
|
118 |
body: JSON.stringify({
|
|
|
122 |
draft: false, // Change this to false for production deployments
|
123 |
function_schedules: [],
|
124 |
required: Object.keys(fileDigests), // Add this line
|
125 |
+
framework: null,
|
126 |
+
}),
|
127 |
});
|
128 |
|
129 |
if (!deployResponse.ok) {
|
130 |
return json({ error: 'Failed to create deployment' }, { status: 400 });
|
131 |
}
|
132 |
|
133 |
+
const deploy = (await deployResponse.json()) as any;
|
134 |
let retryCount = 0;
|
135 |
const maxRetries = 60;
|
136 |
|
|
|
138 |
while (retryCount < maxRetries) {
|
139 |
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
|
140 |
headers: {
|
141 |
+
Authorization: `Bearer ${token}`,
|
142 |
},
|
143 |
});
|
144 |
+
|
145 |
+
const status = (await statusResponse.json()) as any;
|
146 |
+
|
147 |
if (status.state === 'prepared' || status.state === 'uploaded') {
|
148 |
// Upload all files regardless of required array
|
149 |
for (const [filePath, content] of Object.entries(files)) {
|
150 |
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
|
151 |
+
|
152 |
let uploadSuccess = false;
|
153 |
let uploadRetries = 0;
|
154 |
+
|
155 |
while (!uploadSuccess && uploadRetries < 3) {
|
156 |
try {
|
157 |
+
const uploadResponse = await fetch(
|
158 |
+
`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${normalizedPath}`,
|
159 |
+
{
|
160 |
+
method: 'PUT',
|
161 |
+
headers: {
|
162 |
+
Authorization: `Bearer ${token}`,
|
163 |
+
'Content-Type': 'application/octet-stream',
|
164 |
+
},
|
165 |
+
body: content,
|
166 |
},
|
167 |
+
);
|
|
|
168 |
|
169 |
uploadSuccess = uploadResponse.ok;
|
170 |
+
|
171 |
if (!uploadSuccess) {
|
172 |
console.error('Upload failed:', await uploadResponse.text());
|
173 |
uploadRetries++;
|
174 |
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
175 |
}
|
176 |
} catch (error) {
|
177 |
console.error('Upload error:', error);
|
178 |
uploadRetries++;
|
179 |
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
180 |
}
|
181 |
}
|
182 |
|
|
|
185 |
}
|
186 |
}
|
187 |
}
|
188 |
+
|
189 |
if (status.state === 'ready') {
|
190 |
// Only return after files are uploaded
|
191 |
if (Object.keys(files).length === 0 || status.summary?.status === 'ready') {
|
192 |
+
return json({
|
193 |
+
success: true,
|
194 |
deploy: {
|
195 |
id: status.id,
|
196 |
state: status.state,
|
197 |
+
url: status.ssl_url || status.url,
|
198 |
},
|
199 |
+
site: siteInfo,
|
200 |
});
|
201 |
}
|
202 |
}
|
|
|
206 |
}
|
207 |
|
208 |
retryCount++;
|
209 |
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
210 |
}
|
211 |
|
212 |
if (retryCount >= maxRetries) {
|
|
|
214 |
}
|
215 |
|
216 |
// Make sure we're returning the deploy ID and site info
|
217 |
+
return json({
|
218 |
+
success: true,
|
219 |
deploy: {
|
220 |
id: deploy.id,
|
221 |
state: deploy.state,
|
222 |
},
|
223 |
+
site: siteInfo,
|
224 |
});
|
225 |
} catch (error) {
|
226 |
console.error('Deploy error:', error);
|
227 |
return json({ error: 'Deployment failed' }, { status: 500 });
|
228 |
}
|
229 |
+
}
|
app/types/netlify.ts
CHANGED
@@ -38,4 +38,4 @@ export interface NetlifySiteInfo {
|
|
38 |
name: string;
|
39 |
url: string;
|
40 |
chatId: string;
|
41 |
-
}
|
|
|
38 |
name: string;
|
39 |
url: string;
|
40 |
chatId: string;
|
41 |
+
}
|
uno.config.ts
CHANGED
@@ -98,9 +98,7 @@ const COLOR_PRIMITIVES = {
|
|
98 |
};
|
99 |
|
100 |
export default defineConfig({
|
101 |
-
safelist: [
|
102 |
-
...Object.keys(customIconCollection[collectionName] || {}).map(x => `i-bolt:${x}`)
|
103 |
-
],
|
104 |
shortcuts: {
|
105 |
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
|
106 |
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
|
|
|
98 |
};
|
99 |
|
100 |
export default defineConfig({
|
101 |
+
safelist: [...Object.keys(customIconCollection[collectionName] || {}).map((x) => `i-bolt:${x}`)],
|
|
|
|
|
102 |
shortcuts: {
|
103 |
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
|
104 |
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
|