import * as Dialog from '@radix-ui/react-dialog'; import { useState, useEffect } from 'react'; import { toast } from 'react-toastify'; import { motion } from 'framer-motion'; import { Octokit } from '@octokit/rest'; import { classNames } from '~/utils/classNames'; import { getLocalStorage } from '~/lib/persistence/localStorage'; import type { GitHubUserResponse, GitHubRepoInfo } from '~/types/GitHub'; import { logStore } from '~/lib/stores/logs'; import { chatId } from '~/lib/persistence/useChatHistory'; import { useStore } from '@nanostores/react'; import { GitHubAuthDialog } from '~/components/@settings/tabs/github/components/GitHubAuthDialog'; import { SearchInput, EmptyState, StatusIndicator, Badge } from '~/components/ui'; interface GitHubDeploymentDialogProps { isOpen: boolean; onClose: () => void; projectName: string; files: Record; } export function GitHubDeploymentDialog({ isOpen, onClose, projectName, files }: GitHubDeploymentDialogProps) { const [repoName, setRepoName] = useState(''); const [isPrivate, setIsPrivate] = useState(false); const [isLoading, setIsLoading] = useState(false); const [user, setUser] = useState(null); const [recentRepos, setRecentRepos] = useState([]); const [filteredRepos, setFilteredRepos] = useState([]); const [repoSearchQuery, setRepoSearchQuery] = useState(''); const [isFetchingRepos, setIsFetchingRepos] = useState(false); const [showSuccessDialog, setShowSuccessDialog] = useState(false); const [createdRepoUrl, setCreatedRepoUrl] = useState(''); const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]); const [showAuthDialog, setShowAuthDialog] = useState(false); const currentChatId = useStore(chatId); /* * Load GitHub connection on mount * Helper function to sanitize repository name */ const sanitizeRepoName = (name: string): string => { return ( name .toLowerCase() // Replace spaces and underscores with hyphens .replace(/[\s_]+/g, '-') // Remove special characters except hyphens and alphanumeric .replace(/[^a-z0-9-]/g, '') // Remove multiple consecutive hyphens .replace(/-+/g, '-') // Remove leading/trailing hyphens .replace(/^-+|-+$/g, '') // Ensure it's not empty and has reasonable length .substring(0, 100) || 'my-project' ); }; useEffect(() => { if (isOpen) { const connection = getLocalStorage('github_connection'); // Set a default repository name based on the project name with proper sanitization setRepoName(sanitizeRepoName(projectName)); if (connection?.user && connection?.token) { setUser(connection.user); // Only fetch if we have both user and token if (connection.token.trim()) { fetchRecentRepos(connection.token); } } } }, [isOpen, projectName]); // Filter repositories based on search query useEffect(() => { if (recentRepos.length === 0) { setFilteredRepos([]); return; } if (!repoSearchQuery.trim()) { setFilteredRepos(recentRepos); return; } const query = repoSearchQuery.toLowerCase().trim(); const filtered = recentRepos.filter( (repo) => repo.name.toLowerCase().includes(query) || (repo.description && repo.description.toLowerCase().includes(query)) || (repo.language && repo.language.toLowerCase().includes(query)), ); setFilteredRepos(filtered); }, [recentRepos, repoSearchQuery]); const fetchRecentRepos = async (token: string) => { if (!token) { logStore.logError('No GitHub token available'); toast.error('GitHub authentication required'); return; } try { setIsFetchingRepos(true); // Fetch ALL repos by paginating through all pages let allRepos: GitHubRepoInfo[] = []; let page = 1; let hasMore = true; while (hasMore) { const requestUrl = `https://api.github.com/user/repos?sort=updated&per_page=100&page=${page}&affiliation=owner,organization_member`; const response = await fetch(requestUrl, { headers: { Accept: 'application/vnd.github.v3+json', Authorization: `Bearer ${token.trim()}`, }, }); if (!response.ok) { let errorData: { message?: string } = {}; try { errorData = await response.json(); } catch { errorData = { message: 'Could not parse error response' }; } if (response.status === 401) { toast.error('GitHub token expired. Please reconnect your account.'); // Clear invalid token const connection = getLocalStorage('github_connection'); if (connection) { localStorage.removeItem('github_connection'); setUser(null); } } else if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') { // Rate limit exceeded const resetTime = response.headers.get('x-ratelimit-reset'); const resetDate = resetTime ? new Date(parseInt(resetTime) * 1000).toLocaleTimeString() : 'soon'; toast.error(`GitHub API rate limit exceeded. Limit resets at ${resetDate}`); } else { logStore.logError('Failed to fetch GitHub repositories', { status: response.status, statusText: response.statusText, error: errorData, }); toast.error(`Failed to fetch repositories: ${errorData.message || response.statusText}`); } return; } try { const repos = (await response.json()) as GitHubRepoInfo[]; allRepos = allRepos.concat(repos); if (repos.length < 100) { hasMore = false; } else { page += 1; } } catch (parseError) { logStore.logError('Failed to parse GitHub repositories response', { parseError }); toast.error('Failed to parse repository data'); setRecentRepos([]); return; } } setRecentRepos(allRepos); } catch (error) { logStore.logError('Failed to fetch GitHub repositories', { error }); toast.error('Failed to fetch recent repositories'); } finally { setIsFetchingRepos(false); } }; // Function to create a new repository or push to an existing one const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const connection = getLocalStorage('github_connection'); if (!connection?.token || !connection?.user) { toast.error('Please connect your GitHub account in Settings > Connections first'); return; } if (!repoName.trim()) { toast.error('Repository name is required'); return; } // Validate repository name const sanitizedName = sanitizeRepoName(repoName); if (!sanitizedName || sanitizedName.length < 1) { toast.error('Repository name must contain at least one alphanumeric character'); return; } if (sanitizedName.length > 100) { toast.error('Repository name is too long (maximum 100 characters)'); return; } // Update the repo name field with the sanitized version if it was changed if (sanitizedName !== repoName) { setRepoName(sanitizedName); toast.info(`Repository name sanitized to: ${sanitizedName}`); } setIsLoading(true); try { // Initialize Octokit with the GitHub token const octokit = new Octokit({ auth: connection.token }); let repoExists = false; try { // Check if the repository already exists - ensure repo name is properly sanitized const sanitizedRepoName = sanitizeRepoName(repoName); const { data: existingRepo } = await octokit.repos.get({ owner: connection.user.login, repo: sanitizedRepoName, }); repoExists = true; // If we get here, the repo exists - confirm overwrite let confirmMessage = `Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`; // Add visibility change warning if needed if (existingRepo.private !== isPrivate) { const visibilityChange = isPrivate ? 'This will also change the repository from public to private.' : 'This will also change the repository from private to public.'; confirmMessage += `\n\n${visibilityChange}`; } const confirmOverwrite = window.confirm(confirmMessage); if (!confirmOverwrite) { setIsLoading(false); return; } // If visibility needs to be updated if (existingRepo.private !== isPrivate) { await octokit.repos.update({ owner: connection.user.login, repo: sanitizedRepoName, private: isPrivate, }); } } catch (error: any) { // 404 means repo doesn't exist, which is what we want for new repos if (error.status !== 404) { throw error; } } // Create repository if it doesn't exist if (!repoExists) { const sanitizedRepoName = sanitizeRepoName(repoName); const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({ name: sanitizedRepoName, private: isPrivate, // Initialize with a README to avoid empty repository issues auto_init: true, // Create a .gitignore file for the project gitignore_template: 'Node', }); // Set the URL for success dialog setCreatedRepoUrl(newRepo.html_url); // Since we created the repo with auto_init, we need to wait for GitHub to initialize it console.log('Created new repository with auto_init, waiting for GitHub to initialize it...'); // Wait a moment for GitHub to set up the initial commit await new Promise((resolve) => setTimeout(resolve, 2000)); } else { // Set URL for existing repo const sanitizedRepoName = sanitizeRepoName(repoName); setCreatedRepoUrl(`https://github.com/${connection.user.login}/${sanitizedRepoName}`); } // Process files to upload const fileEntries = Object.entries(files); // Filter out files and format them for display const fileList = fileEntries.map(([filePath, content]) => { // The paths are already properly formatted in the GitHubDeploy component return { path: filePath, size: new TextEncoder().encode(content).length, }; }); setPushedFiles(fileList); /* * Now we need to handle the repository, whether it's new or existing * Get the default branch for the repository */ let defaultBranch: string; let baseSha: string | null = null; try { // For both new and existing repos, get the repository info const sanitizedRepoName = sanitizeRepoName(repoName); const { data: repo } = await octokit.repos.get({ owner: connection.user.login, repo: sanitizedRepoName, }); defaultBranch = repo.default_branch || 'main'; console.log(`Repository default branch: ${defaultBranch}`); // For a newly created repo (or existing one), get the reference to the default branch try { const { data: refData } = await octokit.git.getRef({ owner: connection.user.login, repo: sanitizedRepoName, ref: `heads/${defaultBranch}`, }); baseSha = refData.object.sha; console.log(`Found existing reference with SHA: ${baseSha}`); // Get the latest commit to use as a base for our tree const { data: commitData } = await octokit.git.getCommit({ owner: connection.user.login, repo: sanitizedRepoName, commit_sha: baseSha, }); // Store the base tree SHA for tree creation baseSha = commitData.tree.sha; console.log(`Using base tree SHA: ${baseSha}`); } catch (refError) { console.error('Error getting reference:', refError); baseSha = null; } } catch (repoError) { console.error('Error getting repository info:', repoError); defaultBranch = 'main'; baseSha = null; } try { console.log('Creating tree for repository'); // Create a tree with all files const tree = fileEntries.map(([filePath, content]) => ({ path: filePath, // We've already formatted the paths correctly mode: '100644' as const, // Regular file type: 'blob' as const, content, })); console.log(`Creating tree with ${tree.length} files using base: ${baseSha || 'none'}`); // Create a tree with all the files, using the base tree if available const sanitizedRepoName = sanitizeRepoName(repoName); const { data: treeData } = await octokit.git.createTree({ owner: connection.user.login, repo: sanitizedRepoName, tree, base_tree: baseSha || undefined, }); console.log('Tree created successfully', treeData.sha); // Get the current reference to use as parent for our commit let parentCommitSha: string | null = null; try { const { data: refData } = await octokit.git.getRef({ owner: connection.user.login, repo: sanitizedRepoName, ref: `heads/${defaultBranch}`, }); parentCommitSha = refData.object.sha; console.log(`Found parent commit: ${parentCommitSha}`); } catch (refError) { console.log('No reference found, this is a brand new repo', refError); parentCommitSha = null; } // Create a commit with the tree console.log('Creating commit'); const { data: commitData } = await octokit.git.createCommit({ owner: connection.user.login, repo: sanitizedRepoName, message: !repoExists ? 'Initial commit from Bolt.diy' : 'Update from Bolt.diy', tree: treeData.sha, parents: parentCommitSha ? [parentCommitSha] : [], // Use parent if available }); console.log('Commit created successfully', commitData.sha); // Update the reference to point to the new commit try { console.log(`Updating reference: heads/${defaultBranch} to ${commitData.sha}`); await octokit.git.updateRef({ owner: connection.user.login, repo: sanitizedRepoName, ref: `heads/${defaultBranch}`, sha: commitData.sha, force: true, // Use force to ensure the update works }); console.log('Reference updated successfully'); } catch (refError) { console.log('Failed to update reference, attempting to create it', refError); // If the reference doesn't exist, create it (shouldn't happen with auto_init, but just in case) try { await octokit.git.createRef({ owner: connection.user.login, repo: sanitizedRepoName, ref: `refs/heads/${defaultBranch}`, sha: commitData.sha, }); console.log('Reference created successfully'); } catch (createRefError) { console.error('Error creating reference:', createRefError); const errorMsg = typeof createRefError === 'object' && createRefError !== null && 'message' in createRefError ? String(createRefError.message) : 'Unknown error'; throw new Error(`Failed to create Git reference: ${errorMsg}`); } } } catch (gitError) { console.error('Error with git operations:', gitError); const gitErrorMsg = typeof gitError === 'object' && gitError !== null && 'message' in gitError ? String(gitError.message) : 'Unknown error'; throw new Error(`Failed during git operations: ${gitErrorMsg}`); } // Save the repository information for this chat const sanitizedRepoName = sanitizeRepoName(repoName); localStorage.setItem( `github-repo-${currentChatId}`, JSON.stringify({ owner: connection.user.login, name: sanitizedRepoName, url: `https://github.com/${connection.user.login}/${sanitizedRepoName}`, }), ); // Show success dialog setShowSuccessDialog(true); } catch (error) { console.error('Error pushing to GitHub:', error); // Attempt to extract more specific error information let errorMessage = 'Failed to push to GitHub'; let isRetryable = false; if (error instanceof Error) { const errorMsg = error.message.toLowerCase(); if (errorMsg.includes('network') || errorMsg.includes('fetch failed') || errorMsg.includes('connection')) { errorMessage = 'Network error. Please check your internet connection and try again.'; isRetryable = true; } else if (errorMsg.includes('401') || errorMsg.includes('unauthorized')) { errorMessage = 'GitHub authentication failed. Please check your access token in Settings > Connections.'; } else if (errorMsg.includes('403') || errorMsg.includes('forbidden')) { errorMessage = 'Access denied. Your GitHub token may not have sufficient permissions to create/modify repositories.'; } else if (errorMsg.includes('404') || errorMsg.includes('not found')) { errorMessage = 'Repository or resource not found. Please check the repository name and your permissions.'; } else if (errorMsg.includes('422') || errorMsg.includes('validation failed')) { if (errorMsg.includes('name already exists')) { errorMessage = 'A repository with this name already exists in your account. Please choose a different name.'; } else { errorMessage = 'Repository validation failed. Please check the repository name and settings.'; } } else if (errorMsg.includes('rate limit') || errorMsg.includes('429')) { errorMessage = 'GitHub API rate limit exceeded. Please wait a moment and try again.'; isRetryable = true; } else if (errorMsg.includes('timeout')) { errorMessage = 'Request timed out. Please check your connection and try again.'; isRetryable = true; } else { errorMessage = `GitHub error: ${error.message}`; } } else if (typeof error === 'object' && error !== null) { // Octokit errors if ('message' in error) { errorMessage = `GitHub API error: ${error.message as string}`; } // GitHub API errors if ('documentation_url' in error) { console.log('GitHub API documentation:', error.documentation_url); } } // Show error with retry suggestion if applicable const finalMessage = isRetryable ? `${errorMessage} Click to retry.` : errorMessage; toast.error(finalMessage); // Log detailed error for debugging console.error('Detailed GitHub deployment error:', { error, repoName: sanitizeRepoName(repoName), user: connection?.user?.login, isRetryable, }); } finally { setIsLoading(false); } }; const handleClose = () => { setRepoName(''); setIsPrivate(false); setShowSuccessDialog(false); setCreatedRepoUrl(''); onClose(); }; const handleAuthDialogClose = () => { setShowAuthDialog(false); // Refresh user data after auth const connection = getLocalStorage('github_connection'); if (connection?.user && connection?.token) { setUser(connection.user); fetchRecentRepos(connection.token); } }; // Success Dialog if (showSuccessDialog) { return ( !open && handleClose()}>
Successfully pushed to GitHub

Successfully pushed to GitHub

Your code is now available on GitHub

Repository URL

{createdRepoUrl} { navigator.clipboard.writeText(createdRepoUrl); toast.success('URL copied to clipboard'); }} className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-4 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark" whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} >

Pushed Files ({pushedFiles.length})

{pushedFiles.slice(0, 100).map((file) => (
{file.path} {(file.size / 1024).toFixed(1)} KB
))} {pushedFiles.length > 100 && (
+{pushedFiles.length - 100} more files
)}
View Repository { navigator.clipboard.writeText(createdRepoUrl); toast.success('URL copied to clipboard'); }} className="px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 text-sm inline-flex items-center gap-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark" whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} >
Copy URL Close
); } if (!user) { return ( !open && handleClose()}>
GitHub Connection Required

GitHub Connection Required

To deploy your code to GitHub, you need to connect your GitHub account first.

Close setShowAuthDialog(true)} className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2" whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} >
Connect GitHub Account
{/* GitHub Auth Dialog */} ); } return ( !open && handleClose()}>
Deploy to GitHub

Deploy your code to a new or existing GitHub repository

{user.login}

{user.name || user.login}

@{user.login}

{ const value = e.target.value; setRepoName(value); // Show real-time feedback for invalid characters const sanitized = sanitizeRepoName(value); if (value && value !== sanitized) { // Show preview of sanitized name without being too intrusive e.target.setAttribute('data-sanitized', sanitized); } else { e.target.removeAttribute('data-sanitized'); } }} placeholder="my-awesome-project" className="w-full pl-10 px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary-dark focus:outline-none focus:ring-2 focus:ring-purple-500" required maxLength={100} pattern="[a-zA-Z0-9\-_\s]+" title="Repository name can contain letters, numbers, hyphens, underscores, and spaces" />
{repoName && sanitizeRepoName(repoName) !== repoName && (

Will be created as:{' '} {sanitizeRepoName(repoName)}

)}
{filteredRepos.length} of {recentRepos.length}
setRepoSearchQuery(e.target.value)} onClear={() => setRepoSearchQuery('')} className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-sm" />
{recentRepos.length === 0 && !isFetchingRepos ? ( ) : (
{filteredRepos.length === 0 && repoSearchQuery.trim() !== '' ? ( ) : ( filteredRepos.map((repo) => ( setRepoName(repo.name)} className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark hover:border-purple-500/30" whileHover={{ scale: 1.01 }} whileTap={{ scale: 0.99 }} >
{repo.name}
{repo.private && ( Private )}
{repo.description && (

{repo.description}

)}
{repo.language && ( {repo.language} )} {repo.stargazers_count.toLocaleString()} {repo.forks_count.toLocaleString()} {new Date(repo.updated_at).toLocaleDateString()}
)) )}
)}
{isFetchingRepos && (
)}
setIsPrivate(e.target.checked)} className="rounded border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-purple-500 focus:ring-purple-500 dark:bg-bolt-elements-background-depth-3" />

Private repositories are only visible to you and people you share them with

Cancel {isLoading ? ( <>
Deploying... ) : ( <>
Deploy to GitHub )}
{/* GitHub Auth Dialog */} ); }