diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx
index fb2f49e9f..d8153f5fd 100644
--- a/app/components/workbench/Workbench.client.tsx
+++ b/app/components/workbench/Workbench.client.tsx
@@ -158,6 +158,33 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
{isSyncing ?
: }
{isSyncing ? 'Syncing...' : 'Sync Files'}
+ {
+ const accessToken = prompt('Please enter your Netlify access token:');
+ if (!accessToken) {
+ alert('Netlify access token is required. Deployment cancelled.');
+ return;
+ }
+
+ const siteId = prompt('Please enter your Netlify site ID:');
+ if (!siteId) {
+ alert('Netlify site ID is required. Deployment cancelled.');
+ return;
+ }
+
+ workbenchStore.deployToNetlify(accessToken)
+ .then((result: { url: string }) => {
+ toast.success(`Deployed successfully! Site URL: ${result.url}`);
+ })
+ .catch((error: Error) => {
+ toast.error(`Failed to deploy: ${error.message}`);
+ });
+ }}
+ >
+
+ Deploy to Netlify
+
{
diff --git a/app/lib/netlify.ts b/app/lib/netlify.ts
new file mode 100644
index 000000000..6d83d6229
--- /dev/null
+++ b/app/lib/netlify.ts
@@ -0,0 +1,176 @@
+interface NetlifySite {
+ id: string;
+ url: string;
+}
+
+interface NetlifyDeployResponse {
+ id: string;
+ required?: string[];
+}
+
+interface NetlifyDeployStatus {
+ state: string;
+ url: string;
+}
+
+export class NetlifyDeploy {
+ private accessToken: string;
+ private siteId?: string;
+ private baseURL: string;
+
+ constructor(accessToken: string) {
+ this.accessToken = accessToken;
+ this.baseURL = 'https://api.netlify.com/api/v1';
+ }
+
+ private async createSite(name?: string): Promise {
+ try {
+ const response = await this.fetchWithAuth('/sites', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ name: name || `site-${Date.now()}`,
+ }),
+ });
+ const site = response as NetlifySite;
+ this.siteId = site.id;
+ return site;
+ } catch (error: any) {
+ console.error('Error creating site:', error.message);
+ throw error;
+ }
+ }
+
+ private async fetchWithAuth(endpoint: string, options: RequestInit = {}) {
+ const url = `${this.baseURL}${endpoint}`;
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ Authorization: `Bearer ${this.accessToken}`,
+ ...options.headers,
+ },
+ });
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ message: 'Unknown error' })) as { message: string };
+ throw new Error(error.message || `HTTP error! status: ${response.status}`);
+ }
+ return response.json();
+ }
+
+ private async calculateSHA1(content: string): Promise {
+ const msgUint8 = new TextEncoder().encode(content);
+ const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
+ }
+
+ private async getFileList(files: Record) {
+ const fileHashes: Record = {};
+
+ for (const [filePath, file] of Object.entries(files)) {
+ if (file.type === 'file') {
+ const sha1 = await this.calculateSHA1(file.content);
+ fileHashes['/' + filePath] = sha1;
+ }
+ }
+
+ return fileHashes;
+ }
+
+ private async createDeploy(files: Record): Promise {
+ if (!this.siteId) {
+ throw new Error('No site ID available. Make sure to create a site first.');
+ }
+
+ try {
+ const response = await this.fetchWithAuth(`/sites/${this.siteId}/deploys`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ files,
+ async: true
+ }),
+ });
+ return response as NetlifyDeployResponse;
+ } catch (error: any) {
+ console.error('Error creating deploy:', error.message);
+ throw error;
+ }
+ }
+
+ private async uploadFile(deployId: string, filePath: string, content: string) {
+ try {
+ await this.fetchWithAuth(`/deploys/${deployId}/files${filePath}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ },
+ body: content,
+ });
+ } catch (error: any) {
+ console.error(`Error uploading file ${filePath}:`, error.message);
+ throw error;
+ }
+ }
+
+ private async waitForDeploy(deployId: string): Promise {
+ if (!this.siteId) {
+ throw new Error('No site ID available.');
+ }
+
+ while (true) {
+ const data = await this.fetchWithAuth(`/sites/${this.siteId}/deploys/${deployId}`) as NetlifyDeployStatus;
+ const { state } = data;
+
+ if (state === 'ready') {
+ return data;
+ } else if (state === 'error') {
+ throw new Error('Deploy failed');
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
+ }
+
+ async deploy(files: Record, siteName?: string): Promise {
+ try {
+ if (!this.siteId) {
+ console.log('Creating new site...');
+ const site = await this.createSite(siteName);
+ console.log('Site created:', site.url);
+ }
+
+ console.log('Calculating file hashes...');
+ const fileHashes = await this.getFileList(files);
+
+ console.log('Creating deploy...');
+ const deploy = await this.createDeploy(fileHashes);
+
+ const requiredFiles = deploy.required || [];
+ if (requiredFiles.length > 0) {
+ console.log('Uploading files...');
+ for (const sha1 of requiredFiles) {
+ const filePath = Object.entries(fileHashes).find(([_, hash]) => hash === sha1)?.[0];
+ if (filePath) {
+ console.log(`Uploading ${filePath}...`);
+ await this.uploadFile(deploy.id, filePath, files[filePath.slice(1)].content);
+ }
+ }
+ }
+
+ console.log('Waiting for deploy to finish...');
+ const result = await this.waitForDeploy(deploy.id);
+
+ console.log('Deploy successful!');
+ console.log('URL:', result.url);
+ return result;
+ } catch (error: any) {
+ console.error('Deploy failed:', error.message);
+ throw error;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts
index 369b25405..69fa0f380 100644
--- a/app/lib/stores/workbench.ts
+++ b/app/lib/stores/workbench.ts
@@ -15,6 +15,7 @@ import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
import * as nodePath from 'node:path';
import { extractRelativePath } from '~/utils/diff';
import { description } from '~/lib/persistence';
+import { NetlifyDeploy } from '../netlify';
export interface ArtifactState {
id: string;
@@ -502,6 +503,35 @@ export class WorkbenchStore {
console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error));
}
}
+
+ async deployToNetlify(accessToken: string) {
+ try {
+ const files = this.files.get();
+ const netlifyDeploy = new NetlifyDeploy(accessToken);
+
+ // Convert FileMap to the format expected by NetlifyDeploy
+ const deployFiles: Record = {};
+ for (const [path, dirent] of Object.entries(files)) {
+ if (dirent?.type === 'file' && !dirent.isBinary) {
+ deployFiles[path] = {
+ content: dirent.content,
+ type: 'file'
+ };
+ } else if (dirent?.type === 'folder') {
+ deployFiles[path] = {
+ content: '',
+ type: 'folder'
+ };
+ }
+ }
+
+ const result = await netlifyDeploy.deploy(deployFiles);
+ return result;
+ } catch (error) {
+ console.error('Failed to deploy to Netlify:', error);
+ throw error;
+ }
+ }
}
export const workbenchStore = new WorkbenchStore();
diff --git a/package.json b/package.json
index 8dd1fbb44..a609ce700 100644
--- a/package.json
+++ b/package.json
@@ -62,6 +62,7 @@
"@remix-run/cloudflare": "^2.15.0",
"@remix-run/cloudflare-pages": "^2.15.0",
"@remix-run/react": "^2.15.0",
+ "@types/node": "^22.10.1",
"@uiw/codemirror-theme-vscode": "^4.23.6",
"@unocss/reset": "^0.61.9",
"@webcontainer/api": "1.3.0-internal.10",
@@ -69,6 +70,7 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"ai": "^3.4.33",
+ "axios": "^1.7.9",
"date-fns": "^3.6.0",
"diff": "^5.2.0",
"file-saver": "^2.0.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1bf0c536f..721730916 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -110,6 +110,9 @@ importers:
'@remix-run/react':
specifier: ^2.15.0
version: 2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2)
+ '@types/node':
+ specifier: ^22.10.1
+ version: 22.10.1
'@uiw/codemirror-theme-vscode':
specifier: ^4.23.6
version: 4.23.6(@codemirror/language@6.10.6)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)
@@ -131,6 +134,9 @@ importers:
ai:
specifier: ^3.4.33
version: 3.4.33(react@18.3.1)(sswr@2.1.0(svelte@4.2.18))(svelte@4.2.18)(vue@3.4.30(typescript@5.7.2))(zod@3.23.8)
+ axios:
+ specifier: ^1.7.9
+ version: 1.7.9
date-fns:
specifier: ^3.6.0
version: 3.6.0
@@ -2459,10 +2465,16 @@ packages:
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
hasBin: true
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
+ axios@1.7.9:
+ resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==}
+
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -2687,6 +2699,10 @@ packages:
colorjs.io@0.5.2:
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -2855,6 +2871,10 @@ packages:
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -3219,6 +3239,15 @@ packages:
flatted@3.3.2:
resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==}
+ follow-redirects@1.15.9:
+ resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
for-each@0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@@ -3226,6 +3255,10 @@ packages:
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
engines: {node: '>=14'}
+ form-data@4.0.1:
+ resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
+ engines: {node: '>= 6'}
+
format@0.2.2:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
@@ -4546,6 +4579,9 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
public-encrypt@4.0.3:
resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==}
@@ -8058,10 +8094,20 @@ snapshots:
astring@1.9.0: {}
+ asynckit@0.4.0: {}
+
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.0.0
+ axios@1.7.9:
+ dependencies:
+ follow-redirects: 1.15.9
+ form-data: 4.0.1
+ proxy-from-env: 1.1.0
+ transitivePeerDependencies:
+ - debug
+
axobject-query@4.1.0: {}
bail@2.0.2: {}
@@ -8324,6 +8370,10 @@ snapshots:
colorjs.io@0.5.2: {}
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
comma-separated-tokens@2.0.3: {}
common-tags@1.8.2: {}
@@ -8466,6 +8516,8 @@ snapshots:
defu@6.1.4: {}
+ delayed-stream@1.0.0: {}
+
depd@2.0.0: {}
dequal@2.0.3: {}
@@ -8961,6 +9013,8 @@ snapshots:
flatted@3.3.2: {}
+ follow-redirects@1.15.9: {}
+
for-each@0.3.3:
dependencies:
is-callable: 1.2.7
@@ -8970,6 +9024,12 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
+ form-data@4.0.1:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ mime-types: 2.1.35
+
format@0.2.2: {}
formdata-polyfill@4.0.10:
@@ -10705,6 +10765,8 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ proxy-from-env@1.1.0: {}
+
public-encrypt@4.0.3:
dependencies:
bn.js: 4.12.1