diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx index 29c722c89..2e01c07e5 100644 --- a/app/components/workbench/Workbench.client.tsx +++ b/app/components/workbench/Workbench.client.tsx @@ -10,12 +10,14 @@ import { import { IconButton } from '~/components/ui/IconButton'; import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton'; import { Slider, type SliderOptions } from '~/components/ui/Slider'; +import { Dialog, DialogRoot, DialogTitle, DialogDescription, DialogButton } from '~/components/ui/Dialog'; import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench'; import { classNames } from '~/utils/classNames'; import { cubicEasingFn } from '~/utils/easings'; import { renderLogger } from '~/utils/logger'; import { EditorPanel } from './EditorPanel'; import { Preview } from './Preview'; +import { Octokit } from "@octokit/rest"; interface WorkspaceProps { chatStarted?: boolean; @@ -56,6 +58,16 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => renderLogger.trace('Workbench'); const [isSyncing, setIsSyncing] = useState(false); + const [isGitHubPushing, setIsGitHubPushing] = useState(false); + const [showGitHubDialog, setShowGitHubDialog] = useState(false); + const [githubRepoName, setGithubRepoName] = useState('bolt-generated-project'); + const [githubUsername, setGithubUsername] = useState(''); + const [githubToken, setGithubToken] = useState(''); + const [isPrivateRepo, setIsPrivateRepo] = useState(false); + const [selectedBranch, setSelectedBranch] = useState('main'); + const [branches, setBranches] = useState([]); + const [isNewBranch, setIsNewBranch] = useState(false); + const [newBranchName, setNewBranchName] = useState(''); const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0)); const showWorkbench = useStore(workbenchStore.showWorkbench); @@ -116,6 +128,96 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => } }, []); + const isValidBranchName = (branchName: string) => { + // Git branch names must not contain these characters: ~ ^ : ? * [ \ and must not end with a dot. + const invalidCharacters = /[~^:?*[\]\\]/; + return branchName.length > 0 && !invalidCharacters.test(branchName) && !branchName.endsWith('.'); + }; + + const handleGitHubPush = useCallback(async () => { + if (!githubRepoName || !githubUsername || !githubToken) { + toast.error('Please fill in all GitHub details'); + return; + } + + if (!githubToken.startsWith('ghp_') && !githubToken.startsWith('github_pat_')) { + toast.error('Invalid token format. Please use a GitHub Personal Access Token'); + return; + } + + if (isNewBranch) { + if (!newBranchName) { + toast.error('Please enter a name for the new branch'); + return; + } + if (!isValidBranchName(newBranchName)) { + toast.error('Invalid branch name. Please ensure it does not contain invalid characters or end with a dot.'); + return; + } + } + + setIsGitHubPushing(true); + try { + const repoUrl = await workbenchStore.pushToGitHub( + githubRepoName.trim(), + githubUsername.trim(), + githubToken.trim(), + isPrivateRepo, + isNewBranch ? newBranchName.trim() : undefined, + isNewBranch + ); + + toast.success( +
+ Successfully pushed to GitHub!{' '} + + View Repository + +
+ ); + setShowGitHubDialog(false); + } catch (error) { + console.error('GitHub push error:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to push to GitHub'; + + // Add specific error handling for common cases + if (errorMessage.includes('Repository does not exist')) { + toast.error('Cannot create a new branch in a non-existent repository. Please create the repository first.'); + } else if (errorMessage.includes('rate limit')) { + toast.error('GitHub API rate limit exceeded. Please try again later.'); + } else { + toast.error(errorMessage); + } + } finally { + setIsGitHubPushing(false); + } + }, [githubRepoName, githubUsername, githubToken, isPrivateRepo, isNewBranch, newBranchName]); + + const handleCancelPush = useCallback(() => { + if (isGitHubPushing) { + // Cancel the ongoing push operation + setIsGitHubPushing(false); + toast.info('GitHub push operation cancelled'); + } + setShowGitHubDialog(false); + }, [isGitHubPushing]); + + const fetchBranches = useCallback(async () => { + if (!githubUsername || !githubToken || !githubRepoName) return; + + try { + const octokit = new Octokit({ auth: githubToken }); + const { data } = await octokit.rest.repos.listBranches({ + owner: githubUsername, + repo: githubRepoName + }); + setBranches(data.map(branch => branch.name)); + } catch (error) { + console.error('Error fetching branches:', error); + setBranches([]); + } + }, [githubUsername, githubToken, githubRepoName]); + return ( chatStarted && ( variants={workbenchVariants} className="z-workbench" > + + + +
+
+ Push to GitHub +
+ + +
+
+ Push your project to a new or existing GitHub repository. You'll need a GitHub account and a personal access token with repo permissions. +
+ + {/* Repository Name */} +
+ + setGithubRepoName(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-bolt-elements-background-depth-1 focus:outline-none focus:ring-2 focus:ring-bolt-elements-button-primary-background" + placeholder="bolt-generated-project" + /> +
+ + {/* GitHub Username */} +
+ + setGithubUsername(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-bolt-elements-background-depth-1 focus:outline-none focus:ring-2 focus:ring-bolt-elements-button-primary-background" + placeholder="username" + /> +
+ + {/* Repository Visibility */} +
+ +
+ + +
+
+ + {/* Branch Options */} +
+ +
+ + +
+ + {isNewBranch && ( + setNewBranchName(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-bolt-elements-background-depth-1 focus:outline-none focus:ring-2 focus:ring-bolt-elements-button-primary-background" + placeholder="Enter new branch name" + /> + )} +
+ + {/* Personal Access Token */} +
+ + setGithubToken(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-bolt-elements-background-depth-1 focus:outline-none focus:ring-2 focus:ring-bolt-elements-button-primary-background" + placeholder="ghp_xxxxxxxxxxxx" + /> + + Generate a new token + +
+
+ + Cancel + + + {isGitHubPushing ? ( + <> +
+ Pushing... + + ) : ( + <> +
+ Push to GitHub + + )} + +
+
+ +
+
+
Download Code - {isSyncing ?
:
} + {isSyncing ?
:
} {isSyncing ? 'Syncing...' : 'Sync Files'} { - const repoName = prompt("Please enter a name for your new GitHub repository:", "bolt-generated-project"); - if (!repoName) { - alert("Repository name is required. Push to GitHub cancelled."); - return; - } - const githubUsername = prompt("Please enter your GitHub username:"); - if (!githubUsername) { - alert("GitHub username is required. Push to GitHub cancelled."); - return; - } - const githubToken = prompt("Please enter your GitHub personal access token:"); - if (!githubToken) { - alert("GitHub token is required. Push to GitHub cancelled."); - return; - } - - workbenchStore.pushToGitHub(repoName, githubUsername, githubToken); - }} + onClick={() => setShowGitHubDialog(true)} >
Push to GitHub @@ -230,6 +454,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => ) ); }); + interface ViewProps extends HTMLMotionProps<'div'> { children: JSX.Element; } diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index 8589391c8..8920594c7 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -366,111 +366,756 @@ export class WorkbenchStore { return syncedFiles; } - async pushToGitHub(repoName: string, githubUsername: string, ghToken: string) { - + async pushToGitHub( + repoName: string, + githubUsername: string, + ghToken: string, + isPrivate: boolean, + branchName?: string, + isNewBranch?: boolean + ) { try { - // Get the GitHub auth token from environment variables - const githubToken = ghToken; - - const owner = githubUsername; - - if (!githubToken) { - throw new Error('GitHub token is not set in environment variables'); - } - - // Initialize Octokit with the auth token - const octokit = new Octokit({ auth: githubToken }); - - // Check if the repository already exists before creating it - let repo: RestEndpointMethodTypes["repos"]["get"]["response"]['data'] + // Clean and validate inputs + const cleanUsername = githubUsername.trim().replace(/[@\s]/g, ''); + const cleanRepoName = repoName.trim().replace(/[^a-zA-Z0-9-_]/g, '-'); + const targetBranch = (branchName || 'main').trim(); + + // Initialize Octokit client with auth token + const octokit = new Octokit({ + auth: ghToken, + baseUrl: 'https://api.github.com' + }); + + // Get or create repository + let repoData; try { - let resp = await octokit.repos.get({ owner: owner, repo: repoName }); - repo = resp.data - } catch (error) { - if (error instanceof Error && 'status' in error && error.status === 404) { - // Repository doesn't exist, so create a new one - const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({ - name: repoName, - private: false, - auto_init: true, + // Try to get existing repo first + const { data } = await octokit.rest.repos.get({ + owner: cleanUsername, + repo: cleanRepoName + }); + repoData = data; + + // Update repository visibility if it exists + await octokit.rest.repos.update({ + owner: cleanUsername, + repo: cleanRepoName, + private: isPrivate, + name: cleanRepoName + }); + + } catch (error: any) { + if (error?.response?.status === 404) { + const { data } = await octokit.rest.repos.createForAuthenticatedUser({ + name: cleanRepoName, + private: isPrivate, + auto_init: true }); - repo = newRepo; + repoData = data; + await new Promise(resolve => setTimeout(resolve, 5000)); } else { - console.log('cannot create repo!'); - throw error; // Some other error occurred + throw error; } } - - // Get all files + + // Get base commit SHA from default branch + let baseCommitSha; + try { + const { data: ref } = await octokit.rest.git.getRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${repoData.default_branch}` + }); + baseCommitSha = ref.object.sha; + } catch (error) { + console.error('Error getting default branch:', error); + throw new Error('Failed to get default branch. Repository may not be properly initialized.'); + } + + // Create blobs for files in batches to avoid rate limits const files = this.files.get(); if (!files || Object.keys(files).length === 0) { throw new Error('No files found to push'); } - - // Create blobs for each file - const blobs = await Promise.all( - Object.entries(files).map(async ([filePath, dirent]) => { + + const blobs = []; + const BATCH_SIZE = 3; + const fileEntries = Object.entries(files); + for (let i = 0; i < fileEntries.length; i += BATCH_SIZE) { + const batch = fileEntries.slice(i, i + BATCH_SIZE); + if (i > 0) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + const batchResults = await Promise.all(batch.map(async ([filePath, dirent]) => { if (dirent?.type === 'file' && dirent.content) { - const { data: blob } = await octokit.git.createBlob({ - owner: repo.owner.login, - repo: repo.name, - content: Buffer.from(dirent.content).toString('base64'), - encoding: 'base64', + try { + const { data } = await octokit.rest.git.createBlob({ + owner: cleanUsername, + repo: cleanRepoName, + content: Buffer.from(dirent.content).toString('base64'), + encoding: 'base64' + }); + return { + path: filePath.replace(/^\/home\/project\//, ''), + mode: '100644' as const, + type: 'blob' as const, + sha: data.sha + }; + } catch (error) { + console.error('Error creating blob:', error); + return null; + } + } + return null; + })); + + blobs.push(...batchResults.filter((blob): blob is NonNullable => blob !== null)); + } + + const { data: tree } = await octokit.rest.git.createTree({ + owner: cleanUsername, + repo: cleanRepoName, + base_tree: baseCommitSha, + tree: blobs + }); + + const { data: newCommit } = await octokit.rest.git.createCommit({ + owner: cleanUsername, + repo: cleanRepoName, + message: 'Update from Bolt', + tree: tree.sha, + parents: [baseCommitSha] + }); + + if (isNewBranch && branchName) { + try { + await octokit.rest.git.createRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `refs/heads/${targetBranch}`, + sha: newCommit.sha + }); + } catch (error: any) { + if (error?.response?.status === 422) { + await octokit.rest.git.updateRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${targetBranch}`, + sha: newCommit.sha, + force: true + }); + } else { + throw error; + } + } + } else { + try { + const { data: branchRef } = await octokit.rest.git.getRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${targetBranch}` + }); + + if (branchRef.object.sha !== baseCommitSha) { + throw new Error('Branch has diverged from the base commit. Manual merge or pull request required.'); + } + + await octokit.rest.git.updateRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${targetBranch}`, + sha: newCommit.sha + }); + } catch (error: any) { + if (error?.response?.status === 404) { + await octokit.rest.git.createRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `refs/heads/${targetBranch}`, + sha: newCommit.sha }); - return { path: filePath.replace(/^\/home\/project\//, ''), sha: blob.sha }; + } else { + throw error; } - }) - ); + } + } + + return repoData.html_url; + } catch (error) { + console.error('GitHub push error:', error); + if (error instanceof Error) { + if (error.message.includes('rate limit')) { + throw new Error('GitHub API rate limit exceeded. Please try again later.'); + } + if (error.message.includes('Resource protected by organization SAML enforcement')) { + throw new Error('This repository is protected by SAML enforcement. Please authorize your token for SSO.'); + } + throw error; + } + throw new Error('An unexpected error occurred while pushing to GitHub'); + } + } +} + +export const workbenchStore = new WorkbenchStore();import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores'; +import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor'; +import { ActionRunner } from '~/lib/runtime/action-runner'; +import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser'; +import { webcontainer } from '~/lib/webcontainer'; +import type { ITerminal } from '~/types/terminal'; +import { unreachable } from '~/utils/unreachable'; +import { EditorStore } from './editor'; +import { FilesStore, type FileMap } from './files'; +import { PreviewsStore } from './previews'; +import { TerminalStore } from './terminal'; +import JSZip from 'jszip'; +import { saveAs } from 'file-saver'; +import { Octokit, type RestEndpointMethodTypes } from "@octokit/rest"; +import * as nodePath from 'node:path'; +import type { WebContainerProcess } from '@webcontainer/api'; + +export interface ArtifactState { + id: string; + title: string; + closed: boolean; + runner: ActionRunner; +} + +export type ArtifactUpdateState = Pick; + +type Artifacts = MapStore>; + +export type WorkbenchViewType = 'code' | 'preview'; + +export class WorkbenchStore { + #previewsStore = new PreviewsStore(webcontainer); + #filesStore = new FilesStore(webcontainer); + #editorStore = new EditorStore(this.#filesStore); + #terminalStore = new TerminalStore(webcontainer); + + artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); + + showWorkbench: WritableAtom = import.meta.hot?.data.showWorkbench ?? atom(false); + currentView: WritableAtom = import.meta.hot?.data.currentView ?? atom('code'); + unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set()); + modifiedFiles = new Set(); + artifactIdList: string[] = []; + #boltTerminal: { terminal: ITerminal; process: WebContainerProcess } | undefined; + + constructor() { + if (import.meta.hot) { + import.meta.hot.data.artifacts = this.artifacts; + import.meta.hot.data.unsavedFiles = this.unsavedFiles; + import.meta.hot.data.showWorkbench = this.showWorkbench; + import.meta.hot.data.currentView = this.currentView; + } + } + + get previews() { + return this.#previewsStore.previews; + } + + get files() { + return this.#filesStore.files; + } + + get currentDocument(): ReadableAtom { + return this.#editorStore.currentDocument; + } + + get selectedFile(): ReadableAtom { + return this.#editorStore.selectedFile; + } + + get firstArtifact(): ArtifactState | undefined { + return this.#getArtifact(this.artifactIdList[0]); + } + + get filesCount(): number { + return this.#filesStore.filesCount; + } + + get showTerminal() { + return this.#terminalStore.showTerminal; + } + get boltTerminal() { + return this.#terminalStore.boltTerminal; + } + + toggleTerminal(value?: boolean) { + this.#terminalStore.toggleTerminal(value); + } - const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs + attachTerminal(terminal: ITerminal) { + this.#terminalStore.attachTerminal(terminal); + } + attachBoltTerminal(terminal: ITerminal) { + + this.#terminalStore.attachBoltTerminal(terminal); + } + + onTerminalResize(cols: number, rows: number) { + this.#terminalStore.onTerminalResize(cols, rows); + } + + setDocuments(files: FileMap) { + this.#editorStore.setDocuments(files); - if (validBlobs.length === 0) { - throw new Error('No valid files to push'); + if (this.#filesStore.filesCount > 0 && this.currentDocument.get() === undefined) { + // we find the first file and select it + for (const [filePath, dirent] of Object.entries(files)) { + if (dirent?.type === 'file') { + this.setSelectedFile(filePath); + break; + } } + } + } - // Get the latest commit SHA (assuming main branch, update dynamically if needed) - const { data: ref } = await octokit.git.getRef({ - owner: repo.owner.login, - repo: repo.name, - ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch - }); - const latestCommitSha = ref.object.sha; - - // Create a new tree - const { data: newTree } = await octokit.git.createTree({ - owner: repo.owner.login, - repo: repo.name, - base_tree: latestCommitSha, - tree: validBlobs.map((blob) => ({ - path: blob!.path, - mode: '100644', - type: 'blob', - sha: blob!.sha, - })), - }); + setShowWorkbench(show: boolean) { + this.showWorkbench.set(show); + } - // Create a new commit - const { data: newCommit } = await octokit.git.createCommit({ - owner: repo.owner.login, - repo: repo.name, - message: 'Initial commit from your app', - tree: newTree.sha, - parents: [latestCommitSha], - }); + setCurrentDocumentContent(newContent: string) { + const filePath = this.currentDocument.get()?.filePath; - // Update the reference - await octokit.git.updateRef({ - owner: repo.owner.login, - repo: repo.name, - ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch - sha: newCommit.sha, - }); + if (!filePath) { + return; + } - alert(`Repository created and code pushed: ${repo.html_url}`); + const originalContent = this.#filesStore.getFile(filePath)?.content; + const unsavedChanges = originalContent !== undefined && originalContent !== newContent; + + this.#editorStore.updateFile(filePath, newContent); + + const currentDocument = this.currentDocument.get(); + + if (currentDocument) { + const previousUnsavedFiles = this.unsavedFiles.get(); + + if (unsavedChanges && previousUnsavedFiles.has(currentDocument.filePath)) { + return; + } + + const newUnsavedFiles = new Set(previousUnsavedFiles); + + if (unsavedChanges) { + newUnsavedFiles.add(currentDocument.filePath); + } else { + newUnsavedFiles.delete(currentDocument.filePath); + } + + this.unsavedFiles.set(newUnsavedFiles); + } + } + + setCurrentDocumentScrollPosition(position: ScrollPosition) { + const editorDocument = this.currentDocument.get(); + + if (!editorDocument) { + return; + } + + const { filePath } = editorDocument; + + this.#editorStore.updateScrollPosition(filePath, position); + } + + setSelectedFile(filePath: string | undefined) { + this.#editorStore.setSelectedFile(filePath); + } + + async saveFile(filePath: string) { + const documents = this.#editorStore.documents.get(); + const document = documents[filePath]; + + if (document === undefined) { + return; + } + + await this.#filesStore.saveFile(filePath, document.value); + + const newUnsavedFiles = new Set(this.unsavedFiles.get()); + newUnsavedFiles.delete(filePath); + + this.unsavedFiles.set(newUnsavedFiles); + } + + async saveCurrentDocument() { + const currentDocument = this.currentDocument.get(); + + if (currentDocument === undefined) { + return; + } + + await this.saveFile(currentDocument.filePath); + } + + resetCurrentDocument() { + const currentDocument = this.currentDocument.get(); + + if (currentDocument === undefined) { + return; + } + + const { filePath } = currentDocument; + const file = this.#filesStore.getFile(filePath); + + if (!file) { + return; + } + + this.setCurrentDocumentContent(file.content); + } + + async saveAllFiles() { + for (const filePath of this.unsavedFiles.get()) { + await this.saveFile(filePath); + } + } + + getFileModifcations() { + return this.#filesStore.getFileModifications(); + } + + resetAllFileModifications() { + this.#filesStore.resetFileModifications(); + } + + abortAllActions() { + // TODO: what do we wanna do and how do we wanna recover from this? + } + + addArtifact({ messageId, title, id }: ArtifactCallbackData) { + const artifact = this.#getArtifact(messageId); + + if (artifact) { + return; + } + + if (!this.artifactIdList.includes(messageId)) { + this.artifactIdList.push(messageId); + } + + this.artifacts.setKey(messageId, { + id, + title, + closed: false, + runner: new ActionRunner(webcontainer, () => this.boltTerminal), + }); + } + + updateArtifact({ messageId }: ArtifactCallbackData, state: Partial) { + const artifact = this.#getArtifact(messageId); + + if (!artifact) { + return; + } + + this.artifacts.setKey(messageId, { ...artifact, ...state }); + } + + async addAction(data: ActionCallbackData) { + const { messageId } = data; + + const artifact = this.#getArtifact(messageId); + + if (!artifact) { + unreachable('Artifact not found'); + } + + artifact.runner.addAction(data); + } + + async runAction(data: ActionCallbackData, isStreaming: boolean = false) { + const { messageId } = data; + + const artifact = this.#getArtifact(messageId); + + if (!artifact) { + unreachable('Artifact not found'); + } + if (data.action.type === 'file') { + let wc = await webcontainer + const fullPath = nodePath.join(wc.workdir, data.action.filePath); + if (this.selectedFile.value !== fullPath) { + this.setSelectedFile(fullPath); + } + if (this.currentView.value !== 'code') { + this.currentView.set('code'); + } + const doc = this.#editorStore.documents.get()[fullPath]; + if (!doc) { + await artifact.runner.runAction(data, isStreaming); + } + + this.#editorStore.updateFile(fullPath, data.action.content); + + if (!isStreaming) { + this.resetCurrentDocument(); + await artifact.runner.runAction(data); + } + } else { + artifact.runner.runAction(data); + } + } + + #getArtifact(id: string) { + const artifacts = this.artifacts.get(); + return artifacts[id]; + } + + async downloadZip() { + const zip = new JSZip(); + const files = this.files.get(); + + for (const [filePath, dirent] of Object.entries(files)) { + if (dirent?.type === 'file' && !dirent.isBinary) { + // remove '/home/project/' from the beginning of the path + const relativePath = filePath.replace(/^\/home\/project\//, ''); + + // split the path into segments + const pathSegments = relativePath.split('/'); + + // if there's more than one segment, we need to create folders + if (pathSegments.length > 1) { + let currentFolder = zip; + + for (let i = 0; i < pathSegments.length - 1; i++) { + currentFolder = currentFolder.folder(pathSegments[i])!; + } + currentFolder.file(pathSegments[pathSegments.length - 1], dirent.content); + } else { + // if there's only one segment, it's a file in the root + zip.file(relativePath, dirent.content); + } + } + } + + const content = await zip.generateAsync({ type: 'blob' }); + saveAs(content, 'project.zip'); + } + + async syncFiles(targetHandle: FileSystemDirectoryHandle) { + const files = this.files.get(); + const syncedFiles = []; + + for (const [filePath, dirent] of Object.entries(files)) { + if (dirent?.type === 'file' && !dirent.isBinary) { + const relativePath = filePath.replace(/^\/home\/project\//, ''); + const pathSegments = relativePath.split('/'); + let currentHandle = targetHandle; + + for (let i = 0; i < pathSegments.length - 1; i++) { + currentHandle = await currentHandle.getDirectoryHandle(pathSegments[i], { create: true }); + } + + // create or get the file + const fileHandle = await currentHandle.getFileHandle(pathSegments[pathSegments.length - 1], { create: true }); + + // write the file content + const writable = await fileHandle.createWritable(); + await writable.write(dirent.content); + await writable.close(); + + syncedFiles.push(relativePath); + } + } + + return syncedFiles; + } + + async pushToGitHub( + repoName: string, + githubUsername: string, + ghToken: string, + isPrivate: boolean, + branchName?: string, + isNewBranch?: boolean + ) { + try { + // Clean and validate inputs + const cleanUsername = githubUsername.trim().replace(/[@\s]/g, ''); + const cleanRepoName = repoName.trim().replace(/[^a-zA-Z0-9-_]/g, '-'); + const targetBranch = (branchName || 'main').trim(); + + // Initialize Octokit client with auth token + const octokit = new Octokit({ + auth: ghToken, + baseUrl: 'https://api.github.com' + }); + + // Get or create repository + let repoData; + try { + // Try to get existing repo first + const { data } = await octokit.rest.repos.get({ + owner: cleanUsername, + repo: cleanRepoName + }); + repoData = data; + + // Update repository visibility if it exists + await octokit.rest.repos.update({ + owner: cleanUsername, + repo: cleanRepoName, + private: isPrivate, + name: cleanRepoName + }); + + } catch (error: any) { + if (error?.response?.status === 404) { + const { data } = await octokit.rest.repos.createForAuthenticatedUser({ + name: cleanRepoName, + private: isPrivate, + auto_init: true + }); + repoData = data; + await new Promise(resolve => setTimeout(resolve, 5000)); + } else { + throw error; + } + } + + // Get base commit SHA from default branch + let baseCommitSha; + try { + const { data: ref } = await octokit.rest.git.getRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${repoData.default_branch}` + }); + baseCommitSha = ref.object.sha; + } catch (error) { + console.error('Error getting default branch:', error); + throw new Error('Failed to get default branch. Repository may not be properly initialized.'); + } + + // Create blobs for files in batches to avoid rate limits + const files = this.files.get(); + if (!files || Object.keys(files).length === 0) { + throw new Error('No files found to push'); + } + + const blobs = []; + const BATCH_SIZE = 3; + const fileEntries = Object.entries(files); + for (let i = 0; i < fileEntries.length; i += BATCH_SIZE) { + const batch = fileEntries.slice(i, i + BATCH_SIZE); + if (i > 0) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + const batchResults = await Promise.all(batch.map(async ([filePath, dirent]) => { + if (dirent?.type === 'file' && dirent.content) { + try { + const { data } = await octokit.rest.git.createBlob({ + owner: cleanUsername, + repo: cleanRepoName, + content: Buffer.from(dirent.content).toString('base64'), + encoding: 'base64' + }); + return { + path: filePath.replace(/^\/home\/project\//, ''), + mode: '100644' as const, + type: 'blob' as const, + sha: data.sha + }; + } catch (error) { + console.error('Error creating blob:', error); + return null; + } + } + return null; + })); + + blobs.push(...batchResults.filter((blob): blob is NonNullable => blob !== null)); + } + + const { data: tree } = await octokit.rest.git.createTree({ + owner: cleanUsername, + repo: cleanRepoName, + base_tree: baseCommitSha, + tree: blobs + }); + + const { data: newCommit } = await octokit.rest.git.createCommit({ + owner: cleanUsername, + repo: cleanRepoName, + message: 'Update from Bolt', + tree: tree.sha, + parents: [baseCommitSha] + }); + + if (isNewBranch && branchName) { + try { + await octokit.rest.git.createRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `refs/heads/${targetBranch}`, + sha: newCommit.sha + }); + } catch (error: any) { + if (error?.response?.status === 422) { + await octokit.rest.git.updateRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${targetBranch}`, + sha: newCommit.sha, + force: true + }); + } else { + throw error; + } + } + } else { + try { + const { data: branchRef } = await octokit.rest.git.getRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${targetBranch}` + }); + + if (branchRef.object.sha !== baseCommitSha) { + throw new Error('Branch has diverged from the base commit. Manual merge or pull request required.'); + } + + await octokit.rest.git.updateRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${targetBranch}`, + sha: newCommit.sha + }); + } catch (error: any) { + if (error?.response?.status === 404) { + await octokit.rest.git.createRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `refs/heads/${targetBranch}`, + sha: newCommit.sha + }); + } else { + throw error; + } + } + } + + return repoData.html_url; } catch (error) { - console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error)); + console.error('GitHub push error:', error); + if (error instanceof Error) { + if (error.message.includes('rate limit')) { + throw new Error('GitHub API rate limit exceeded. Please try again later.'); + } + if (error.message.includes('Resource protected by organization SAML enforcement')) { + throw new Error('This repository is protected by SAML enforcement. Please authorize your token for SSO.'); + } + throw error; + } + throw new Error('An unexpected error occurred while pushing to GitHub'); } } } -export const workbenchStore = new WorkbenchStore(); +export const workbenchStore = new WorkbenchStore(); \ No newline at end of file diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 86d73409c..c0b9c97ca 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -3,6 +3,7 @@ import { ClientOnly } from 'remix-utils/client-only'; import { BaseChat } from '~/components/chat/BaseChat'; import { Chat } from '~/components/chat/Chat.client'; import { Header } from '~/components/header/Header'; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from '~/utils/constants'; export const meta: MetaFunction = () => { return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }]; @@ -14,7 +15,16 @@ export default function Index() { return (
- }>{() => } + {}} + provider={DEFAULT_PROVIDER} + setProvider={() => {}} + /> + }> + {() => } +
); }