mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Merge pull request #1559 from xKevIsDev/main
feat: add Vercel integration for project deployment
This commit is contained in:
commit
53a674dc58
@ -3,6 +3,7 @@ import React, { Suspense, useState } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import ConnectionDiagnostics from './ConnectionDiagnostics';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import VercelConnection from './VercelConnection';
|
||||
|
||||
// Use React.lazy for dynamic imports
|
||||
const GitHubConnection = React.lazy(() => import('./GithubConnection'));
|
||||
@ -155,6 +156,9 @@ export default function ConnectionsTab() {
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<NetlifyConnection />
|
||||
</Suspense>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<VercelConnection />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Additional help text */}
|
||||
|
289
app/components/@settings/tabs/connections/VercelConnection.tsx
Normal file
289
app/components/@settings/tabs/connections/VercelConnection.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import {
|
||||
vercelConnection,
|
||||
isConnecting,
|
||||
isFetchingStats,
|
||||
updateVercelConnection,
|
||||
fetchVercelStats,
|
||||
} from '~/lib/stores/vercel';
|
||||
|
||||
export default function VercelConnection() {
|
||||
const connection = useStore(vercelConnection);
|
||||
const connecting = useStore(isConnecting);
|
||||
const fetchingStats = useStore(isFetchingStats);
|
||||
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjects = async () => {
|
||||
if (connection.user && connection.token) {
|
||||
await fetchVercelStats(connection.token);
|
||||
}
|
||||
};
|
||||
fetchProjects();
|
||||
}, [connection.user, connection.token]);
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
isConnecting.set(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.vercel.com/v2/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Invalid token or unauthorized');
|
||||
}
|
||||
|
||||
const userData = (await response.json()) as any;
|
||||
updateVercelConnection({
|
||||
user: userData.user || userData, // Handle both possible structures
|
||||
token: connection.token,
|
||||
});
|
||||
|
||||
await fetchVercelStats(connection.token);
|
||||
toast.success('Successfully connected to Vercel');
|
||||
} catch (error) {
|
||||
console.error('Auth error:', error);
|
||||
logStore.logError('Failed to authenticate with Vercel', { error });
|
||||
toast.error('Failed to connect to Vercel');
|
||||
updateVercelConnection({ user: null, token: '' });
|
||||
} finally {
|
||||
isConnecting.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
updateVercelConnection({ user: null, token: '' });
|
||||
toast.success('Disconnected from Vercel');
|
||||
};
|
||||
|
||||
console.log('connection', connection);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
className="w-5 h-5 dark:invert"
|
||||
height="24"
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src={`https://cdn.simpleicons.org/vercel/black`}
|
||||
/>
|
||||
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Vercel Connection</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!connection.user ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={connection.token}
|
||||
onChange={(e) => updateVercelConnection({ ...connection, token: e.target.value })}
|
||||
disabled={connecting}
|
||||
placeholder="Enter your Vercel personal access token"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm',
|
||||
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
||||
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
<a
|
||||
href="https://vercel.com/account/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Get your token
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !connection.token}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-bolt-elements-borderColor text-white',
|
||||
'hover:bg-bolt-elements-borderColorActive',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{connecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug w-4 h-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
||||
Connected to Vercel
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
||||
{/* Debug output */}
|
||||
<pre className="hidden">{JSON.stringify(connection.user, null, 2)}</pre>
|
||||
|
||||
<img
|
||||
src={`https://vercel.com/api/www/avatar?u=${connection.user?.username || connection.user?.user?.username}`}
|
||||
referrerPolicy="no-referrer"
|
||||
crossOrigin="anonymous"
|
||||
alt="User Avatar"
|
||||
className="w-12 h-12 rounded-full border-2 border-bolt-elements-borderColorActive"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
{connection.user?.username || connection.user?.user?.username || 'Vercel User'}
|
||||
</h4>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
{connection.user?.email || connection.user?.user?.email || 'No email available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fetchingStats ? (
|
||||
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
||||
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
||||
Fetching Vercel projects...
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
|
||||
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
|
||||
>
|
||||
<div className="i-ph:buildings w-4 h-4" />
|
||||
Your Projects ({connection.stats?.totalProjects || 0})
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
|
||||
isProjectsExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isProjectsExpanded && connection.stats?.projects?.length ? (
|
||||
<div className="grid gap-3">
|
||||
{connection.stats.projects.map((project) => (
|
||||
<a
|
||||
key={project.id}
|
||||
href={`https://vercel.com/dashboard/${project.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block p-4 rounded-lg border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
||||
<div className="i-ph:globe w-4 h-4 text-bolt-elements-borderColorActive" />
|
||||
{project.name}
|
||||
</h5>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
||||
{project.targets?.production?.alias && project.targets.production.alias.length > 0 ? (
|
||||
<>
|
||||
<a
|
||||
href={`https://${project.targets.production.alias.find((a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-bolt-elements-borderColorActive"
|
||||
>
|
||||
{project.targets.production.alias.find(
|
||||
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
|
||||
) || project.targets.production.alias[0]}
|
||||
</a>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:clock w-3 h-3" />
|
||||
{new Date(project.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</>
|
||||
) : project.latestDeployments && project.latestDeployments.length > 0 ? (
|
||||
<>
|
||||
<a
|
||||
href={`https://${project.latestDeployments[0].url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-bolt-elements-borderColorActive"
|
||||
>
|
||||
{project.latestDeployments[0].url}
|
||||
</a>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:clock w-3 h-3" />
|
||||
{new Date(project.latestDeployments[0].created).toLocaleDateString()}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{project.framework && (
|
||||
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:code w-3 h-3" />
|
||||
{project.framework}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : isProjectsExpanded ? (
|
||||
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info w-4 h-4" />
|
||||
No projects found in your Vercel account
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
@ -30,10 +30,10 @@ export function NetlifyDeploymentLink() {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textSecondary hover:text-[#00AD9F] z-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Add this to prevent click from bubbling up
|
||||
e.stopPropagation(); // This is to prevent click from bubbling up
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:rocket-launch w-5 h-5" />
|
||||
<div className="i-ph:link w-4 h-4 hover:text-blue-400" />
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
|
158
app/components/chat/VercelDeploymentLink.client.tsx
Normal file
158
app/components/chat/VercelDeploymentLink.client.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { vercelConnection } from '~/lib/stores/vercel';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function VercelDeploymentLink() {
|
||||
const connection = useStore(vercelConnection);
|
||||
const currentChatId = useStore(chatId);
|
||||
const [deploymentUrl, setDeploymentUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProjectData() {
|
||||
if (!connection.token || !currentChatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have a stored project ID for this chat
|
||||
const projectId = localStorage.getItem(`vercel-project-${currentChatId}`);
|
||||
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Fetch projects directly from the API
|
||||
const projectsResponse = await fetch('https://api.vercel.com/v9/projects', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!projectsResponse.ok) {
|
||||
throw new Error(`Failed to fetch projects: ${projectsResponse.status}`);
|
||||
}
|
||||
|
||||
const projectsData = (await projectsResponse.json()) as any;
|
||||
const projects = projectsData.projects || [];
|
||||
|
||||
// Extract the chat number from currentChatId
|
||||
const chatNumber = currentChatId.split('-')[0];
|
||||
|
||||
// Find project by matching the chat number in the name
|
||||
const project = projects.find((p: { name: string | string[] }) => p.name.includes(`bolt-diy-${chatNumber}`));
|
||||
|
||||
if (project) {
|
||||
// Fetch project details including deployments
|
||||
const projectDetailsResponse = await fetch(`https://api.vercel.com/v9/projects/${project.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (projectDetailsResponse.ok) {
|
||||
const projectDetails = (await projectDetailsResponse.json()) as any;
|
||||
|
||||
// Try to get URL from production aliases first
|
||||
if (projectDetails.targets?.production?.alias && projectDetails.targets.production.alias.length > 0) {
|
||||
// Find the clean URL (without -projects.vercel.app)
|
||||
const cleanUrl = projectDetails.targets.production.alias.find(
|
||||
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
|
||||
);
|
||||
|
||||
if (cleanUrl) {
|
||||
setDeploymentUrl(`https://${cleanUrl}`);
|
||||
return;
|
||||
} else {
|
||||
// If no clean URL found, use the first alias
|
||||
setDeploymentUrl(`https://${projectDetails.targets.production.alias[0]}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no aliases or project details failed, try fetching deployments
|
||||
const deploymentsResponse = await fetch(
|
||||
`https://api.vercel.com/v6/deployments?projectId=${project.id}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
},
|
||||
);
|
||||
|
||||
if (deploymentsResponse.ok) {
|
||||
const deploymentsData = (await deploymentsResponse.json()) as any;
|
||||
|
||||
if (deploymentsData.deployments && deploymentsData.deployments.length > 0) {
|
||||
setDeploymentUrl(`https://${deploymentsData.deployments[0].url}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to API call if not found in fetched projects
|
||||
const fallbackResponse = await fetch(`/api/vercel-deploy?projectId=${projectId}&token=${connection.token}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const data = await fallbackResponse.json();
|
||||
|
||||
if ((data as { deploy?: { url?: string } }).deploy?.url) {
|
||||
setDeploymentUrl((data as { deploy: { url: string } }).deploy.url);
|
||||
} else if ((data as { project?: { url?: string } }).project?.url) {
|
||||
setDeploymentUrl((data as { project: { url: string } }).project.url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching Vercel deployment:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProjectData();
|
||||
}, [connection.token, currentChatId]);
|
||||
|
||||
if (!deploymentUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={deploymentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textSecondary hover:text-[#000000] z-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className={`i-ph:link w-4 h-4 hover:text-blue-400 ${isLoading ? 'animate-pulse' : ''}`} />
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-3 py-2 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary text-xs z-50"
|
||||
sideOffset={5}
|
||||
>
|
||||
{deploymentUrl}
|
||||
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
@ -25,6 +25,7 @@ import { BinaryContent } from './BinaryContent';
|
||||
import { getTheme, reconfigureTheme } from './cm-theme';
|
||||
import { indentKeyBinding } from './indent';
|
||||
import { getLanguage } from './languages';
|
||||
import { createEnvMaskingExtension } from './EnvMasking';
|
||||
|
||||
const logger = createScopedLogger('CodeMirrorEditor');
|
||||
|
||||
@ -134,6 +135,9 @@ export const CodeMirrorEditor = memo(
|
||||
|
||||
const [languageCompartment] = useState(new Compartment());
|
||||
|
||||
// Add a compartment for the env masking extension
|
||||
const [envMaskingCompartment] = useState(new Compartment());
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView>();
|
||||
const themeRef = useRef<Theme>();
|
||||
@ -214,6 +218,7 @@ export const CodeMirrorEditor = memo(
|
||||
if (!doc) {
|
||||
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
||||
languageCompartment.of([]),
|
||||
envMaskingCompartment.of([]),
|
||||
]);
|
||||
|
||||
view.setState(state);
|
||||
@ -236,6 +241,7 @@ export const CodeMirrorEditor = memo(
|
||||
if (!state) {
|
||||
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
||||
languageCompartment.of([]),
|
||||
envMaskingCompartment.of([createEnvMaskingExtension(() => docRef.current?.filePath)]),
|
||||
]);
|
||||
|
||||
editorStates.set(doc.filePath, state);
|
||||
|
80
app/components/editor/codemirror/EnvMasking.ts
Normal file
80
app/components/editor/codemirror/EnvMasking.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { EditorView, Decoration, type DecorationSet, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
|
||||
// Create a proper WidgetType class for the masked text
|
||||
class MaskedTextWidget extends WidgetType {
|
||||
constructor(private readonly _value: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: MaskedTextWidget) {
|
||||
return other._value === this._value;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = '*'.repeat(this._value.length);
|
||||
span.className = 'cm-masked-text';
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createEnvMaskingExtension(getFilePath: () => string | undefined) {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: { docChanged: boolean; view: EditorView; viewportChanged: boolean }) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
buildDecorations(view: EditorView) {
|
||||
const filePath = getFilePath();
|
||||
const isEnvFile = filePath?.endsWith('.env') || filePath?.includes('.env.') || filePath?.includes('/.env');
|
||||
|
||||
if (!isEnvFile) {
|
||||
return Decoration.none;
|
||||
}
|
||||
|
||||
const decorations: any[] = [];
|
||||
const doc = view.state.doc;
|
||||
|
||||
for (let i = 1; i <= doc.lines; i++) {
|
||||
const line = doc.line(i);
|
||||
const text = line.text;
|
||||
|
||||
// Match lines with KEY=VALUE format
|
||||
const match = text.match(/^([^=]+)=(.+)$/);
|
||||
|
||||
if (match && !text.trim().startsWith('#')) {
|
||||
const [, key, value] = match;
|
||||
const valueStart = line.from + key.length + 1;
|
||||
|
||||
// Create a decoration that replaces the value with asterisks
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
inclusive: true,
|
||||
widget: new MaskedTextWidget(value),
|
||||
}).range(valueStart, line.to),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(decorations);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
},
|
||||
);
|
||||
}
|
@ -3,26 +3,30 @@ import { toast } from 'react-toastify';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { netlifyConnection } from '~/lib/stores/netlify';
|
||||
import { vercelConnection } from '~/lib/stores/vercel';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { webcontainer } from '~/lib/webcontainer';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { path } from '~/utils/path';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory'; // Add this import
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
import { streamingState } from '~/lib/stores/streaming';
|
||||
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
|
||||
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
|
||||
|
||||
interface HeaderActionButtonsProps {}
|
||||
|
||||
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||
const { showChat } = useStore(chatStore);
|
||||
const connection = useStore(netlifyConnection);
|
||||
const netlifyConn = useStore(netlifyConnection);
|
||||
const vercelConn = useStore(vercelConnection);
|
||||
const [activePreviewIndex] = useState(0);
|
||||
const previews = useStore(workbenchStore.previews);
|
||||
const activePreview = previews[activePreviewIndex];
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | null>(null);
|
||||
const isSmallViewport = useViewport(1024);
|
||||
const canHideChat = showWorkbench || !showChat;
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
@ -42,8 +46,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
|
||||
const currentChatId = useStore(chatId);
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!connection.user || !connection.token) {
|
||||
const handleNetlifyDeploy = async () => {
|
||||
if (!netlifyConn.user || !netlifyConn.token) {
|
||||
toast.error('Please connect to Netlify first in the settings tab!');
|
||||
return;
|
||||
}
|
||||
@ -118,7 +122,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`);
|
||||
|
||||
// Deploy using the API route with file contents
|
||||
const response = await fetch('/api/deploy', {
|
||||
const response = await fetch('/api/netlify-deploy', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -126,7 +130,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
body: JSON.stringify({
|
||||
siteId: existingSiteId || undefined,
|
||||
files: fileContents,
|
||||
token: connection.token,
|
||||
token: netlifyConn.token,
|
||||
chatId: currentChatId, // Use chatId instead of artifact.id
|
||||
}),
|
||||
});
|
||||
@ -149,7 +153,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
`https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.token}`,
|
||||
Authorization: `Bearer ${netlifyConn.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -203,6 +207,125 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVercelDeploy = async () => {
|
||||
if (!vercelConn.user || !vercelConn.token) {
|
||||
toast.error('Please connect to Vercel first in the settings tab!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentChatId) {
|
||||
toast.error('No active chat found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('vercel');
|
||||
|
||||
const artifact = workbenchStore.firstArtifact;
|
||||
|
||||
if (!artifact) {
|
||||
throw new Error('No active project found');
|
||||
}
|
||||
|
||||
const actionId = 'build-' + Date.now();
|
||||
const actionData: ActionCallbackData = {
|
||||
messageId: 'vercel build',
|
||||
artifactId: artifact.id,
|
||||
actionId,
|
||||
action: {
|
||||
type: 'build' as const,
|
||||
content: 'npm run build',
|
||||
},
|
||||
};
|
||||
|
||||
// Add the action first
|
||||
artifact.runner.addAction(actionData);
|
||||
|
||||
// Then run it
|
||||
await artifact.runner.runAction(actionData);
|
||||
|
||||
if (!artifact.runner.buildOutput) {
|
||||
throw new Error('Build failed');
|
||||
}
|
||||
|
||||
// Get the build files
|
||||
const container = await webcontainer;
|
||||
|
||||
// Remove /home/project from buildPath if it exists
|
||||
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
|
||||
|
||||
// Get all files recursively
|
||||
async function getAllFiles(dirPath: string): Promise<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 /dist prefix from the path
|
||||
const deployPath = fullPath.replace(buildPath, '');
|
||||
files[deployPath] = content;
|
||||
} else if (entry.isDirectory()) {
|
||||
const subFiles = await getAllFiles(fullPath);
|
||||
Object.assign(files, subFiles);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
const fileContents = await getAllFiles(buildPath);
|
||||
|
||||
// Use chatId instead of artifact.id
|
||||
const existingProjectId = localStorage.getItem(`vercel-project-${currentChatId}`);
|
||||
|
||||
// Deploy using the API route with file contents
|
||||
const response = await fetch('/api/vercel-deploy', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: existingProjectId || undefined,
|
||||
files: fileContents,
|
||||
token: vercelConn.token,
|
||||
chatId: currentChatId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as any;
|
||||
|
||||
if (!response.ok || !data.deploy || !data.project) {
|
||||
console.error('Invalid deploy response:', data);
|
||||
throw new Error(data.error || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
// Store the project ID if it's a new project
|
||||
if (data.project) {
|
||||
localStorage.setItem(`vercel-project-${currentChatId}`, data.project.id);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
Deployed successfully to Vercel!{' '}
|
||||
<a href={data.deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
View site
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Vercel deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Vercel deployment failed');
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
@ -213,7 +336,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2"
|
||||
>
|
||||
{isDeploying ? 'Deploying...' : 'Deploy'}
|
||||
{isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
|
||||
<div
|
||||
className={classNames('i-ph:caret-down w-4 h-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')}
|
||||
/>
|
||||
@ -225,10 +348,10 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
<Button
|
||||
active
|
||||
onClick={() => {
|
||||
handleDeploy();
|
||||
handleNetlifyDeploy();
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying || !activePreview || !connection.user}
|
||||
disabled={isDeploying || !activePreview || !netlifyConn.user}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative"
|
||||
>
|
||||
<img
|
||||
@ -238,15 +361,20 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/netlify"
|
||||
/>
|
||||
<span className="mx-auto">{!connection.user ? 'No Account Connected' : 'Deploy to Netlify'}</span>
|
||||
{connection.user && <NetlifyDeploymentLink />}
|
||||
<span className="mx-auto">
|
||||
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
|
||||
</span>
|
||||
{netlifyConn.user && <NetlifyDeploymentLink />}
|
||||
</Button>
|
||||
<Button
|
||||
active={false}
|
||||
disabled
|
||||
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2"
|
||||
active
|
||||
onClick={() => {
|
||||
handleVercelDeploy();
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying || !activePreview || !vercelConn.user}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative"
|
||||
>
|
||||
<span className="sr-only">Coming Soon</span>
|
||||
<img
|
||||
className="w-5 h-5 bg-black p-1 rounded"
|
||||
height="24"
|
||||
@ -255,7 +383,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
src="https://cdn.simpleicons.org/vercel/white"
|
||||
alt="vercel"
|
||||
/>
|
||||
<span className="mx-auto">Deploy to Vercel (Coming Soon)</span>
|
||||
<span className="mx-auto">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
|
||||
{vercelConn.user && <VercelDeploymentLink />}
|
||||
</Button>
|
||||
<Button
|
||||
active={false}
|
||||
@ -269,7 +398,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
width="24"
|
||||
crossOrigin="anonymous"
|
||||
src="https://cdn.simpleicons.org/cloudflare"
|
||||
alt="vercel"
|
||||
alt="cloudflare"
|
||||
/>
|
||||
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
|
||||
</Button>
|
||||
|
94
app/lib/stores/vercel.ts
Normal file
94
app/lib/stores/vercel.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { atom } from 'nanostores';
|
||||
import type { VercelConnection } from '~/types/vercel';
|
||||
import { logStore } from './logs';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
// Initialize with stored connection or defaults
|
||||
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('vercel_connection') : null;
|
||||
const initialConnection: VercelConnection = storedConnection
|
||||
? JSON.parse(storedConnection)
|
||||
: {
|
||||
user: null,
|
||||
token: '',
|
||||
stats: undefined,
|
||||
};
|
||||
|
||||
export const vercelConnection = atom<VercelConnection>(initialConnection);
|
||||
export const isConnecting = atom<boolean>(false);
|
||||
export const isFetchingStats = atom<boolean>(false);
|
||||
|
||||
export const updateVercelConnection = (updates: Partial<VercelConnection>) => {
|
||||
const currentState = vercelConnection.get();
|
||||
const newState = { ...currentState, ...updates };
|
||||
vercelConnection.set(newState);
|
||||
|
||||
// Persist to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('vercel_connection', JSON.stringify(newState));
|
||||
}
|
||||
};
|
||||
|
||||
export async function fetchVercelStats(token: string) {
|
||||
try {
|
||||
isFetchingStats.set(true);
|
||||
|
||||
const projectsResponse = await fetch('https://api.vercel.com/v9/projects', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectsResponse.ok) {
|
||||
throw new Error(`Failed to fetch projects: ${projectsResponse.status}`);
|
||||
}
|
||||
|
||||
const projectsData = (await projectsResponse.json()) as any;
|
||||
const projects = projectsData.projects || [];
|
||||
|
||||
// Fetch latest deployment for each project
|
||||
const projectsWithDeployments = await Promise.all(
|
||||
projects.map(async (project: any) => {
|
||||
try {
|
||||
const deploymentsResponse = await fetch(
|
||||
`https://api.vercel.com/v6/deployments?projectId=${project.id}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (deploymentsResponse.ok) {
|
||||
const deploymentsData = (await deploymentsResponse.json()) as any;
|
||||
return {
|
||||
...project,
|
||||
latestDeployments: deploymentsData.deployments || [],
|
||||
};
|
||||
}
|
||||
|
||||
return project;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching deployments for project ${project.id}:`, error);
|
||||
return project;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const currentState = vercelConnection.get();
|
||||
updateVercelConnection({
|
||||
...currentState,
|
||||
stats: {
|
||||
projects: projectsWithDeployments,
|
||||
totalProjects: projectsWithDeployments.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Vercel API Error:', error);
|
||||
logStore.logError('Failed to fetch Vercel stats', { error });
|
||||
toast.error('Failed to fetch Vercel statistics');
|
||||
} finally {
|
||||
isFetchingStats.set(false);
|
||||
}
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import { json } from '@remix-run/node';
|
||||
import type { ActionFunction } from '@remix-run/node';
|
||||
import { json, type ActionFunction } from '@remix-run/cloudflare';
|
||||
import type { SupabaseProject } from '~/types/supabase';
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { json } from '@remix-run/node';
|
||||
import type { ActionFunctionArgs } from '@remix-run/node';
|
||||
import { json, type ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
|
248
app/routes/api.vercel-deploy.ts
Normal file
248
app/routes/api.vercel-deploy.ts
Normal file
@ -0,0 +1,248 @@
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from '@remix-run/cloudflare';
|
||||
import type { VercelProjectInfo } from '~/types/vercel';
|
||||
|
||||
// Add loader function to handle GET requests
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const projectId = url.searchParams.get('projectId');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!projectId || !token) {
|
||||
return json({ error: 'Missing projectId or token' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get project info
|
||||
const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${projectId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectResponse.ok) {
|
||||
return json({ error: 'Failed to fetch project' }, { status: 400 });
|
||||
}
|
||||
|
||||
const projectData = (await projectResponse.json()) as any;
|
||||
|
||||
// Get latest deployment
|
||||
const deploymentsResponse = await fetch(`https://api.vercel.com/v6/deployments?projectId=${projectId}&limit=1`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!deploymentsResponse.ok) {
|
||||
return json({ error: 'Failed to fetch deployments' }, { status: 400 });
|
||||
}
|
||||
|
||||
const deploymentsData = (await deploymentsResponse.json()) as any;
|
||||
|
||||
const latestDeployment = deploymentsData.deployments?.[0];
|
||||
|
||||
return json({
|
||||
project: {
|
||||
id: projectData.id,
|
||||
name: projectData.name,
|
||||
url: `https://${projectData.name}.vercel.app`,
|
||||
},
|
||||
deploy: latestDeployment
|
||||
? {
|
||||
id: latestDeployment.id,
|
||||
state: latestDeployment.state,
|
||||
url: latestDeployment.url ? `https://${latestDeployment.url}` : `https://${projectData.name}.vercel.app`,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching Vercel deployment:', error);
|
||||
return json({ error: 'Failed to fetch deployment' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
interface DeployRequestBody {
|
||||
projectId?: string;
|
||||
files: Record<string, string>;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
// Existing action function for POST requests
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const { projectId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string };
|
||||
|
||||
if (!token) {
|
||||
return json({ error: 'Not connected to Vercel' }, { status: 401 });
|
||||
}
|
||||
|
||||
let targetProjectId = projectId;
|
||||
let projectInfo: VercelProjectInfo | undefined;
|
||||
|
||||
// If no projectId provided, create a new project
|
||||
if (!targetProjectId) {
|
||||
const projectName = `bolt-diy-${chatId}-${Date.now()}`;
|
||||
const createProjectResponse = await fetch('https://api.vercel.com/v9/projects', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: projectName,
|
||||
framework: null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!createProjectResponse.ok) {
|
||||
const errorData = (await createProjectResponse.json()) as any;
|
||||
return json(
|
||||
{ error: `Failed to create project: ${errorData.error?.message || 'Unknown error'}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const newProject = (await createProjectResponse.json()) as any;
|
||||
targetProjectId = newProject.id;
|
||||
projectInfo = {
|
||||
id: newProject.id,
|
||||
name: newProject.name,
|
||||
url: `https://${newProject.name}.vercel.app`,
|
||||
chatId,
|
||||
};
|
||||
} else {
|
||||
// Get existing project info
|
||||
const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${targetProjectId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (projectResponse.ok) {
|
||||
const existingProject = (await projectResponse.json()) as any;
|
||||
projectInfo = {
|
||||
id: existingProject.id,
|
||||
name: existingProject.name,
|
||||
url: `https://${existingProject.name}.vercel.app`,
|
||||
chatId,
|
||||
};
|
||||
} else {
|
||||
// If project doesn't exist, create a new one
|
||||
const projectName = `bolt-diy-${chatId}-${Date.now()}`;
|
||||
const createProjectResponse = await fetch('https://api.vercel.com/v9/projects', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: projectName,
|
||||
framework: null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!createProjectResponse.ok) {
|
||||
const errorData = (await createProjectResponse.json()) as any;
|
||||
return json(
|
||||
{ error: `Failed to create project: ${errorData.error?.message || 'Unknown error'}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const newProject = (await createProjectResponse.json()) as any;
|
||||
targetProjectId = newProject.id;
|
||||
projectInfo = {
|
||||
id: newProject.id,
|
||||
name: newProject.name,
|
||||
url: `https://${newProject.name}.vercel.app`,
|
||||
chatId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare files for deployment
|
||||
const deploymentFiles = [];
|
||||
|
||||
for (const [filePath, content] of Object.entries(files)) {
|
||||
// Ensure file path doesn't start with a slash for Vercel
|
||||
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
|
||||
deploymentFiles.push({
|
||||
file: normalizedPath,
|
||||
data: content,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new deployment
|
||||
const deployResponse = await fetch(`https://api.vercel.com/v13/deployments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: projectInfo.name,
|
||||
project: targetProjectId,
|
||||
target: 'production',
|
||||
files: deploymentFiles,
|
||||
routes: [{ src: '/(.*)', dest: '/$1' }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!deployResponse.ok) {
|
||||
const errorData = (await deployResponse.json()) as any;
|
||||
return json(
|
||||
{ error: `Failed to create deployment: ${errorData.error?.message || 'Unknown error'}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const deployData = (await deployResponse.json()) as any;
|
||||
|
||||
// Poll for deployment status
|
||||
let retryCount = 0;
|
||||
const maxRetries = 60;
|
||||
let deploymentUrl = '';
|
||||
let deploymentState = '';
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
const statusResponse = await fetch(`https://api.vercel.com/v13/deployments/${deployData.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (statusResponse.ok) {
|
||||
const status = (await statusResponse.json()) as any;
|
||||
deploymentState = status.readyState;
|
||||
deploymentUrl = status.url ? `https://${status.url}` : '';
|
||||
|
||||
if (status.readyState === 'READY' || status.readyState === 'ERROR') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
retryCount++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
if (deploymentState === 'ERROR') {
|
||||
return json({ error: 'Deployment failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
return json({ error: 'Deployment timed out' }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
deploy: {
|
||||
id: deployData.id,
|
||||
state: deploymentState,
|
||||
url: deploymentUrl || projectInfo.url,
|
||||
},
|
||||
project: projectInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Vercel deploy error:', error);
|
||||
return json({ error: 'Deployment failed' }, { status: 500 });
|
||||
}
|
||||
}
|
40
app/types/vercel.ts
Normal file
40
app/types/vercel.ts
Normal file
@ -0,0 +1,40 @@
|
||||
export interface VercelUser {
|
||||
user: any;
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface VercelProject {
|
||||
createdAt: string | number | Date;
|
||||
targets: any;
|
||||
id: string;
|
||||
name: string;
|
||||
framework?: string;
|
||||
latestDeployments?: Array<{
|
||||
id: string;
|
||||
url: string;
|
||||
created: number;
|
||||
state: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface VercelStats {
|
||||
projects: VercelProject[];
|
||||
totalProjects: number;
|
||||
}
|
||||
|
||||
export interface VercelConnection {
|
||||
user: VercelUser | null;
|
||||
token: string;
|
||||
stats?: VercelStats;
|
||||
}
|
||||
|
||||
export interface VercelProjectInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
chatId: string;
|
||||
}
|
Loading…
Reference in New Issue
Block a user