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