mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Merge pull request #5 from vgcman16/codex/add-one-click-deploy-support
Add Cloudflare one-click deploy
This commit is contained in:
@@ -106,6 +106,10 @@ VITE_GITHUB_TOKEN_TYPE=
|
||||
# Netlify Authentication
|
||||
VITE_NETLIFY_ACCESS_TOKEN=
|
||||
|
||||
# Cloudflare Pages Authentication
|
||||
VITE_CLOUDFLARE_API_TOKEN=
|
||||
VITE_CLOUDFLARE_ACCOUNT_ID=
|
||||
|
||||
# Example Context Values for qwen2.5-coder:32b
|
||||
#
|
||||
# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM
|
||||
|
||||
@@ -84,6 +84,8 @@ project, please check the [project management guide](./PROJECT.md) to get starte
|
||||
- ✅ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
|
||||
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
|
||||
- ✅ Deploy directly to Netlify (@xKevIsDev)
|
||||
- ✅ Deploy directly to Vercel
|
||||
- ✅ Deploy directly to Cloudflare Pages
|
||||
- ✅ Supabase Integration (@xKevIsDev)
|
||||
- ⬜ Have LLM plan the project in a MD file for better results/transparency
|
||||
- ✅ VSCode Integration with git-like confirmations
|
||||
@@ -104,7 +106,7 @@ project, please check the [project management guide](./PROJECT.md) to get starte
|
||||
- **Revert code to earlier versions** for easier debugging and quicker changes.
|
||||
- **Download projects as ZIP** for easy portability Sync to a folder on the host.
|
||||
- **Integration-ready Docker support** for a hassle-free setup.
|
||||
- **Deploy** directly to **Netlify**
|
||||
- **Deploy** directly to **Netlify**, **Vercel**, or **Cloudflare Pages**
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
52
app/components/chat/CloudflareDeploymentLink.client.tsx
Normal file
52
app/components/chat/CloudflareDeploymentLink.client.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { cloudflareConnection, fetchCloudflareProjects } from '~/lib/stores/cloudflare';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function CloudflareDeploymentLink() {
|
||||
const connection = useStore(cloudflareConnection);
|
||||
const currentChatId = useStore(chatId);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.token && connection.accountId) {
|
||||
fetchCloudflareProjects(connection.token, connection.accountId);
|
||||
}
|
||||
}, [connection.token, connection.accountId]);
|
||||
|
||||
const project = connection.projects?.find((p) => p.name.includes(`bolt-diy-${currentChatId}`));
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = project.subdomain ? `https://${project.subdomain}` : `https://${project.name}.pages.dev`;
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={url}
|
||||
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-orange-500 z-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:link w-4 h-4 hover:text-blue-400" />
|
||||
</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}
|
||||
>
|
||||
{url}
|
||||
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
134
app/components/deploy/CloudflareDeploy.client.tsx
Normal file
134
app/components/deploy/CloudflareDeploy.client.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { toast } from 'react-toastify';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { cloudflareConnection } from '~/lib/stores/cloudflare';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { webcontainer } from '~/lib/webcontainer';
|
||||
import { path } from '~/utils/path';
|
||||
import { useState } from 'react';
|
||||
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
|
||||
export function useCloudflareDeploy() {
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const cfConn = useStore(cloudflareConnection);
|
||||
const currentChatId = useStore(chatId);
|
||||
|
||||
const handleCloudflareDeploy = async () => {
|
||||
if (!cfConn.accountId || !cfConn.token) {
|
||||
toast.error('Please configure Cloudflare account and token in settings.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentChatId) {
|
||||
toast.error('No active chat found');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeploying(true);
|
||||
|
||||
const artifact = workbenchStore.firstArtifact;
|
||||
if (!artifact) {
|
||||
throw new Error('No active project found');
|
||||
}
|
||||
|
||||
const deploymentId = `deploy-cloudflare`;
|
||||
workbenchStore.addArtifact({
|
||||
id: deploymentId,
|
||||
messageId: deploymentId,
|
||||
title: 'Cloudflare Deployment',
|
||||
type: 'standalone',
|
||||
});
|
||||
const deployArtifact = workbenchStore.artifacts.get()[deploymentId];
|
||||
|
||||
deployArtifact.runner.handleDeployAction('building', 'running', { source: 'cloudflare' });
|
||||
|
||||
const actionId = 'build-' + Date.now();
|
||||
const actionData: ActionCallbackData = {
|
||||
messageId: 'cloudflare build',
|
||||
artifactId: artifact.id,
|
||||
actionId,
|
||||
action: { type: 'build' as const, content: 'npm run build' },
|
||||
};
|
||||
|
||||
artifact.runner.addAction(actionData);
|
||||
await artifact.runner.runAction(actionData);
|
||||
|
||||
if (!artifact.runner.buildOutput) {
|
||||
deployArtifact.runner.handleDeployAction('building', 'failed', { error: 'Build failed', source: 'cloudflare' });
|
||||
throw new Error('Build failed');
|
||||
}
|
||||
|
||||
deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'cloudflare' });
|
||||
|
||||
const container = await webcontainer;
|
||||
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
|
||||
const commonDirs = [buildPath, '/dist', '/build', '/out', '/output', '/.next', '/public'];
|
||||
let finalBuildPath = buildPath;
|
||||
let found = false;
|
||||
for (const dir of commonDirs) {
|
||||
try {
|
||||
await container.fs.readdir(dir);
|
||||
finalBuildPath = dir;
|
||||
found = true;
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
if (!found) throw new Error('Could not find build output directory');
|
||||
|
||||
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');
|
||||
const deployPath = fullPath.replace(finalBuildPath, '');
|
||||
files[deployPath] = content;
|
||||
} else if (entry.isDirectory()) {
|
||||
Object.assign(files, await getAllFiles(fullPath));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const fileContents = await getAllFiles(finalBuildPath);
|
||||
const existingProject = localStorage.getItem(`cloudflare-project-${currentChatId}`);
|
||||
|
||||
const response = await fetch('/api/cloudflare-deploy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectName: existingProject || undefined,
|
||||
files: fileContents,
|
||||
token: cfConn.token,
|
||||
accountId: cfConn.accountId,
|
||||
chatId: currentChatId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.deploy || !data.project) {
|
||||
deployArtifact.runner.handleDeployAction('deploying', 'failed', {
|
||||
error: data.error || 'Invalid deployment response',
|
||||
source: 'cloudflare',
|
||||
});
|
||||
throw new Error(data.error || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
localStorage.setItem(`cloudflare-project-${currentChatId}`, data.project.name);
|
||||
|
||||
deployArtifact.runner.handleDeployAction('complete', 'complete', { url: data.deploy.url, source: 'cloudflare' });
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Cloudflare deploy error:', err);
|
||||
toast.error(err instanceof Error ? err.message : 'Cloudflare deployment failed');
|
||||
return false;
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { isDeploying, handleCloudflareDeploy, isConnected: !!cfConn.token };
|
||||
}
|
||||
@@ -2,14 +2,17 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { netlifyConnection } from '~/lib/stores/netlify';
|
||||
import { vercelConnection } from '~/lib/stores/vercel';
|
||||
import { cloudflareConnection } from '~/lib/stores/cloudflare';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { streamingState } from '~/lib/stores/streaming';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { useState } from 'react';
|
||||
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
|
||||
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
|
||||
import { CloudflareDeploymentLink } from '~/components/chat/CloudflareDeploymentLink.client';
|
||||
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client';
|
||||
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
|
||||
import { useCloudflareDeploy } from '~/components/deploy/CloudflareDeploy.client';
|
||||
|
||||
interface DeployButtonProps {
|
||||
onVercelDeploy?: () => Promise<void>;
|
||||
@@ -19,14 +22,16 @@ interface DeployButtonProps {
|
||||
export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy }: DeployButtonProps) => {
|
||||
const netlifyConn = useStore(netlifyConnection);
|
||||
const vercelConn = useStore(vercelConnection);
|
||||
const cloudflareConn = useStore(cloudflareConnection);
|
||||
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 [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'cloudflare' | null>(null);
|
||||
const isStreaming = useStore(streamingState);
|
||||
const { handleVercelDeploy } = useVercelDeploy();
|
||||
const { handleNetlifyDeploy } = useNetlifyDeploy();
|
||||
const { handleCloudflareDeploy } = useCloudflareDeploy();
|
||||
|
||||
const handleVercelDeployClick = async () => {
|
||||
setIsDeploying(true);
|
||||
@@ -60,6 +65,18 @@ export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy }: DeployButtonPr
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloudflareDeployClick = async () => {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('cloudflare');
|
||||
|
||||
try {
|
||||
await handleCloudflareDeploy();
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden text-sm">
|
||||
<DropdownMenu.Root>
|
||||
@@ -126,8 +143,15 @@ export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy }: DeployButtonPr
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
disabled
|
||||
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2 opacity-60 cursor-not-allowed"
|
||||
className={classNames(
|
||||
'cursor-pointer 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',
|
||||
{
|
||||
'opacity-60 cursor-not-allowed':
|
||||
isDeploying || !activePreview || !cloudflareConn.token || !cloudflareConn.accountId,
|
||||
},
|
||||
)}
|
||||
disabled={isDeploying || !activePreview || !cloudflareConn.token || !cloudflareConn.accountId}
|
||||
onClick={handleCloudflareDeployClick}
|
||||
>
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
@@ -137,7 +161,8 @@ export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy }: DeployButtonPr
|
||||
src="https://cdn.simpleicons.org/cloudflare"
|
||||
alt="cloudflare"
|
||||
/>
|
||||
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
|
||||
<span className="mx-auto">{!cloudflareConn.token ? 'No Cloudflare Token' : 'Deploy to Cloudflare'}</span>
|
||||
{cloudflareConn.token && cloudflareConn.accountId && <CloudflareDeploymentLink />}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
@@ -522,7 +522,7 @@ export class ActionRunner {
|
||||
details?: {
|
||||
url?: string;
|
||||
error?: string;
|
||||
source?: 'netlify' | 'vercel' | 'github';
|
||||
source?: 'netlify' | 'vercel' | 'cloudflare' | 'github';
|
||||
},
|
||||
): void {
|
||||
if (!this.onDeployAlert) {
|
||||
|
||||
43
app/lib/stores/cloudflare.ts
Normal file
43
app/lib/stores/cloudflare.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { atom } from 'nanostores';
|
||||
import type { CloudflareConnection, CloudflareProject } from '~/types/cloudflare';
|
||||
import { logStore } from './logs';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('cloudflare_connection') : null;
|
||||
|
||||
const envToken = import.meta.env.VITE_CLOUDFLARE_API_TOKEN;
|
||||
const envAccountId = import.meta.env.VITE_CLOUDFLARE_ACCOUNT_ID;
|
||||
|
||||
const initialConnection: CloudflareConnection = storedConnection
|
||||
? JSON.parse(storedConnection)
|
||||
: { accountId: envAccountId || '', token: envToken || '', projects: undefined };
|
||||
|
||||
export const cloudflareConnection = atom<CloudflareConnection>(initialConnection);
|
||||
export const isConnecting = atom<boolean>(false);
|
||||
|
||||
export const updateCloudflareConnection = (updates: Partial<CloudflareConnection>) => {
|
||||
const current = cloudflareConnection.get();
|
||||
const newState = { ...current, ...updates };
|
||||
cloudflareConnection.set(newState);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('cloudflare_connection', JSON.stringify(newState));
|
||||
}
|
||||
};
|
||||
|
||||
export async function fetchCloudflareProjects(token: string, accountId: string) {
|
||||
try {
|
||||
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch projects: ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as any;
|
||||
updateCloudflareConnection({ projects: data.result as CloudflareProject[] });
|
||||
} catch (error) {
|
||||
console.error('Cloudflare API Error:', error);
|
||||
logStore.logError('Failed to fetch Cloudflare projects', { error });
|
||||
toast.error('Failed to fetch Cloudflare projects');
|
||||
}
|
||||
}
|
||||
70
app/routes/api.cloudflare-deploy.ts
Normal file
70
app/routes/api.cloudflare-deploy.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
interface DeployRequestBody {
|
||||
accountId: string;
|
||||
projectName?: string;
|
||||
files: Record<string, string>;
|
||||
chatId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const { accountId, projectName, files, token, chatId } = (await request.json()) as DeployRequestBody;
|
||||
|
||||
if (!accountId || !token) {
|
||||
return json({ error: 'Missing Cloudflare credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
let targetProject = projectName;
|
||||
|
||||
if (!targetProject) {
|
||||
const name = `bolt-diy-${chatId}-${Date.now()}`;
|
||||
const createRes = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const txt = await createRes.text();
|
||||
return json({ error: `Failed to create project: ${txt}` }, { status: 400 });
|
||||
}
|
||||
targetProject = name;
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
for (const [filePath, content] of Object.entries(files)) {
|
||||
const normalized = filePath.startsWith('/') ? filePath.substring(1) : filePath;
|
||||
zip.file(normalized, content);
|
||||
}
|
||||
const zipData = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
|
||||
const form = new FormData();
|
||||
form.append('metadata', JSON.stringify({}));
|
||||
form.append('file', new Blob([zipData]), 'deploy.zip');
|
||||
|
||||
const deployRes = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/${targetProject}/deployments`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: form as any,
|
||||
},
|
||||
);
|
||||
|
||||
const deployData = await deployRes.json();
|
||||
if (!deployRes.ok) {
|
||||
return json({ error: deployData.errors?.[0]?.message || 'Failed to deploy' }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
deploy: { id: deployData.result.id, url: deployData.result.url },
|
||||
project: { name: targetProject, id: deployData.result.project_id },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Cloudflare deploy error:', error);
|
||||
return json({ error: 'Deployment failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export interface DeployAlert {
|
||||
stage?: 'building' | 'deploying' | 'complete';
|
||||
buildStatus?: 'pending' | 'running' | 'complete' | 'failed';
|
||||
deployStatus?: 'pending' | 'running' | 'complete' | 'failed';
|
||||
source?: 'vercel' | 'netlify' | 'github';
|
||||
source?: 'vercel' | 'netlify' | 'cloudflare' | 'github';
|
||||
}
|
||||
|
||||
export interface FileHistory {
|
||||
|
||||
11
app/types/cloudflare.ts
Normal file
11
app/types/cloudflare.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface CloudflareProject {
|
||||
id: string;
|
||||
name: string;
|
||||
subdomain?: string;
|
||||
}
|
||||
|
||||
export interface CloudflareConnection {
|
||||
accountId: string;
|
||||
token: string;
|
||||
projects?: CloudflareProject[];
|
||||
}
|
||||
@@ -40,6 +40,7 @@ Also [this pinned post in our community](https://thinktank.ottomator.ai/t/videos
|
||||
- **Revert code to earlier versions** for easier debugging and quicker changes.
|
||||
- **Download projects as ZIP** for easy portability.
|
||||
- **Integration-ready Docker support** for a hassle-free setup.
|
||||
- **One-click deployment** to **Netlify**, **Vercel**, or **Cloudflare Pages**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user