Merge pull request #5 from vgcman16/codex/add-one-click-deploy-support

Add Cloudflare one-click deploy
This commit is contained in:
vgcman16
2025-06-05 16:31:09 -05:00
committed by GitHub
11 changed files with 349 additions and 7 deletions

View File

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

View File

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

View 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>
);
}

View 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 };
}

View File

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

View File

@@ -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) {

View 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');
}
}

View 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 });
}
}

View File

@@ -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
View File

@@ -0,0 +1,11 @@
export interface CloudflareProject {
id: string;
name: string;
subdomain?: string;
}
export interface CloudflareConnection {
accountId: string;
token: string;
projects?: CloudflareProject[];
}

View File

@@ -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**.
---