diff --git a/app/commit.json b/app/commit.json new file mode 100644 index 000000000..dcafaee1b --- /dev/null +++ b/app/commit.json @@ -0,0 +1 @@ +{ "commit": "b07702d0bc09434df42448248354821658ad14e7" } diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 7c2a9da12..1534ad09d 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -545,7 +545,7 @@ export const BaseChat = React.forwardRef( {!chatStarted && (
{ImportButtons(importChat)} - + {importChat && }
)} {!chatStarted && diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx index 4fe4c55e6..2313bf868 100644 --- a/app/components/chat/GitCloneButton.tsx +++ b/app/components/chat/GitCloneButton.tsx @@ -3,6 +3,8 @@ import { useGit } from '~/lib/hooks/useGit'; import type { Message } from 'ai'; import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands'; import { generateId } from '~/utils/fileUtils'; +import { useState } from 'react'; +import { GitCloneModal } from '~/components/git/GitCloneModal'; const IGNORE_PATTERNS = [ 'node_modules/**', @@ -31,47 +33,43 @@ const IGNORE_PATTERNS = [ const ig = ignore().add(IGNORE_PATTERNS); interface GitCloneButtonProps { - className?: string; importChat?: (description: string, messages: Message[]) => Promise; } export default function GitCloneButton({ importChat }: GitCloneButtonProps) { const { ready, gitClone } = useGit(); - const onClick = async (_e: any) => { + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleClone = async (repoUrl: string) => { if (!ready) { return; } - const repoUrl = prompt('Enter the Git url'); - - if (repoUrl) { - const { workdir, data } = await gitClone(repoUrl); - - if (importChat) { - const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); - console.log(filePaths); + const { workdir, data } = await gitClone(repoUrl); - const textDecoder = new TextDecoder('utf-8'); + if (importChat) { + const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); + const textDecoder = new TextDecoder('utf-8'); - // Convert files to common format for command detection - const fileContents = filePaths - .map((filePath) => { - const { data: content, encoding } = data[filePath]; - return { - path: filePath, - content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '', - }; - }) - .filter((f) => f.content); + // Convert files to common format for command detection + const fileContents = filePaths + .map((filePath) => { + const { data: content, encoding } = data[filePath]; + return { + path: filePath, + content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '', + }; + }) + .filter((f) => f.content); - // Detect and create commands message - const commands = await detectProjectCommands(fileContents); - const commandsMessage = createCommandsMessage(commands); + // Detect and create commands message + const commands = await detectProjectCommands(fileContents); + const commandsMessage = createCommandsMessage(commands); - // Create files message - const filesMessage: Message = { - role: 'assistant', - content: `Cloning the repo ${repoUrl} into ${workdir} + // Create files message + const filesMessage: Message = { + role: 'assistant', + content: `Cloning the repo ${repoUrl} into ${workdir} ${fileContents .map( @@ -82,29 +80,32 @@ ${file.content} ) .join('\n')} `, - id: generateId(), - createdAt: new Date(), - }; + id: generateId(), + createdAt: new Date(), + }; - const messages = [filesMessage]; + const messages = [filesMessage]; - if (commandsMessage) { - messages.push(commandsMessage); - } - - await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages); + if (commandsMessage) { + messages.push(commandsMessage); } + + await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages); } }; return ( - + <> + + + setIsModalOpen(false)} onClone={handleClone} /> + ); } diff --git a/app/components/git/GitCloneModal.tsx b/app/components/git/GitCloneModal.tsx new file mode 100644 index 000000000..dbb3c9e7d --- /dev/null +++ b/app/components/git/GitCloneModal.tsx @@ -0,0 +1,199 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Dialog, DialogRoot } from '~/components/ui/Dialog'; +import { GitHubAuth } from '~/components/github/GitHubAuth'; +import { getGitHubUser, getUserRepos } from '~/lib/github/github.client'; +import { toast } from 'react-toastify'; +import { GitCloneSpinner } from './GitCloneSpinner'; + +interface GitCloneModalProps { + open: boolean; + onClose: () => void; + onClone: (url: string) => Promise; +} + +export function GitCloneModal({ open, onClose, onClone }: GitCloneModalProps) { + const [publicUrl, setPublicUrl] = useState(''); + const [userRepos, setUserRepos] = useState>([]); + const [selectedRepo, setSelectedRepo] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isCloning, setIsCloning] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [username, setUsername] = useState(''); + + const loadUserRepos = useCallback(async () => { + const token = localStorage.getItem('github_token'); + + if (!token) { + setIsAuthenticated(false); + return; + } + + try { + setIsLoading(true); + + const user = await getGitHubUser(token); + setUsername(user.login); + + const repos = await getUserRepos(token); + setUserRepos( + repos.map((repo) => ({ + name: repo.full_name, + url: repo.clone_url, + })), + ); + setIsAuthenticated(true); + } catch (error) { + console.error('Error loading repos:', error); + toast.error('Failed to load repositories'); + + if (error instanceof Error && 'status' in error && (error.status === 401 || error.status === 403)) { + localStorage.removeItem('github_token'); + setIsAuthenticated(false); + } + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (open) { + loadUserRepos(); + } + }, [open, loadUserRepos]); + + const handleAuthComplete = useCallback( + async (token: string) => { + try { + const user = await getGitHubUser(token); + setUsername(user.login); + setIsAuthenticated(true); + loadUserRepos(); + } catch (error) { + console.error('Auth error:', error); + toast.error('Authentication failed'); + localStorage.removeItem('github_token'); + setIsAuthenticated(false); + } + }, + [loadUserRepos], + ); + + const handleClone = useCallback(async () => { + try { + const cloneUrl = selectedRepo || publicUrl; + + if (cloneUrl) { + setIsCloning(true); + onClose(); // Close the modal immediately when starting clone + await onClone(cloneUrl); + setIsCloning(false); + } + } catch (error) { + console.error('Clone error:', error); + toast.error('Failed to clone repository'); + setIsCloning(false); + } + }, [selectedRepo, publicUrl, onClone, onClose]); + + return ( + <> + + +
+

Clone Repository

+ +
+ +
+ {(!selectedRepo || !isAuthenticated) && ( +
+
Public Repository URL
+ { + setPublicUrl(e.target.value); + + if (e.target.value && selectedRepo) { + setSelectedRepo(''); + } + }} + className="w-full p-2 h-[32px] rounded bg-[#2D2D2D] border border-[#383838] text-white placeholder-[#8B8B8B] focus:outline-none focus:border-[#525252]" + /> +
+ )} + +
+
+ {isAuthenticated ? `${username}'s GitHub Repositories` : 'Your GitHub Repositories'} +
+ {isAuthenticated ? ( + + ) : ( + toast.error(error.message)}> + + + )} + {isLoading && ( +
+
+
+ )} +
+ +
+ + +
+
+
+
+ + + ); +} diff --git a/app/components/git/GitCloneSpinner.tsx b/app/components/git/GitCloneSpinner.tsx new file mode 100644 index 000000000..8b0ca60b4 --- /dev/null +++ b/app/components/git/GitCloneSpinner.tsx @@ -0,0 +1,25 @@ +interface GitCloneSpinnerProps { + isOpen: boolean; +} + +export function GitCloneSpinner({ isOpen }: GitCloneSpinnerProps) { + if (!isOpen) { + return null; + } + + return ( + <> + {/* Full screen blocker that prevents all interactions */} +
+ + {/* Spinner overlay */} +
+
+
+
Cloning Repository...
+
This may take a few moments
+
+
+ + ); +} diff --git a/app/components/github/GitHubAuth.tsx b/app/components/github/GitHubAuth.tsx new file mode 100644 index 000000000..c77db2c94 --- /dev/null +++ b/app/components/github/GitHubAuth.tsx @@ -0,0 +1,215 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { GITHUB_CONFIG } from '~/lib/github/config'; + +interface GitHubAuthProps { + onAuthComplete?: (token: string) => void; + onError?: (error: Error) => void; + onAuthStart?: () => void; + children?: React.ReactNode; +} + +interface GitHubErrorResponse { + error?: string; + error_description?: string; +} + +interface DeviceCodeResponse extends GitHubErrorResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +interface AccessTokenResponse extends GitHubErrorResponse { + access_token?: string; +} + +export function GitHubAuth({ onAuthComplete, onError, onAuthStart, children }: GitHubAuthProps) { + const [isLoading, setIsLoading] = useState(false); + const [userCode, setUserCode] = useState(null); + const [verificationUrl, setVerificationUrl] = useState(null); + const [isPolling, setIsPolling] = useState(false); + const [showCopied, setShowCopied] = useState(false); + + // Reset states when auth completes + const handleAuthSuccess = useCallback( + (token: string) => { + setUserCode(null); + setVerificationUrl(null); + setIsPolling(false); + setIsLoading(false); + onAuthComplete?.(token); + }, + [onAuthComplete], + ); + + const pollForToken = useCallback( + async (code: string, interval: number, attempts = 0) => { + if (attempts >= GITHUB_CONFIG.maxPollAttempts) { + setIsPolling(false); + onError?.(new Error('Authentication timed out. Please try again.')); + + return; + } + + try { + const params = new URLSearchParams({ + endpoint: GITHUB_CONFIG.accessTokenEndpoint, + client_id: GITHUB_CONFIG.clientId, + device_code: code, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }); + + const response = await fetch(`/api/github/proxy?${params}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = (await response.json()) as AccessTokenResponse; + + if (data.error === 'authorization_pending') { + // Authorization is pending, continue polling + setTimeout(() => pollForToken(code, interval, attempts + 1), interval * 1000); + return; + } + + if (data.error || data.error_description) { + throw new Error(data.error_description || data.error || 'Authentication failed'); + } + + if (!data.access_token) { + throw new Error('Invalid response from GitHub'); + } + + localStorage.setItem('github_token', data.access_token); + handleAuthSuccess(data.access_token); + } catch (error: any) { + setIsPolling(false); + onError?.(error); + } + }, + [onAuthComplete, onError], + ); + + const handleStartAuth = useCallback(async () => { + try { + setIsLoading(true); + + const params = new URLSearchParams({ + endpoint: GITHUB_CONFIG.deviceCodeEndpoint, + client_id: GITHUB_CONFIG.clientId, + scope: GITHUB_CONFIG.scope, + }); + + const response = await fetch(`/api/github/proxy?${params}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = (await response.json()) as DeviceCodeResponse; + + if (data.error || data.error_description) { + throw new Error(data.error_description || data.error || 'Failed to start authentication process'); + } + + if (!data.device_code || !data.user_code || !data.verification_uri) { + throw new Error('Invalid response from GitHub'); + } + + setUserCode(data.user_code); + setVerificationUrl(data.verification_uri); + setIsPolling(true); + + pollForToken(data.device_code, data.interval || 5); + } catch (error: any) { + setIsLoading(false); + onError?.(error); + } finally { + setIsLoading(false); + } + }, [pollForToken, onError]); + + useEffect(() => { + if (!children) { + handleStartAuth(); + } + }, []); + + useEffect(() => { + return () => { + setIsPolling(false); + setIsLoading(false); + }; + }, []); + + const handleCopyCode = useCallback(() => { + if (userCode) { + navigator.clipboard.writeText(userCode); + setShowCopied(true); + setTimeout(() => setShowCopied(false), 2000); + } + }, [userCode]); + + if (isLoading) { + return ( +
+
+

Initializing GitHub authentication...

+
+ ); + } + + if (userCode && verificationUrl) { + return ( +
+

+ Enter this code at{' '} + + {verificationUrl} + +

+
+ {userCode} + +
+ {isPolling && ( +

+ Waiting for authorization... You can close the GitHub window once authorized. +

+ )} +
+ ); + } + + if (!children) { + return null; + } + + return React.cloneElement(children as React.ReactElement, { + onClick: (e: React.MouseEvent) => { + e.preventDefault(); + + if (onAuthStart) { + onAuthStart(); + } + + handleStartAuth(); + (children as React.ReactElement).props.onClick?.(e); + }, + }); +} diff --git a/app/components/github/GitHubAuthModal.tsx b/app/components/github/GitHubAuthModal.tsx new file mode 100644 index 000000000..6e1ebcac4 --- /dev/null +++ b/app/components/github/GitHubAuthModal.tsx @@ -0,0 +1,325 @@ +import { Dialog, DialogRoot } from '~/components/ui/Dialog'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { Octokit } from '@octokit/rest'; +import { toast } from 'react-toastify'; +import { GitHubAuth } from '~/components/github/GitHubAuth'; +import { getGitHubUser } from '~/lib/github/github.client'; + +interface GitHubAuthModalProps { + isOpen: boolean; + onClose: () => void; + onPushComplete?: (success: boolean, repoUrl?: string) => void; + onAuthComplete?: (token: string) => void; + initialToken?: string | null; +} + +export function GitHubAuthModal({ + isOpen, + onClose, + onPushComplete, + onAuthComplete, + initialToken, +}: GitHubAuthModalProps) { + const [error, setError] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [repoName, setRepoName] = useState('bolt-generated-project'); + const [repoVisibility, setRepoVisibility] = useState(false); + const [user, setUser] = useState<{ login: string } | null>(null); + const [token, setToken] = useState(null); + const [isAuthenticating, setIsAuthenticating] = useState(false); + const [isUpdatingVisibility, setIsUpdatingVisibility] = useState(false); + const hasShownToast = useRef(false); + const checkTimeoutRef = useRef(); + + // If we have an initial token, validate and use it + useEffect(() => { + if (initialToken && !isAuthenticated) { + getGitHubUser(initialToken) + .then((githubUser) => { + setUser(githubUser); + setToken(initialToken); + setIsAuthenticated(true); + }) + .catch((error) => { + console.error('Failed to validate token:', error); + setError('Failed to validate GitHub token. Please authenticate again.'); + }); + } + }, [initialToken, isAuthenticated]); + + const checkRepoVisibility = useCallback( + async (name: string) => { + if (!isAuthenticated || !user || !token) { + return; + } + + try { + const octokit = new Octokit({ auth: token }); + + try { + const { data: repo } = await octokit.repos.get({ + owner: user.login, + repo: name, + }); + setRepoVisibility(repo.private); + } catch (error) { + if (error instanceof Error && 'status' in error && error.status === 404) { + // Repository doesn't exist yet, set to public + setRepoVisibility(false); + } else { + console.error('Error checking repo visibility:', error); + } + } + } catch (error) { + console.error('Error initializing Octokit:', error); + } + }, + [isAuthenticated, user, token], + ); + + const handleRepoNameChange = useCallback( + (e: React.ChangeEvent) => { + const newName = e.target.value; + setRepoName(newName); + + // Clear any existing timeout + if (checkTimeoutRef.current) { + clearTimeout(checkTimeoutRef.current); + } + + // Set new timeout to check repo after 1000ms of no typing + checkTimeoutRef.current = setTimeout(() => { + checkRepoVisibility(newName); + }, 1000); + }, + [checkRepoVisibility], + ); + + // Clean up timeout on unmount + useEffect(() => { + return () => { + if (checkTimeoutRef.current) { + clearTimeout(checkTimeoutRef.current); + } + }; + }, []); + + const handleAuthComplete = useCallback(async (authToken: string) => { + setIsAuthenticating(true); + + try { + const githubUser = await getGitHubUser(authToken); + setUser(githubUser); + setToken(authToken); + setIsAuthenticated(true); + + if (!hasShownToast.current) { + toast.success('Successfully authenticated with GitHub!'); + hasShownToast.current = true; + } + } catch (error: any) { + console.error('Failed to get GitHub user:', error); + setError('Failed to get GitHub user info. Please try again.'); + toast.error('Failed to authenticate with GitHub: ' + (error.message || 'Unknown error')); + } finally { + setIsAuthenticating(false); + } + }, []); + + const handleCreateRepo = useCallback(async () => { + if (!repoName.trim()) { + setError('Repository name is required'); + return; + } + + if (!token || !user) { + setError('Not authenticated with GitHub'); + return; + } + + onAuthComplete?.(token); + + try { + // Always use force push + const result = await workbenchStore.pushToGitHub(repoName, user.login, token, true, repoVisibility); + onPushComplete?.(true, result.html_url); + } catch (error) { + console.error('Failed to push to GitHub:', error); + setError(error instanceof Error ? error.message : 'Failed to push to GitHub'); + onPushComplete?.(false); + } + }, [repoName, user, token, onAuthComplete, onPushComplete, repoVisibility]); + + // Monitor localStorage for GitHub token + useEffect(() => { + if (isAuthenticating) { + const checkToken = () => { + const token = localStorage.getItem('github_token'); + + if (token) { + setIsAuthenticating(false); + handleAuthComplete(token); + } + + return undefined; + }; + + // Check immediately and then set up interval + checkToken(); + + const interval = setInterval(checkToken, 500); + + // Cleanup interval + return () => clearInterval(interval); + } + + return undefined; + }, [isAuthenticating, handleAuthComplete]); + + // Clear state when modal closes + useEffect(() => { + if (!isOpen) { + setError(null); + setIsAuthenticated(false); + setRepoName('bolt-generated-project'); + setRepoVisibility(false); + setUser(null); + setToken(null); + hasShownToast.current = false; + } + }, [isOpen]); + + return ( + + {isAuthenticating ? ( +
+
+
+

Authenticating with GitHub...

+
+
+ ) : ( + +

+ {isAuthenticated ? 'Push GitHub Repository' : 'GitHub Authentication'} +

+ {!isAuthenticated ? ( + <> +

+ Authenticate with GitHub to push your project +

+ setError(error.message)}> + + + + ) : ( + <> +

Enter a name for your GitHub repository

+ {error &&
{error}
} +
+ +
+ Make Private + +
+ +
+ + )} +
+ )} + + ); +} diff --git a/app/components/github/GitHubPushOverlay.tsx b/app/components/github/GitHubPushOverlay.tsx new file mode 100644 index 000000000..f65ecb935 --- /dev/null +++ b/app/components/github/GitHubPushOverlay.tsx @@ -0,0 +1,10 @@ +export function GitHubPushOverlay() { + return ( +
+
+
+

Pushing your project to GitHub...

+
+
+ ); +} diff --git a/app/components/github/useGitHubPush.tsx b/app/components/github/useGitHubPush.tsx new file mode 100644 index 000000000..c8d203ed7 --- /dev/null +++ b/app/components/github/useGitHubPush.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { getGitHubUser } from '~/lib/github/github.client'; + +export function useGitHubPush() { + const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); + const [isPushingToGitHub, setIsPushingToGitHub] = useState(false); + + const handlePushToGitHub = async () => { + try { + // Check for existing GitHub token + const existingToken = localStorage.getItem('github_token'); + + if (existingToken) { + // Get the GitHub user info directly to validate token + await getGitHubUser(existingToken); + } + + // Show auth modal, passing the existing token if we have one + setIsAuthModalOpen(true); + } catch (error) { + console.error('Failed to use existing GitHub token:', error); + + // If token is invalid, remove it + localStorage.removeItem('github_token'); + setIsAuthModalOpen(true); + } + }; + + const handleAuthComplete = async () => { + setIsAuthModalOpen(false); + setIsPushingToGitHub(true); + }; + + const handlePushComplete = (success: boolean, repoUrl?: string) => { + setIsPushingToGitHub(false); + + if (success) { + toast.success( +
+ Successfully pushed to GitHub! + {repoUrl && ( + + View Repository → + + )} +
, + { autoClose: 5000 }, + ); + } else { + toast.error('Failed to push to GitHub. Please try again.'); + } + }; + + return { + isAuthModalOpen, + isPushingToGitHub, + setIsAuthModalOpen, + handlePushToGitHub, + handleAuthComplete, + handlePushComplete, + }; +} diff --git a/app/components/settings/features/FeaturesTab.tsx b/app/components/settings/features/FeaturesTab.tsx index 0af3d401c..7fb4afaea 100644 --- a/app/components/settings/features/FeaturesTab.tsx +++ b/app/components/settings/features/FeaturesTab.tsx @@ -60,6 +60,7 @@ export default function FeaturesTab() {

renderLogger.trace('Workbench'); const [isSyncing, setIsSyncing] = useState(false); + const { + isAuthModalOpen, + isPushingToGitHub, + setIsAuthModalOpen, + handlePushToGitHub, + handleAuthComplete, + handlePushComplete, + } = useGitHubPush(); const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0)); const showWorkbench = useStore(workbenchStore.showWorkbench); @@ -168,37 +178,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
Toggle Terminal - { - 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 = Cookies.get('githubUsername'); - const githubToken = Cookies.get('githubToken'); - - if (!githubUsername || !githubToken) { - const usernameInput = prompt('Please enter your GitHub username:'); - const tokenInput = prompt('Please enter your GitHub personal access token:'); - - if (!usernameInput || !tokenInput) { - alert('GitHub username and token are required. Push to GitHub cancelled.'); - return; - } - - workbenchStore.pushToGitHub(repoName, usernameInput, tokenInput); - } else { - workbenchStore.pushToGitHub(repoName, githubUsername, githubToken); - } - }} - > +
Push to GitHub @@ -241,10 +221,21 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
+ setIsAuthModalOpen(false)} + onAuthComplete={handleAuthComplete} + onPushComplete={handlePushComplete} + initialToken={localStorage.getItem('github_token')} + /> + + {/* Loading Overlay */} + {isPushingToGitHub && } ) ); }); + interface ViewProps extends HTMLMotionProps<'div'> { children: JSX.Element; } diff --git a/app/lib/github/config.ts b/app/lib/github/config.ts new file mode 100644 index 000000000..222d9be87 --- /dev/null +++ b/app/lib/github/config.ts @@ -0,0 +1,10 @@ +export const GITHUB_CONFIG = { + clientId: import.meta.env.VITE_GITHUB_CLIENT_ID || '', + scope: 'read:user repo', + proxyUrl: '/api/github/proxy', + deviceCodeEndpoint: '/login/device/code', + accessTokenEndpoint: '/login/oauth/access_token', + userApiUrl: 'https://api.github.com/user', + pollInterval: 5, + maxPollAttempts: 12, +} as const; diff --git a/app/lib/github/github.client.ts b/app/lib/github/github.client.ts new file mode 100644 index 000000000..feeb3438b --- /dev/null +++ b/app/lib/github/github.client.ts @@ -0,0 +1,95 @@ +export interface GitHubUser { + login: string; + id: number; + avatar_url: string; + name?: string; + email?: string; +} + +export interface GitHubRepo { + id: number; + name: string; + full_name: string; + private: boolean; + clone_url: string; + html_url: string; +} + +interface GitHubRepoCreate { + name: string; + owner: { + login: string; + }; +} + +async function githubRequest(endpoint: string, token: string, method = 'GET', body?: any): Promise { + const url = new URL('/api/github/proxy', window.location.origin); + url.searchParams.set('endpoint', endpoint); + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; + + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const error = new Error('GitHub API request failed') as any; + error.status = response.status; + throw error; + } + + return response.json(); +} + +export async function getGitHubUser(token: string): Promise { + return githubRequest('/user', token); +} + +export async function getUserRepos(token: string): Promise { + const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100', { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + if (!response.ok) { + const error = new Error('Failed to fetch user repositories') as any; + error.status = response.status; + throw error; + } + + return response.json(); +} + +export async function createRepository(token: string, name: string, description?: string): Promise { + // First create the repository without auto_init + const repo = await githubRequest('/user/repos', token, 'POST', { + name, + description: description || 'Created with Bolt', + private: false, + auto_init: false, + }); + + // Create README.md with proper content + const readmeContent = `# ${name}\n\nThis project was created using bolt.diy, the official open source version of Bolt.new (previously known as oTToDev and bolt.new ANY LLM).`; + + // Encode content in base64 + const content = Buffer.from(readmeContent).toString('base64'); + + // Create README.md file + await githubRequest(`/repos/${repo.owner.login}/${name}/contents/README.md`, token, 'PUT', { + message: 'Initial commit: Add README.md', + content, + }); + + // Wait a bit for GitHub to process + await new Promise((resolve) => setTimeout(resolve, 1000)); + + return repo; +} diff --git a/app/lib/github/github.service.ts b/app/lib/github/github.service.ts new file mode 100644 index 000000000..a1b9706d4 --- /dev/null +++ b/app/lib/github/github.service.ts @@ -0,0 +1,228 @@ +import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest'; +import Cookies from 'js-cookie'; +import { extractRelativePath } from '~/utils/diff'; + +export class GitHubService { + private async _getOctokit(githubUsername?: string, ghToken?: string) { + const githubToken = ghToken || Cookies.get('githubToken'); + const owner = githubUsername || Cookies.get('githubUsername'); + + if (!githubToken || !owner) { + throw new Error('GitHub token or username is not set in cookies or provided.'); + } + + return { octokit: new Octokit({ auth: githubToken }), owner }; + } + + private async _waitForVisibilityChange( + octokit: Octokit, + owner: string, + repoName: string, + expectedPrivate: boolean, + maxAttempts = 10, + ): Promise { + for (let i = 0; i < maxAttempts; i++) { + try { + const { data: repo } = await octokit.repos.get({ + owner, + repo: repoName, + }); + + if (repo.private === expectedPrivate) { + return true; + } + } catch (error) { + console.error('Error checking repository visibility:', error); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return false; + } + + async updateRepoVisibility(repoName: string, isPrivate: boolean, githubUsername?: string, ghToken?: string) { + try { + const { octokit, owner } = await this._getOctokit(githubUsername, ghToken); + + try { + // Check if repo exists + const { data: repo } = await octokit.repos.get({ + owner, + repo: repoName, + }); + + if (repo.private === isPrivate) { + return { ...repo, message: `Repository is already ${isPrivate ? 'private' : 'public'}` }; + } + + // Update visibility + console.log(`Updating repository visibility to ${isPrivate ? 'private' : 'public'}`); + await octokit.repos.update({ + owner, + repo: repoName, + private: isPrivate, + }); + + // Wait for visibility change to be confirmed + const visibilityUpdated = await this._waitForVisibilityChange(octokit, owner, repoName, isPrivate); + + if (visibilityUpdated) { + console.log('Repository visibility update confirmed'); + + const { data: updatedRepo } = await octokit.repos.get({ + owner, + repo: repoName, + }); + + return { ...updatedRepo, message: `Repository visibility updated to ${isPrivate ? 'private' : 'public'}` }; + } else { + throw new Error('Repository visibility update could not be confirmed'); + } + } catch (error) { + if (error instanceof Error && 'status' in error && error.status === 404) { + // Create new repository if it doesn't exist + const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({ + name: repoName, + private: isPrivate, + auto_init: true, + }); + + // Wait for repository to be accessible + const repoReady = await this._waitForVisibilityChange(octokit, owner, repoName, isPrivate); + + if (!repoReady) { + throw new Error('Repository creation could not be confirmed'); + } + + return { ...newRepo, message: 'New repository created successfully' }; + } + + throw error; + } + } catch (error) { + console.error('Error updating repository visibility:', error); + throw error; + } + } + + async pushToGitHub( + files: Record, + repoName: string, + githubUsername?: string, + ghToken?: string, + force: boolean = true, + isPrivate: boolean = false, + ) { + const { octokit, owner } = await this._getOctokit(githubUsername, ghToken); + + // Get repository info + let repo: RestEndpointMethodTypes['repos']['get']['response']['data']; + + try { + const { data: existingRepo } = await octokit.repos.get({ + owner, + repo: repoName, + }); + repo = existingRepo; + } catch { + // If repo doesn't exist, create it with the specified visibility + try { + const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({ + name: repoName, + private: isPrivate, + auto_init: true, + }); + + // Wait for repository to be accessible + const repoReady = await this._waitForVisibilityChange(octokit, owner, repoName, isPrivate); + + if (!repoReady) { + throw new Error('Repository creation could not be confirmed'); + } + + repo = newRepo; + } catch (createError) { + const errorMessage = + createError instanceof Error ? createError.message : 'Unknown error occurred while creating repository'; + throw new Error(`Failed to create repository: ${errorMessage}`); + } + } + + if (!files || Object.keys(files).length === 0) { + return { ...repo, message: 'No files to push.' }; + } + + try { + // Create blobs for new files + const blobs = await Promise.all( + Object.entries(files).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', + }); + + // Use extractRelativePath to ensure we don't have leading slashes + return { path: extractRelativePath(filePath), sha: blob.sha }; + } + + return null; + }), + ); + + const validBlobs = blobs.filter(Boolean); + + if (validBlobs.length === 0) { + return { ...repo, message: 'No valid files to push.' }; + } + + // Get current commit and ensure we have the latest + const defaultBranch = repo.default_branch || 'main'; + const { data: ref } = await octokit.git.getRef({ + owner: repo.owner.login, + repo: repo.name, + ref: `heads/${defaultBranch}`, + }); + + // Create new tree + const { data: newTree } = await octokit.git.createTree({ + owner: repo.owner.login, + repo: repo.name, + base_tree: ref.object.sha, + tree: validBlobs.map((blob) => ({ + path: blob!.path, + mode: '100644', + type: 'blob', + sha: blob!.sha, + })), + }); + + // Create new commit + const { data: newCommit } = await octokit.git.createCommit({ + owner: repo.owner.login, + repo: repo.name, + message: 'Update from Bolt', + tree: newTree.sha, + parents: [ref.object.sha], + }); + + // Update reference with force flag if specified + await octokit.git.updateRef({ + owner: repo.owner.login, + repo: repo.name, + ref: `heads/${defaultBranch}`, + sha: newCommit.sha, + force, + }); + + return { + ...repo, + message: 'Successfully pushed changes to GitHub', + commitSha: newCommit.sha, + }; + } catch (error: any) { + throw new Error(`Failed to push to GitHub: ${error.message}`); + } + } +} diff --git a/app/lib/github/useGitHubAuth.ts b/app/lib/github/useGitHubAuth.ts new file mode 100644 index 000000000..4a6bb78ca --- /dev/null +++ b/app/lib/github/useGitHubAuth.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useState } from 'react'; +import { getGitHubUser, type GitHubUser } from './github.client'; + +export function useGitHubAuth() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [user, setUser] = useState(null); + + const checkAuth = useCallback(async () => { + const token = localStorage.getItem('github_token'); + + if (!token) { + setIsAuthenticated(false); + setUser(null); + setIsLoading(false); + + return; + } + + try { + const userInfo = await getGitHubUser(token); + setUser(userInfo); + setIsAuthenticated(true); + } catch (error) { + // Only remove token if it's an auth error (401 or 403) + if (error instanceof Error && 'status' in error && (error.status === 401 || error.status === 403)) { + localStorage.removeItem('github_token'); + } + + setIsAuthenticated(false); + setUser(null); + } finally { + setIsLoading(false); + } + }, []); + + // Initial auth check + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + // Re-run auth check when window regains focus + useEffect(() => { + window.addEventListener('focus', checkAuth); + + return () => { + window.removeEventListener('focus', checkAuth); + }; + }, [checkAuth]); + + const handleAuthComplete = useCallback(async (token: string) => { + try { + const userInfo = await getGitHubUser(token); + setUser(userInfo); + setIsAuthenticated(true); + } catch (error) { + localStorage.removeItem('github_token'); + setIsAuthenticated(false); + setUser(null); + throw error; + } + }, []); + + const handleLogout = useCallback(() => { + localStorage.removeItem('github_token'); + setUser(null); + setIsAuthenticated(false); + }, []); + + return { + isAuthenticated, + isLoading, + user, + handleAuthComplete, + handleLogout, + }; +} diff --git a/app/lib/hooks/useGit.ts b/app/lib/hooks/useGit.ts index 3c8c61bb2..c65086ac2 100644 --- a/app/lib/hooks/useGit.ts +++ b/app/lib/hooks/useGit.ts @@ -49,6 +49,11 @@ export function useGit() { } fileData.current = {}; + + // Check if this is a GitHub URL and we have a token + const isGithubUrl = url.includes('github.com'); + const githubToken = isGithubUrl ? localStorage.getItem('github_token') : null; + await git.clone({ fs, http, @@ -58,8 +63,15 @@ export function useGit() { singleBranch: true, corsProxy: 'https://cors.isomorphic-git.org', onAuth: (url) => { - // let domain=url.split("/")[2] + // If we have a GitHub token, use it + if (isGithubUrl && githubToken) { + return { + username: githubToken, + password: 'x-oauth-basic', + }; + } + // For non-GitHub repos, use saved credentials let auth = lookupSavedPassword(url); if (auth) { @@ -80,7 +92,10 @@ export function useGit() { toast.error(`Error Authenticating with ${url.split('/')[2]}`); }, onAuthSuccess: (url, auth) => { - saveGitAuth(url, auth); + // Only save non-GitHub credentials + if (!isGithubUrl) { + saveGitAuth(url, auth); + } }, }); @@ -92,7 +107,7 @@ export function useGit() { return { workdir: webcontainer.workdir, data }; }, - [webcontainer], + [webcontainer, fs, ready], ); return { ready, gitClone }; diff --git a/app/lib/hooks/useSettings.tsx b/app/lib/hooks/useSettings.tsx index 3f5515102..56d0c7f84 100644 --- a/app/lib/hooks/useSettings.tsx +++ b/app/lib/hooks/useSettings.tsx @@ -11,7 +11,7 @@ import { import { useCallback, useEffect, useState } from 'react'; import Cookies from 'js-cookie'; import type { IProviderSetting, ProviderInfo } from '~/types/model'; -import { logStore } from '~/lib/stores/logs'; // assuming logStore is imported from this location +import { logStore } from '~/lib/stores/logs'; interface CommitData { commit: string; @@ -25,11 +25,11 @@ const versionData: CommitData = { export function useSettings() { const providers = useStore(providersStore); - const debug = useStore(isDebugMode); - const eventLogs = useStore(isEventLogsEnabled); + const debug: boolean = useStore(isDebugMode); + const eventLogs: boolean = useStore(isEventLogsEnabled); const promptId = useStore(promptStore); - const isLocalModel = useStore(isLocalModelsEnabled); - const isLatestBranch = useStore(latestBranchStore); + const isLocalModel: boolean = useStore(isLocalModelsEnabled); + const isLatestBranch: boolean = useStore(latestBranchStore); const [activeProviders, setActiveProviders] = useState([]); // Function to check if we're on stable version @@ -179,6 +179,7 @@ export function useSettings() { promptStore.set(promptId); Cookies.set('promptId', promptId); }, []); + const enableLatestBranch = useCallback((enabled: boolean) => { latestBranchStore.set(enabled); logStore.logSystem(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`); diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index 0d46057db..925e312cc 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -11,11 +11,10 @@ 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 { GitHubService } from '~/lib/github/github.service'; import * as nodePath from 'node:path'; import { extractRelativePath } from '~/utils/diff'; import { description } from '~/lib/persistence'; -import Cookies from 'js-cookie'; import { createSampler } from '~/utils/sampler'; export interface ArtifactState { @@ -37,6 +36,7 @@ export class WorkbenchStore { #filesStore = new FilesStore(webcontainer); #editorStore = new EditorStore(this.#filesStore); #terminalStore = new TerminalStore(webcontainer); + #githubService = new GitHubService(); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); @@ -46,6 +46,7 @@ export class WorkbenchStore { modifiedFiles = new Set(); artifactIdList: string[] = []; #globalExecutionQueue = Promise.resolve(); + constructor() { if (import.meta.hot) { import.meta.hot.data.artifacts = this.artifacts; @@ -408,113 +409,19 @@ export class WorkbenchStore { return syncedFiles; } - async pushToGitHub(repoName: string, githubUsername?: string, ghToken?: string) { - try { - // Use cookies if username and token are not provided - const githubToken = ghToken || Cookies.get('githubToken'); - const owner = githubUsername || Cookies.get('githubUsername'); - - if (!githubToken || !owner) { - throw new Error('GitHub token or username is not set in cookies or provided.'); - } - - // 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']; - - try { - const resp = await octokit.repos.get({ 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, - }); - repo = newRepo; - } else { - console.log('cannot create repo!'); - throw error; // Some other error occurred - } - } - - // Get all files - 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]) => { - 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', - }); - return { path: extractRelativePath(filePath), sha: blob.sha }; - } - - return null; - }), - ); - - const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs - - if (validBlobs.length === 0) { - throw new Error('No valid files to push'); - } + async updateRepoVisibility(repoName: string, isPrivate: boolean, githubUsername?: string, ghToken?: string) { + return this.#githubService.updateRepoVisibility(repoName, isPrivate, githubUsername, ghToken); + } - // 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, - })), - }); - - // 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], - }); - - // 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, - }); - - alert(`Repository created and code pushed: ${repo.html_url}`); - } catch (error) { - console.error('Error pushing to GitHub:', error); - throw error; // Rethrow the error for further handling - } + async pushToGitHub( + repoName: string, + githubUsername?: string, + ghToken?: string, + force: boolean = true, + isPrivate: boolean = false, + ) { + const files = this.files.get(); + return this.#githubService.pushToGitHub(files, repoName, githubUsername, ghToken, force, isPrivate); } } diff --git a/app/routes/api.github.proxy.ts b/app/routes/api.github.proxy.ts new file mode 100644 index 000000000..15ea289e7 --- /dev/null +++ b/app/routes/api.github.proxy.ts @@ -0,0 +1,180 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/cloudflare'; + +interface GitHubErrorResponse { + error?: string; + error_description?: string; +} + +interface GitHubResponse extends GitHubErrorResponse { + [key: string]: unknown; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const targetEndpoint = url.searchParams.get('endpoint'); + const token = request.headers.get('Authorization'); + + if (!targetEndpoint) { + return new Response('Missing required parameters', { status: 400 }); + } + + // Determine if this is a GitHub API request or OAuth request + const isApiRequest = targetEndpoint.startsWith('/user') || targetEndpoint.startsWith('/repos'); + const baseUrl = isApiRequest ? 'https://api.github.com' : 'https://github.com'; + + /* + * For API requests, we need the token but not client_id + * For OAuth requests, we need client_id + */ + if (isApiRequest && !token) { + return new Response('Missing Authorization header', { status: 401 }); + } + + const githubUrl = `${baseUrl}${targetEndpoint}`; + const params = new URLSearchParams(); + + // Forward all query parameters to GitHub + url.searchParams.forEach((value, key) => { + if (key !== 'endpoint') { + params.append(key, value); + } + }); + + try { + const headers: Record = { + Accept: 'application/json', + }; + + if (token) { + headers.Authorization = token; + } + + const response = await fetch(`${githubUrl}${params.toString() ? `?${params}` : ''}`, { + method: 'GET', + headers, + }); + + const data = (await response.json()) as GitHubResponse; + + return new Response(JSON.stringify(data), { + status: response.status, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + } catch (error) { + return new Response( + JSON.stringify({ + error: 'Failed to proxy request', + details: error instanceof Error ? error.message : 'Unknown error', + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }, + ); + } +} + +export async function action({ request }: ActionFunctionArgs) { + const url = new URL(request.url); + const targetEndpoint = url.searchParams.get('endpoint'); + const clientId = url.searchParams.get('client_id'); + const token = request.headers.get('Authorization'); + + if (!targetEndpoint) { + return new Response('Missing required parameters', { status: 400 }); + } + + // Determine if this is a GitHub API request or OAuth request + const isApiRequest = targetEndpoint.startsWith('/user') || targetEndpoint.startsWith('/repos'); + const baseUrl = isApiRequest ? 'https://api.github.com' : 'https://github.com'; + + /* + * For API requests, we need the token but not client_id + * For OAuth requests, we need client_id + */ + if (isApiRequest && !token) { + return new Response('Missing Authorization header', { status: 401 }); + } + + if (!isApiRequest && !clientId) { + return new Response('Missing client_id', { status: 400 }); + } + + const githubUrl = `${baseUrl}${targetEndpoint}`; + const params = new URLSearchParams(); + + // Forward all query parameters to GitHub + url.searchParams.forEach((value, key) => { + if (key !== 'endpoint') { + params.append(key, value); + } + }); + + try { + const headers: Record = { + Accept: 'application/json', + }; + + if (token) { + headers.Authorization = token; + } + + const response = await fetch(`${githubUrl}${params.toString() ? `?${params}` : ''}`, { + method: request.method, + headers, + body: request.method !== 'GET' ? await request.text() : undefined, + }); + + const data = (await response.json()) as GitHubResponse; + + // Check if the response is an error + if (data.error) { + return new Response(JSON.stringify(data), { + status: data.error === 'authorization_pending' ? 202 : 400, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + } + + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + } catch (error) { + return new Response( + JSON.stringify({ + error: 'Failed to proxy request', + details: error instanceof Error ? error.message : 'Unknown error', + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }, + ); + } +} + +// Handle preflight requests +export async function options() { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); +}