diff --git a/app/commit.json b/app/commit.json deleted file mode 100644 index 0b3f44a2c..000000000 --- a/app/commit.json +++ /dev/null @@ -1 +0,0 @@ -{ "commit": "636f87f568a368dadc5cf3c077284710951e2488", "version": "0.0.3" } diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx index 4fe4c55e6..d17db6574 100644 --- a/app/components/chat/GitCloneButton.tsx +++ b/app/components/chat/GitCloneButton.tsx @@ -1,8 +1,8 @@ import ignore from 'ignore'; -import { useGit } from '~/lib/hooks/useGit'; import type { Message } from 'ai'; import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands'; import { generateId } from '~/utils/fileUtils'; +import { useGit } from '~/lib/git'; const IGNORE_PATTERNS = [ 'node_modules/**', diff --git a/app/components/git/GitUrlImport.client.tsx b/app/components/git/GitUrlImport.client.tsx index c2c949ec9..c6a439d23 100644 --- a/app/components/git/GitUrlImport.client.tsx +++ b/app/components/git/GitUrlImport.client.tsx @@ -5,11 +5,11 @@ import { useEffect, useState } from 'react'; import { ClientOnly } from 'remix-utils/client-only'; import { BaseChat } from '~/components/chat/BaseChat'; import { Chat } from '~/components/chat/Chat.client'; -import { useGit } from '~/lib/hooks/useGit'; import { useChatHistory } from '~/lib/persistence'; import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands'; import { LoadingOverlay } from '~/components/ui/LoadingOverlay'; import { toast } from 'react-toastify'; +import { useGit } from '~/lib/git'; const IGNORE_PATTERNS = [ 'node_modules/**', diff --git a/app/components/settings/connections/ConnectionsTab.tsx b/app/components/settings/connections/ConnectionsTab.tsx index 4b89022e7..aa82e2038 100644 --- a/app/components/settings/connections/ConnectionsTab.tsx +++ b/app/components/settings/connections/ConnectionsTab.tsx @@ -1,151 +1,36 @@ -import React, { useState, useEffect } from 'react'; -import { toast } from 'react-toastify'; -import Cookies from 'js-cookie'; -import { logStore } from '~/lib/stores/logs'; - -interface GitHubUserResponse { - login: string; - id: number; - [key: string]: any; // for other properties we don't explicitly need -} +import React from 'react'; +import { ProviderCard } from '~/lib/git/components/ProviderCard'; +import { useGitProviders } from '~/lib/git/hooks/useGitProviders'; export default function ConnectionsTab() { - const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || ''); - const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || ''); - const [isConnected, setIsConnected] = useState(false); - const [isVerifying, setIsVerifying] = useState(false); - - useEffect(() => { - // Check if credentials exist and verify them - if (githubUsername && githubToken) { - verifyGitHubCredentials(); - } - }, []); - - const verifyGitHubCredentials = async () => { - setIsVerifying(true); - - try { - const response = await fetch('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${githubToken}`, - }, - }); - - if (response.ok) { - const data = (await response.json()) as GitHubUserResponse; - - if (data.login === githubUsername) { - setIsConnected(true); - return true; - } - } - - setIsConnected(false); - - return false; - } catch (error) { - console.error('Error verifying GitHub credentials:', error); - setIsConnected(false); - - return false; - } finally { - setIsVerifying(false); - } - }; - - const handleSaveConnection = async () => { - if (!githubUsername || !githubToken) { - toast.error('Please provide both GitHub username and token'); - return; - } - - setIsVerifying(true); - - const isValid = await verifyGitHubCredentials(); - - if (isValid) { - Cookies.set('githubUsername', githubUsername); - Cookies.set('githubToken', githubToken); - logStore.logSystem('GitHub connection settings updated', { - username: githubUsername, - hasToken: !!githubToken, - }); - toast.success('GitHub credentials verified and saved successfully!'); - Cookies.set('git:github.com', JSON.stringify({ username: githubToken, password: 'x-oauth-basic' })); - setIsConnected(true); - } else { - toast.error('Invalid GitHub credentials. Please check your username and token.'); - } - }; - - const handleDisconnect = () => { - Cookies.remove('githubUsername'); - Cookies.remove('githubToken'); - Cookies.remove('git:github.com'); - setGithubUsername(''); - setGithubToken(''); - setIsConnected(false); - logStore.logSystem('GitHub connection removed'); - toast.success('GitHub connection removed successfully!'); - }; + const { + providers, + credentials, + expandedProviders, + handleSaveConnection, + handleDisconnect, + updateProviderCredentials, + toggleProvider, + } = useGitProviders(); return ( -
-

GitHub Connection

-
-
- - setGithubUsername(e.target.value)} - disabled={isVerifying} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50" - /> -
-
- - setGithubToken(e.target.value)} - disabled={isVerifying} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50" - /> -
-
-
- {!isConnected ? ( - - ) : ( - - )} - {isConnected && ( - -
- Connected to GitHub - - )} -
+
+
+ // Preloading icons otherwise they will not be displayed. Need fixing. +
+ // Preloading icons otherwise they will not be displayed. Need fixing. + {Object.entries(providers).map(([key, plugin]) => ( + toggleProvider(key)} + onUpdateCredentials={(updates) => updateProviderCredentials(key, updates)} + onSave={() => handleSaveConnection(key)} + onDisconnect={() => handleDisconnect(key)} + /> + ))}
); } diff --git a/app/components/settings/providers/ProvidersTab.tsx b/app/components/settings/providers/ProvidersTab.tsx index 58c8dac6b..e03731f43 100644 --- a/app/components/settings/providers/ProvidersTab.tsx +++ b/app/components/settings/providers/ProvidersTab.tsx @@ -35,8 +35,8 @@ export default function ProvidersTab() { newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name)); // Split providers into regular and URL-configurable - const regular = newFilteredProviders.filter(p => !URL_CONFIGURABLE_PROVIDERS.includes(p.name)); - const urlConfigurable = newFilteredProviders.filter(p => URL_CONFIGURABLE_PROVIDERS.includes(p.name)); + const regular = newFilteredProviders.filter((p) => !URL_CONFIGURABLE_PROVIDERS.includes(p.name)); + const urlConfigurable = newFilteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name)); setFilteredProviders([...regular, ...urlConfigurable]); }, [providers, searchTerm, isLocalModel]); @@ -112,8 +112,8 @@ export default function ProvidersTab() { ); }; - const regularProviders = filteredProviders.filter(p => !URL_CONFIGURABLE_PROVIDERS.includes(p.name)); - const urlConfigurableProviders = filteredProviders.filter(p => URL_CONFIGURABLE_PROVIDERS.includes(p.name)); + const regularProviders = filteredProviders.filter((p) => !URL_CONFIGURABLE_PROVIDERS.includes(p.name)); + const urlConfigurableProviders = filteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name)); return (
@@ -128,22 +128,19 @@ export default function ProvidersTab() {
{/* Regular Providers Grid */} -
- {regularProviders.map(renderProviderCard)} -
+
{regularProviders.map(renderProviderCard)}
{/* URL Configurable Providers Section */} {urlConfigurableProviders.length > 0 && (

Experimental Providers

- These providers are experimental and allow you to run AI models locally or connect to your own infrastructure. They require additional setup but offer more flexibility. + These providers are experimental and allow you to run AI models locally or connect to your own + infrastructure. They require additional setup but offer more flexibility.

-
- {urlConfigurableProviders.map(renderProviderCard)} -
+
{urlConfigurableProviders.map(renderProviderCard)}
)}
); -} \ No newline at end of file +} diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx index 0e34b5998..11b96f358 100644 --- a/app/components/workbench/Workbench.client.tsx +++ b/app/components/workbench/Workbench.client.tsx @@ -1,7 +1,7 @@ import { useStore } from '@nanostores/react'; import { motion, type HTMLMotionProps, type Variants } from 'framer-motion'; import { computed } from 'nanostores'; -import { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import { type OnChangeCallback as OnEditorChange, @@ -17,7 +17,7 @@ import { renderLogger } from '~/utils/logger'; import { EditorPanel } from './EditorPanel'; import { Preview } from './Preview'; import useViewport from '~/lib/hooks'; -import Cookies from 'js-cookie'; +import { getGitCredentials, createGitPushHandler, gitProviders } from '~/lib/git'; interface WorkspaceProps { chatStarted?: boolean; @@ -58,6 +58,10 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => renderLogger.trace('Workbench'); const [isSyncing, setIsSyncing] = useState(false); + const [hasCredentials, setHasCredentials] = useState<{ github: boolean; gitlab: boolean }>({ + github: false, + gitlab: false, + }); const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0)); const showWorkbench = useStore(workbenchStore.showWorkbench); @@ -83,6 +87,17 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => workbenchStore.setDocuments(files); }, [files]); + useEffect(() => { + const initCredentials = async () => { + const credentials = await getGitCredentials(); + setHasCredentials({ + github: credentials.github || false, + gitlab: credentials.gitlab || false, + }); + }; + initCredentials(); + }, []); + const onEditorChange = useCallback((update) => { workbenchStore.setCurrentDocumentContent(update.content); }, []); @@ -120,6 +135,26 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => } }, []); + const cleanPath = (path: string): string => { + return path.replace('/home/project/', ''); + }; + + const getWorkbenchFiles = useCallback(() => { + const docs = workbenchStore.files.get(); + return Object.entries(docs).reduce( + (acc, [path, doc]) => { + if (doc && 'content' in doc) { + acc[cleanPath(path)] = (doc as { content: string }).content; + } + + return acc; + }, + {} as Record, + ); + }, []); + + const pushFunctions = useMemo(() => createGitPushHandler(getWorkbenchFiles), [getWorkbenchFiles]); + return ( chatStarted && (
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 - + {Object.entries(gitProviders).map( + ([name, plugin]) => + hasCredentials[name as keyof typeof hasCredentials] && ( + +
+ Push to {plugin.provider.title} + + ), + )}
)} ) ); }); + interface ViewProps extends HTMLMotionProps<'div'> { children: JSX.Element; } diff --git a/app/lib/auth/credentials.ts b/app/lib/auth/credentials.ts new file mode 100644 index 000000000..580d7828c --- /dev/null +++ b/app/lib/auth/credentials.ts @@ -0,0 +1,172 @@ +import Cookies from 'js-cookie'; +import { toast } from 'react-toastify'; +import { logStore } from '~/lib/stores/logs'; +import { encrypt, decrypt, isEncryptionInitialized, initializeMasterKey } from './encryption'; +import type { GitAuth } from 'isomorphic-git'; + +const getDomain = (url: string): string => { + const withoutProtocol = url.replace(/^https?:\/\//, ''); + return withoutProtocol.split(/[/?#]/)[0]; +}; + +const ensureEncryption = async (): Promise => { + if (!isEncryptionInitialized()) { + return await initializeMasterKey(); + } + + return true; +}; + +const getLegacyCredentials = async (domain: string): Promise => { + const provider = domain.split('.')[0]; + const encryptedUsername = Cookies.get(`${provider}Username`); + const encryptedToken = Cookies.get(`${provider}Token`); + + if (!encryptedUsername || !encryptedToken) { + return null; + } + + try { + const username = await decrypt(encryptedUsername); + const token = await decrypt(encryptedToken); + + if (!username || !token) { + Cookies.remove(`${provider}Username`); + Cookies.remove(`${provider}Token`); + + return null; + } + + return { username, password: token }; + } catch (error) { + logStore.logError('Failed to decrypt legacy credentials:', error); + Cookies.remove(`${provider}Username`); + Cookies.remove(`${provider}Token`); + + return null; + } +}; + +const migrateLegacyCredentials = async (domain: string, auth: GitAuth): Promise => { + const provider = domain.split('.')[0]; + + try { + const encryptedCreds = await encrypt(JSON.stringify(auth)); + Cookies.set(domain, encryptedCreds); + + Cookies.remove(`${provider}Username`); + Cookies.remove(`${provider}Token`); + + const legacyKeys = [ + `${provider}AccessToken`, + `${provider}Auth`, + `${provider}Credentials`, + `${provider}_username`, + `${provider}_token`, + ]; + + legacyKeys.forEach((key) => { + if (Cookies.get(key)) { + Cookies.remove(key); + logStore.logSystem(`Removed legacy cookie: ${key}`); + } + }); + + logStore.logSystem(`Successfully migrated ${provider} credentials to new format and cleaned up legacy data`); + + return true; + } catch (error) { + logStore.logError('Failed to migrate legacy credentials:', error); + return false; + } +}; + +const getNewFormatCredentials = async (domain: string): Promise => { + const encryptedCreds = Cookies.get(domain); + + if (!encryptedCreds) { + return null; + } + + try { + const decryptedCreds = await decrypt(encryptedCreds); + const { username, password } = JSON.parse(decryptedCreds); + + if (!username || !password) { + Cookies.remove(domain); + return null; + } + + return { username, password }; + } catch (error) { + logStore.logError('Failed to parse or decrypt Git Cookie:', error); + Cookies.remove(domain); + + return null; + } +}; + +const lookupSavedPassword = async (url: string): Promise => { + if (!(await ensureEncryption())) { + return null; + } + + const domain = getDomain(url); + const newFormatCreds = await getNewFormatCredentials(domain); + + if (newFormatCreds) { + return newFormatCreds; + } + + const legacyCreds = await getLegacyCredentials(domain); + + if (legacyCreds) { + const migrationSuccess = await migrateLegacyCredentials(domain, legacyCreds); + + if (migrationSuccess) { + return legacyCreds; + } + } + + return null; +}; + +const saveGitAuth = async (url: string, auth: GitAuth) => { + if (!(await ensureEncryption())) { + toast.error('Failed to initialize encryption'); + return; + } + + const domain = getDomain(url); + + try { + const encryptedCreds = await encrypt( + JSON.stringify({ + username: auth.username, + password: auth.password, + }), + ); + Cookies.set(domain, encryptedCreds); + logStore.logSystem(`${domain} connection settings updated`, { + username: auth.username, + hasToken: !!auth.password, + }); + } catch (error) { + logStore.logError('Failed to encrypt credentials:', error); + } +}; + +const removeGitAuth = async (url: string) => { + const domain = getDomain(url); + + try { + Cookies.remove(domain); + logStore.logSystem(`${domain} connection removed`); + toast.success(`${domain} connection removed successfully!`); + } catch (error) { + logStore.logError('Failed to encrypt credentials:', error); + toast.error('Failed to save credentials securely'); + } +}; + +export { lookupSavedPassword, saveGitAuth, removeGitAuth, ensureEncryption }; diff --git a/app/lib/auth/encryption.ts b/app/lib/auth/encryption.ts new file mode 100644 index 000000000..4346d8079 --- /dev/null +++ b/app/lib/auth/encryption.ts @@ -0,0 +1,83 @@ +import { logStore } from '~/lib/stores/logs'; + +let masterKey: CryptoKey | null = null; + +const generateRandomKey = (): Uint8Array => { + return crypto.getRandomValues(new Uint8Array(32)); +}; + +const isEncryptionInitialized = (): boolean => { + return masterKey !== null; +}; + +const initializeMasterKey = async (): Promise => { + try { + const storedKey = localStorage.getItem('masterKey'); + + let keyData: Uint8Array; + + if (storedKey) { + keyData = new Uint8Array( + atob(storedKey) + .split('') + .map((c) => c.charCodeAt(0)), + ); + } else { + keyData = generateRandomKey(); + localStorage.setItem('masterKey', btoa(String.fromCharCode(...keyData))); + } + + masterKey = await crypto.subtle.importKey('raw', keyData, 'AES-GCM', false, ['encrypt', 'decrypt']); + + return true; + } catch (error) { + logStore.logError('Failed to initialize master key:', error); + return false; + } +}; + +const encrypt = async (text: string): Promise => { + if (!masterKey) { + throw new Error('Master key not initialized'); + } + + const encoder = new TextEncoder(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encodedText = encoder.encode(text); + + const encryptedData = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, masterKey, encodedText); + + const encryptedArray = new Uint8Array(encryptedData); + const combinedArray = new Uint8Array(iv.length + encryptedArray.length); + combinedArray.set(iv); + combinedArray.set(encryptedArray, iv.length); + + return btoa(String.fromCharCode(...combinedArray)); +}; + +const decrypt = async (encryptedText: string): Promise => { + if (!masterKey) { + throw new Error('Master key not initialized'); + } + + try { + const decoder = new TextDecoder(); + const encryptedArray = new Uint8Array( + atob(encryptedText) + .split('') + .map((char) => char.charCodeAt(0)), + ); + + const iv = encryptedArray.slice(0, 12); + const encryptedData = encryptedArray.slice(12); + + const decryptedData = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, masterKey, encryptedData); + + return decoder.decode(decryptedData); + } catch (error) { + logStore.logError('Decryption failed:', error); + throw error; + } +}; + +export { encrypt, decrypt, isEncryptionInitialized, initializeMasterKey }; diff --git a/app/lib/auth/index.ts b/app/lib/auth/index.ts new file mode 100644 index 000000000..c0cdc7a06 --- /dev/null +++ b/app/lib/auth/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './encryption'; +export * from './credentials'; diff --git a/app/lib/auth/types.ts b/app/lib/auth/types.ts new file mode 100644 index 000000000..f5e2e1505 --- /dev/null +++ b/app/lib/auth/types.ts @@ -0,0 +1,4 @@ +export interface EncryptedCredentials { + username: string; + password: string; +} diff --git a/app/lib/git/components/ProviderCard.tsx b/app/lib/git/components/ProviderCard.tsx new file mode 100644 index 000000000..26f4914c1 --- /dev/null +++ b/app/lib/git/components/ProviderCard.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import type { GitProvider } from '~/lib/git/types'; + +interface ProviderCardProps { + provider: GitProvider; + credentials: { + username: string; + token: string; + isConnected: boolean; + isVerifying: boolean; + }; + isExpanded: boolean; + onToggle: () => void; + onUpdateCredentials: (updates: { username?: string; token?: string }) => void; + onSave: () => void; + onDisconnect: () => void; +} + +export function ProviderCard({ + provider, + credentials, + isExpanded, + onToggle, + onUpdateCredentials, + onSave, + onDisconnect, +}: ProviderCardProps) { + return ( +
+
+
+
+

{provider.title} Connection

+ {credentials.username && ( + ({credentials.username}) + )} +
+
+ {credentials.isConnected && ( +
+
+ Connected +
+ )} +
+
+
+
+
+ + {isExpanded && ( +
+
+

{provider.instructions}

+
    + {provider.tokenSetupSteps.map((step, index) => ( +
  • {step}
  • + ))} +
+

+ + {provider.tokenSetupSetupUrl} + +

+
+ +
+
+ + onUpdateCredentials({ username: e.target.value })} + disabled={credentials.isVerifying} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+
+ + onUpdateCredentials({ token: e.target.value })} + disabled={credentials.isVerifying} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+
+ +
+ {!credentials.isConnected ? ( + + ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/app/lib/hooks/useGit.ts b/app/lib/git/hooks/useGit.ts similarity index 67% rename from app/lib/hooks/useGit.ts rename to app/lib/git/hooks/useGit.ts index 3c8c61bb2..b8f0b54e0 100644 --- a/app/lib/hooks/useGit.ts +++ b/app/lib/git/hooks/useGit.ts @@ -3,36 +3,15 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from import { webcontainer as webcontainerPromise } from '~/lib/webcontainer'; import git, { type GitAuth, type PromiseFsClient } from 'isomorphic-git'; import http from 'isomorphic-git/http/web'; -import Cookies from 'js-cookie'; import { toast } from 'react-toastify'; - -const lookupSavedPassword = (url: string) => { - const domain = url.split('/')[2]; - const gitCreds = Cookies.get(`git:${domain}`); - - if (!gitCreds) { - return null; - } - - try { - const { username, password } = JSON.parse(gitCreds || '{}'); - return { username, password }; - } catch (error) { - console.log(`Failed to parse Git Cookie ${error}`); - return null; - } -}; - -const saveGitAuth = (url: string, auth: GitAuth) => { - const domain = url.split('/')[2]; - Cookies.set(`git:${domain}`, JSON.stringify(auth)); -}; +import { ensureEncryption, lookupSavedPassword, saveGitAuth } from '~/lib/auth'; export function useGit() { const [ready, setReady] = useState(false); const [webcontainer, setWebcontainer] = useState(); const [fs, setFs] = useState(); const fileData = useRef>({}); + useEffect(() => { webcontainerPromise.then((container) => { fileData.current = {}; @@ -49,48 +28,61 @@ export function useGit() { } fileData.current = {}; - await git.clone({ - fs, - http, - dir: webcontainer.workdir, - url, - depth: 1, - singleBranch: true, - corsProxy: 'https://cors.isomorphic-git.org', - onAuth: (url) => { - // let domain=url.split("/")[2] - - let auth = lookupSavedPassword(url); - - if (auth) { - return auth; - } - - if (confirm('This repo is password protected. Ready to enter a username & password?')) { - auth = { - username: prompt('Enter username'), - password: prompt('Enter password'), - }; - return auth; - } else { + + try { + if (url.startsWith('git@')) { + throw new Error('SSH protocol is not supported. Please use HTTPS URL instead.'); + } + + await git.clone({ + fs, + http, + dir: webcontainer.workdir, + url, + depth: 1, + singleBranch: true, + corsProxy: 'https://cors.isomorphic-git.org', + onAuth: async (url) => { + if (!(await ensureEncryption())) { + return { cancel: true }; + } + + const auth = await lookupSavedPassword(url); + + if (auth) { + return auth; + } + + if (confirm('This repo is password protected. Ready to enter a username & password?')) { + const username = prompt('Enter username'); + const password = prompt('Enter password'); + + if (username && password) { + return { username, password }; + } + } + return { cancel: true }; - } - }, - onAuthFailure: (url, _auth) => { - toast.error(`Error Authenticating with ${url.split('/')[2]}`); - }, - onAuthSuccess: (url, auth) => { - saveGitAuth(url, auth); - }, - }); - - const data: Record = {}; - - for (const [key, value] of Object.entries(fileData.current)) { - data[key] = value; - } + }, + onAuthFailure: (url, _auth) => { + toast.error(`Error Authenticating with ${url.split('/')[2]}`); + }, + onAuthSuccess: async (url, auth) => { + await saveGitAuth(url, auth as GitAuth); + }, + }); + + const data: Record = {}; + + for (const [key, value] of Object.entries(fileData.current)) { + data[key] = value; + } - return { workdir: webcontainer.workdir, data }; + return { workdir: webcontainer.workdir, data }; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to clone repository'); + throw error; + } }, [webcontainer], ); @@ -145,14 +137,10 @@ const getFs = ( return await webcontainer.fs.rm(relativePath, { recursive: true, ...options }); }, - - // Mock implementations for missing functions unlink: async (path: string) => { - // unlink is just removing a single file const relativePath = pathUtils.relative(webcontainer.workdir, path); return await webcontainer.fs.rm(relativePath, { recursive: false }); }, - stat: async (path: string) => { try { const relativePath = pathUtils.relative(webcontainer.workdir, path); @@ -169,13 +157,13 @@ const getFs = ( isDirectory: () => fileInfo.isDirectory(), isSymbolicLink: () => false, size: 1, - mode: 0o666, // Default permissions + mode: 0o666, mtimeMs: Date.now(), uid: 1000, gid: 1000, }; } catch (error: any) { - console.log(error?.message); + console.error(error?.message); const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException; err.code = 'ENOENT'; @@ -185,36 +173,16 @@ const getFs = ( throw err; } }, - lstat: async (path: string) => { - /* - * For basic usage, lstat can return the same as stat - * since we're not handling symbolic links - */ return await getFs(webcontainer, record).promises.stat(path); }, - readlink: async (path: string) => { - /* - * Since WebContainer doesn't support symlinks, - * we'll throw a "not a symbolic link" error - */ throw new Error(`EINVAL: invalid argument, readlink '${path}'`); }, - symlink: async (target: string, path: string) => { - /* - * Since WebContainer doesn't support symlinks, - * we'll throw a "operation not supported" error - */ throw new Error(`EPERM: operation not permitted, symlink '${target}' -> '${path}'`); }, - chmod: async (_path: string, _mode: number) => { - /* - * WebContainer doesn't support changing permissions, - * but we can pretend it succeeded for compatibility - */ return await Promise.resolve(); }, }, @@ -222,45 +190,35 @@ const getFs = ( const pathUtils = { dirname: (path: string) => { - // Handle empty or just filename cases if (!path || !path.includes('/')) { return '.'; } - // Remove trailing slashes path = path.replace(/\/+$/, ''); - // Get directory part return path.split('/').slice(0, -1).join('/') || '/'; }, basename: (path: string, ext?: string) => { - // Remove trailing slashes path = path.replace(/\/+$/, ''); - // Get the last part of the path const base = path.split('/').pop() || ''; - // If extension is provided, remove it from the result if (ext && base.endsWith(ext)) { return base.slice(0, -ext.length); } return base; }, + relative: (from: string, to: string): string => { - // Handle empty inputs if (!from || !to) { return '.'; } - // Normalize paths by removing trailing slashes and splitting const normalizePathParts = (p: string) => p.replace(/\/+$/, '').split('/').filter(Boolean); - const fromParts = normalizePathParts(from); const toParts = normalizePathParts(to); - - // Find common parts at the start of both paths let commonLength = 0; const minLength = Math.min(fromParts.length, toParts.length); @@ -272,16 +230,10 @@ const pathUtils = { commonLength++; } - // Calculate the number of "../" needed const upCount = fromParts.length - commonLength; - - // Get the remaining path parts we need to append const remainingPath = toParts.slice(commonLength); - - // Construct the relative path const relativeParts = [...Array(upCount).fill('..'), ...remainingPath]; - // Handle empty result case return relativeParts.length === 0 ? '.' : relativeParts.join('/'); }, }; diff --git a/app/lib/git/hooks/useGitProviders.ts b/app/lib/git/hooks/useGitProviders.ts new file mode 100644 index 000000000..ea829eb91 --- /dev/null +++ b/app/lib/git/hooks/useGitProviders.ts @@ -0,0 +1,160 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'react-toastify'; +import { lookupSavedPassword, saveGitAuth, ensureEncryption, removeGitAuth } from '~/lib/auth'; +import { gitProviders } from '~/lib/git/providers'; + +interface ProviderCredentials { + username: string; + token: string; + isConnected: boolean; + isVerifying: boolean; +} + +interface ProviderState { + [key: string]: ProviderCredentials; +} + +export function useGitProviders() { + const [credentials, setCredentials] = useState(() => { + return Object.keys(gitProviders).reduce( + (acc, key) => ({ + ...acc, + [key]: { username: '', token: '', isConnected: false, isVerifying: false }, + }), + {}, + ); + }); + + const [expandedProviders, setExpandedProviders] = useState>({}); + + useEffect(() => { + initializeEncryption(); + }, []); + + const initializeEncryption = async () => { + const success = await ensureEncryption(); + + if (success) { + loadSavedCredentials(); + } + }; + + const loadSavedCredentials = async () => { + for (const [key, plugin] of Object.entries(gitProviders)) { + const auth = await lookupSavedPassword(plugin.provider.url); + + if (auth?.username && auth?.password) { + setCredentials((prev) => ({ + ...prev, + [key]: { + ...prev[key], + username: auth.username || '', + token: auth.password || '', + isConnected: true, + }, + })); + } + } + }; + + const handleSaveConnection = async (providerKey: string) => { + const plugin = gitProviders[providerKey]; + + if (!plugin) { + return; + } + + const { username, token } = credentials[providerKey]; + + if (!username || !token) { + toast.error(`Please provide both ${plugin.provider.title} username and token`); + return; + } + + setCredentials((prev) => ({ + ...prev, + [providerKey]: { ...prev[providerKey], isVerifying: true }, + })); + + try { + const isValid = await plugin.api.validateCredentials(username, token); + + if (isValid) { + await saveGitAuth(plugin.provider.url, { username, password: token }); + setCredentials((prev) => ({ + ...prev, + [providerKey]: { + ...prev[providerKey], + isConnected: true, + isVerifying: false, + }, + })); + toast.success(`${plugin.provider.title} credentials verified and saved successfully!`); + } else { + setCredentials((prev) => ({ + ...prev, + [providerKey]: { + ...prev[providerKey], + isConnected: false, + isVerifying: false, + }, + })); + toast.error(`Invalid ${plugin.provider.title} credentials. Please check your username and token.`); + } + } catch (error) { + setCredentials((prev) => ({ + ...prev, + [providerKey]: { + ...prev[providerKey], + isConnected: false, + isVerifying: false, + }, + })); + console.error(`Error validating ${plugin.provider.title} credentials:`, error); + toast.error(`Error validating ${plugin.provider.title} credentials`); + } + }; + + const handleDisconnect = async (providerKey: string) => { + const plugin = gitProviders[providerKey]; + + if (!plugin) { + return; + } + + await removeGitAuth(plugin.provider.url); + setCredentials((prev) => ({ + ...prev, + [providerKey]: { + ...prev[providerKey], + username: '', + token: '', + isConnected: false, + }, + })); + }; + + const updateProviderCredentials = (providerKey: string, updates: { username?: string; token?: string }) => { + setCredentials((prev) => ({ + ...prev, + [providerKey]: { ...prev[providerKey], ...updates }, + })); + }; + + const toggleProvider = (providerKey: string) => { + setExpandedProviders((prev) => ({ + ...prev, + [providerKey]: !prev[providerKey], + })); + }; + + return { + providers: gitProviders, + credentials, + expandedProviders, + handleSaveConnection, + handleDisconnect, + updateProviderCredentials, + toggleProvider, + }; +} diff --git a/app/lib/git/index.ts b/app/lib/git/index.ts new file mode 100644 index 000000000..13046af37 --- /dev/null +++ b/app/lib/git/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './operations'; +export { gitProviders, registerGitProvider } from './providers'; +export { useGit } from './hooks/useGit'; +export { useGitProviders } from './hooks/useGitProviders'; +export { ProviderCard } from './components/ProviderCard'; diff --git a/app/lib/git/operations.ts b/app/lib/git/operations.ts new file mode 100644 index 000000000..7215b7bd4 --- /dev/null +++ b/app/lib/git/operations.ts @@ -0,0 +1,64 @@ +import { toast } from 'react-toastify'; +import { ensureEncryption, lookupSavedPassword } from '~/lib/auth'; +import { gitProviders } from './providers'; + +export const getGitCredentials = async (): Promise> => { + const results: Record = {}; + + for (const [name, plugin] of Object.entries(gitProviders)) { + const auth = await lookupSavedPassword(plugin.provider.url); + results[name] = !!(auth?.username && auth?.password); + } + + return results; +}; + +export const createGitPushHandler = (getFiles: () => Record) => { + const createPushFunction = (providerName: string) => async (): Promise => { + const plugin = gitProviders[providerName]; + + if (!plugin) { + toast.error(`Git provider ${providerName} not found`); + return; + } + + const repoName = prompt( + `Please enter a name for your new ${plugin.provider.title} repository:`, + 'bolt-generated-project', + ); + + if (!repoName) { + toast.error('Repository name is required'); + return; + } + + if (!(await ensureEncryption())) { + toast.error('Failed to initialize secure storage'); + return; + } + + const auth = await lookupSavedPassword(plugin.provider.url); + + if (!auth?.username || !auth?.password) { + toast.info(`Please set up your ${plugin.provider.title} credentials in the Connections tab`); + return; + } + + const files = getFiles(); + const result = await plugin.api.pushWithRepoHandling(repoName, auth.username, files, auth.password); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + }; + + const pushFunctions: Record Promise> = {}; + + for (const providerName of Object.keys(gitProviders)) { + pushFunctions[providerName] = createPushFunction(providerName); + } + + return pushFunctions; +}; diff --git a/app/lib/git/providers/github/index.ts b/app/lib/git/providers/github/index.ts new file mode 100644 index 000000000..5ab762d7a --- /dev/null +++ b/app/lib/git/providers/github/index.ts @@ -0,0 +1,260 @@ +import { Octokit } from '@octokit/rest'; +import type { GitProvider, GitProviderAPI, GitPushResult } from '~/lib/git/types'; +import type { Endpoints } from '@octokit/types'; + +export const githubProvider: GitProvider = { + name: 'github', + title: 'GitHub', + url: 'github.com', + instructions: 'Create a Personal Access Token with repo scope:', + tokenSetupSetupUrl: 'https://github.com/settings/tokens', + tokenSetupSteps: [ + '1. Go to GitHub Settings > Developer settings > Personal access tokens', + '2. Generate a new token with "repo" scope', + '3. Copy the token', + ], + icon: 'i-ph:github-logo-duotone', +}; + +let project: Endpoints['GET /repos/{owner}/{repo}']['response']['data'] | any = null; +let octokit: Octokit; + +export const githubAPI: GitProviderAPI = { + setToken(token: string) { + octokit = new Octokit({ auth: token }); + }, + async checkFileExistence(branchName: string, filePath: string): Promise { + if (!project) { + throw new Error('Project not set. Please call getRepo first.'); + } + + try { + await octokit.repos.getContent({ + owner: project.owner.login, + repo: project.name, + path: filePath, + ref: branchName, + }); + return true; + } catch (error: any) { + if (error.status === 404) { + return false; + } + + console.error('Error checking file existence:', error); + throw error; + } + }, + async push(files: Record): Promise { + return await this.createCommit(files, 'feat: initial commit'); + }, + async validateCredentials(username: string, token: string): Promise { + this.setToken(token); + + try { + const { data } = await octokit.users.getAuthenticated(); + return data.login === username; + } catch (error) { + console.error('Error validating GitHub credentials:', error); + return false; + } + }, + async getRepo(repoName: string, username: string): Promise { + try { + const { data } = await octokit.repos.get({ + owner: username, + repo: repoName, + }); + project = data; + + return project; + } catch (error: any) { + if (error.status === 404) { + return null; + } + + console.error('Error getting GitHub repo:', error); + + return null; + } + }, + async createCommit(files: Record, commitMessage: string): Promise { + if (!project) { + throw new Error('Project not set. Please call getRepo first.'); + } + + try { + const branchToUse = project.default_branch || 'main'; + + const tree = await octokit.git.createTree({ + owner: project.owner.login, + repo: project.name, + tree: Object.entries(files).map(([path, content]) => ({ + path, + mode: '100644', // blob (file) + type: 'blob', + content, + })), + base_tree: project.default_branch, + }); + + const commit = await octokit.git.createCommit({ + owner: project.owner.login, + repo: project.name, + message: commitMessage, + tree: tree.data.sha, + parents: project.default_branch + ? [ + ( + await octokit.git.getRef({ + owner: project.owner.login, + repo: project.name, + ref: `heads/${project.default_branch}`, + }) + ).data.object.sha, + ] + : [], + }); + await octokit.git.updateRef({ + owner: project.owner.login, + repo: project.name, + ref: `heads/${branchToUse}`, + sha: commit.data.sha, + }); + } catch (error) { + console.error('Error creating commit:', error); + throw error; + } + }, + async createBranch(branchName: string, ref: string): Promise { + if (!project) { + throw new Error('Project not set. Please call getRepo first.'); + } + + try { + await octokit.git.createRef({ + owner: project.owner.login, + repo: project.name, + ref: `refs/heads/${branchName}`, + sha: ref, + }); + } catch (error) { + console.error('Error creating branch:', error); + throw error; + } + }, + async createMergeRequest(sourceBranch: string, targetBranch: string, title: string): Promise { + if (!project) { + throw new Error('Project not set. Please call getRepo first.'); + } + + try { + await octokit.pulls.create({ + owner: project.owner.login, + repo: project.name, + head: sourceBranch, + base: targetBranch, + title, + }); + } catch (error) { + console.error('Error creating merge request:', error); + throw error; + } + }, + async createRepo(repoName: string): Promise { + try { + const { data } = await octokit.repos.createForAuthenticatedUser({ + name: repoName, + auto_init: true, + }); + project = data; + } catch (error) { + console.error('Error creating repo:', error); + throw error; + } + }, + async pushWithRepoHandling( + repoName: string, + username: string, + files: Record, + token: string, + ): Promise { + try { + this.setToken(token); + project = null; + await this.getRepo(repoName, username); + + if (!project) { + const shouldCreate = confirm(`Repository "${repoName}" doesn't exist. Would you like to create it?`); + + if (!shouldCreate) { + throw new Error('Repository creation cancelled'); + } + + await this.createRepo(repoName); + + if (project) { + await this.push(files); + } else { + throw new Error('Failed to create new repository.'); + } + + return { + success: true, + message: `Repository created and code pushed: ${project.html_url}`, + }; + } + + const commitMsg = prompt('Enter commit message:'); + + if (commitMsg) { + try { + await this.createCommit(files, commitMsg); + return { + success: true, + message: `Successfully commit to: ${project.html_url}`, + }; + } catch (error: any) { + if (error.message.includes('Update is not a fast-forward')) { + console.error('Error: Update is not a fast-forward. Consider pulling changes first.'); + + const pull = confirm('Do you want to pull changes and try pushing again?'); + + if (pull) { + try { + await this.getRepo(repoName, username); + await this.pushWithRepoHandling(repoName, username, files, token); + } catch (pullError) { + console.error('Error pulling changes:', pullError); + return { + success: false, + message: `Error pulling changes: ${(pullError as Error).message}`, + }; + } + } else { + return { + success: false, + message: 'Push failed. Consider pulling changes and trying again.', + }; + } + } else { + throw error; // Re-throw other errors + } + } + } else { + throw new Error('Commit message is required.'); + } + + return { + success: true, + message: `Successfully commit to: ${project.html_url}`, + }; + } catch (error: any) { + console.error('Error pushing to GitHub:', error); + return { + success: false, + message: error.message, + }; + } + }, +}; diff --git a/app/lib/git/providers/gitlab/index.ts b/app/lib/git/providers/gitlab/index.ts new file mode 100644 index 000000000..7eb1db08f --- /dev/null +++ b/app/lib/git/providers/gitlab/index.ts @@ -0,0 +1,214 @@ +import type { GitProvider, GitProviderAPI, GitPushResult } from '~/lib/git/types'; +import axios, { Axios } from 'axios'; + +export const gitlabProvider: GitProvider = { + name: 'gitlab', + title: 'GitLab', + url: 'gitlab.com', + instructions: 'Create a Personal Access Token with api and write_repository scopes:', + tokenSetupSetupUrl: 'https://gitlab.com/-/user_settings/personal_access_tokens', + tokenSetupSteps: [ + '1. Go to GitLab Settings > Access Tokens', + '2. Create a new token with "api" and "write_repository" scopes', + '3. Generate and copy the token', + ], + icon: 'i-ph:gitlab-logo-duotone', +}; + +let project: { id?: any; default_branch?: any; branch?: string; web_url?: any }; +let gitlab: Axios; + +export const gitlabAPI: GitProviderAPI = { + setToken(token: string) { + gitlab = axios.create({ + baseURL: 'https://gitlab.com/api/v4', + headers: { + 'PRIVATE-TOKEN': token, + 'Content-Type': 'application/json', + }, + timeout: 10000, + }); + }, + async checkFileExistence(branchName: string, filePath: string): Promise { + try { + await gitlab.get(`/projects/${project.id}/repository/files/${encodeURIComponent(filePath)}`, { + params: { + ref: branchName, + }, + }); + + return true; + } catch (error) { + if (axios.isAxiosError(error) && error.response && error.response.status === 404) { + return false; + } else { + console.error('Error checking file existence:', error); + throw error; + } + } + }, + async push(files: Record): Promise { + return await this.createCommit(files, 'feat: initial commit'); + }, + async validateCredentials(username: string): Promise { + try { + const response = await gitlab.get('/user'); + + if (response.status !== 200) { + return false; + } + + const data: { username: string } = response.data; + + return data.username === username; + } catch (error) { + console.error('Error validating GitLab credentials:', error); + return false; + } + }, + async getRepo(repoName: string, username: string): Promise { + try { + const { data } = await gitlab.get(`/projects/${encodeURIComponent(`${username}/${repoName}`)}`); + project = data; + } catch (error: any) { + if (error.response && error.response.status === 404) { + return null; + } + + console.error('Error getting GitLab repo:', error); + + return null; + } + return true; + }, + async createCommit(files: Record, commitMessage: string): Promise { + try { + const actions: { action: string; file_path: string; content: string }[] = []; + const branchToUse = project.branch || project.default_branch || 'main'; + + if (!branchToUse) { + throw new Error('No branch specified and no default branch found for project.'); + } + + for (const [filePath, content] of Object.entries(files)) { + const fileExists = await this.checkFileExistence(branchToUse, filePath); + + const action = { + action: fileExists ? 'update' : 'create', + file_path: filePath, + content, + }; + + actions.push(action); + } + + const commitData = { + branch: branchToUse, + commit_message: commitMessage, + actions, + }; + + const { data } = await gitlab.post(`/projects/${project.id}/repository/commits`, commitData); + project = data; + } catch (error) { + console.error('Error creating commit:', error); + throw error; + } + }, + async createBranch(branchName: string, ref: string): Promise { + try { + const branchData = { + branch: branchName, + ref, + }; + + const { data } = await gitlab.post(`/projects/${project.id}/repository/branches`, branchData); + project = data; + } catch (error) { + console.error('Error creating branch:', error); + throw error; + } + }, + async createMergeRequest(sourceBranch: string, targetBranch: string, title: string): Promise { + try { + const mergeRequestData = { + source_branch: sourceBranch, + target_branch: targetBranch, + title, + }; + + const { data } = await gitlab.post(`/projects/${project.id}/merge_requests`, mergeRequestData); + project = data; + } catch (error) { + console.error('Error creating merge request:', error); + throw error; + } + }, + async createRepo(repoName: string): Promise { + try { + const { data } = await gitlab.post('/projects', { + name: repoName, + initialize_with_readme: true, + }); + project = data; + } catch (error) { + console.error('Error creating repo:', error); + throw error; + } + }, + async pushWithRepoHandling( + repoName: string, + username: string, + files: Record, + token: string, + ): Promise { + try { + this.setToken(token); + project = { id: undefined, default_branch: undefined, web_url: undefined }; + await this.getRepo(repoName, username); + + if (!project.id) { + const shouldCreate = confirm(`Repository "${repoName}" doesn't exist. Would you like to create it?`); + + if (!shouldCreate) { + throw new Error('Repository creation cancelled'); + } + + if (repoName) { + await this.createRepo(repoName); + + if (project.id) { + await this.push(files); + } else { + throw new Error('Failed to create new repository.'); + } + } else { + throw new Error('Repository name is required.'); + } + + return { + success: true, + message: `Repository created and code pushed: ${project.web_url}`, + }; + } + + const commitMsg = prompt('Enter commit message:'); + + if (commitMsg) { + await this.createCommit(files, commitMsg); + return { + success: true, + message: `Successfully commit to: ${project.web_url}`, + }; + } else { + throw new Error('Commit message is required.'); + } + } catch (error: any) { + console.error('Error pushing to GitLab:', error); + return { + success: false, + message: error.message, + }; + } + }, +}; diff --git a/app/lib/git/providers/index.ts b/app/lib/git/providers/index.ts new file mode 100644 index 000000000..ba14a65a5 --- /dev/null +++ b/app/lib/git/providers/index.ts @@ -0,0 +1,18 @@ +import { githubProvider, githubAPI } from './github'; +import { gitlabProvider, gitlabAPI } from './gitlab'; +import type { GitProviderPlugin } from '~/lib/git/types'; + +export const gitProviders: Record = { + github: { + provider: githubProvider, + api: githubAPI, + }, + gitlab: { + provider: gitlabProvider, + api: gitlabAPI, + }, +}; + +export const registerGitProvider = (name: string, plugin: GitProviderPlugin) => { + gitProviders[name] = plugin; +}; diff --git a/app/lib/git/types.ts b/app/lib/git/types.ts new file mode 100644 index 000000000..7c720a500 --- /dev/null +++ b/app/lib/git/types.ts @@ -0,0 +1,32 @@ +export interface GitProvider { + name: string; + title: string; + url: string; + instructions: string; + tokenSetupSetupUrl: string; + tokenSetupSteps: string[]; + icon: string; // CSS class name for the icon +} + +export interface GitPushResult { + success: boolean; + message: string; +} + +export interface GitProviderAPI { + setToken(token: string): void; + checkFileExistence(branchName: string, filePath: string): Promise; + push(files: Record): Promise; + validateCredentials(username: string, token: string): Promise; + getRepo(repoName: string, username: string): Promise; + createCommit(files: Record, commitMessage: string): Promise; + createBranch(branchName: string, ref: string): Promise; + createMergeRequest(sourceBranch: string, targetBranch: string, title: string): Promise; + createRepo(repoName: string): Promise; + pushWithRepoHandling(repoName: string, username: string, files: Record, token: string): Promise; +} + +export interface GitProviderPlugin { + provider: GitProvider; + api: GitProviderAPI; +} diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index 0d46057db..35f4035b2 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -11,11 +11,9 @@ 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 { extractRelativePath } from '~/utils/diff'; import { description } from '~/lib/persistence'; -import Cookies from 'js-cookie'; import { createSampler } from '~/utils/sampler'; export interface ArtifactState { @@ -407,115 +405,6 @@ 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'); - } - - // 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 - } - } } export const workbenchStore = new WorkbenchStore(); diff --git a/docker-compose.yaml b/docker-compose.yaml index 0c9acd09d..b64c1b804 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -34,6 +34,8 @@ services: app-dev: image: bolt-ai:development build: + context: . + dockerfile: Dockerfile target: bolt-ai-development environment: - NODE_ENV=development diff --git a/package.json b/package.json index 05d483b91..0f1068d5b 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@iconify-json/svg-spinners": "^1.2.1", "@lezer/highlight": "^1.2.1", "@nanostores/react": "^0.7.3", + "@octokit/openapi-types": "^22.2.0", "@octokit/rest": "^21.0.2", "@octokit/types": "^13.6.2", "@openrouter/ai-sdk-provider": "^0.0.5", @@ -74,6 +75,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "ai": "^4.0.13", + "axios": "^1.7.9", "date-fns": "^3.6.0", "diff": "^5.2.0", "file-saver": "^2.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efec89864..bb09d9a97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: '@nanostores/react': specifier: ^0.7.3 version: 0.7.3(nanostores@0.10.3)(react@18.3.1) + '@octokit/openapi-types': + specifier: ^22.2.0 + version: 22.2.0 '@octokit/rest': specifier: ^21.0.2 version: 21.0.2 @@ -142,7 +145,10 @@ importers: version: 5.5.0 ai: specifier: ^4.0.13 - version: 4.0.18(react@18.3.1)(zod@3.23.8) + version: 4.0.22(react@18.3.1)(zod@3.23.8) + axios: + specifier: ^1.7.9 + version: 1.7.9 date-fns: specifier: ^3.6.0 version: 3.6.0 @@ -369,8 +375,8 @@ packages: zod: optional: true - '@ai-sdk/provider-utils@2.0.4': - resolution: {integrity: sha512-GMhcQCZbwM6RoZCri0MWeEWXRt/T+uCxsmHEsTwNvEH3GDjNzchfX25C8ftry2MeEOOn6KfqCLSKomcgK6RoOg==} + '@ai-sdk/provider-utils@2.0.5': + resolution: {integrity: sha512-2M7vLhYN0ThGjNlzow7oO/lsL+DyMxvGMIYmVQvEYaCWhDzxH5dOp78VNjJIVwHzVLMbBDigX3rJuzAs853idw==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 @@ -394,12 +400,12 @@ packages: resolution: {integrity: sha512-mV+3iNDkzUsZ0pR2jG0sVzU6xtQY5DtSCBy3JFycLp6PwjyLw/iodfL3MwdmMCRJWgs3dadcHejRnMvF9nGTBg==} engines: {node: '>=18'} - '@ai-sdk/provider@1.0.2': - resolution: {integrity: sha512-YYtP6xWQyaAf5LiWLJ+ycGTOeBLWrED7LUrvc+SQIWhGaneylqbaGsyQL7VouQUeQ4JZ1qKYZuhmi3W56HADPA==} + '@ai-sdk/provider@1.0.3': + resolution: {integrity: sha512-WiuJEpHTrltOIzv3x2wx4gwksAHW0h6nK3SoDzjqCOJLu/2OJ1yASESTIX+f07ChFykHElVoP80Ol/fe9dw6tQ==} engines: {node: '>=18'} - '@ai-sdk/react@1.0.6': - resolution: {integrity: sha512-8Hkserq0Ge6AEi7N4hlv2FkfglAGbkoAXEZ8YSp255c3PbnZz6+/5fppw+aROmZMOfNwallSRuy1i/iPa2rBpQ==} + '@ai-sdk/react@1.0.7': + resolution: {integrity: sha512-j2/of4iCNq+r2Bjx0O9vdRhn5C/02t2Esenis71YtnsoynPz74eQlJ3N0RYYPheThiJes50yHdfdVdH9ulxs1A==} engines: {node: '>=18'} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc @@ -410,8 +416,8 @@ packages: zod: optional: true - '@ai-sdk/ui-utils@1.0.5': - resolution: {integrity: sha512-DGJSbDf+vJyWmFNexSPUsS1AAy7gtsmFmoSyNbNbJjwl9hRIf2dknfA1V0ahx6pg3NNklNYFm53L8Nphjovfvg==} + '@ai-sdk/ui-utils@1.0.6': + resolution: {integrity: sha512-ZP6Vjj+VCnSPBIAvWAdKj2olQONJ/f4aZpkVCGkzprdhv8TjHwB6CTlXFS3zypuEGy4asg84dc1dvXKooQXFvg==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 @@ -2391,8 +2397,8 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} - ai@4.0.18: - resolution: {integrity: sha512-BTWzalLNE1LQphEka5xzJXDs5v4xXy1Uzr7dAVk+C/CnO3WNpuMBgrCymwUv0VrWaWc8xMQuh+OqsT7P7JyekQ==} + ai@4.0.22: + resolution: {integrity: sha512-yvcjWtofI2HZwgT3jMkoNnDUhAY+S9cOvZ6xbbOzrS0ZeFl1/gcbasFnwAqUJ7uL/t72/3a0Vy/pKg6N19A2Mw==} engines: {node: '>=18'} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc @@ -2459,10 +2465,16 @@ packages: async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axios@1.7.9: + resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -2683,6 +2695,10 @@ packages: colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2860,6 +2876,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3227,6 +3247,15 @@ packages: flatted@3.3.2: resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -3234,6 +3263,10 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} + form-data@4.0.1: + resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -4564,6 +4597,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + public-encrypt@4.0.3: resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} @@ -5811,9 +5847,9 @@ snapshots: optionalDependencies: zod: 3.23.8 - '@ai-sdk/provider-utils@2.0.4(zod@3.23.8)': + '@ai-sdk/provider-utils@2.0.5(zod@3.23.8)': dependencies: - '@ai-sdk/provider': 1.0.2 + '@ai-sdk/provider': 1.0.3 eventsource-parser: 3.0.0 nanoid: 3.3.8 secure-json-parse: 2.7.0 @@ -5836,24 +5872,24 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/provider@1.0.2': + '@ai-sdk/provider@1.0.3': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.0.6(react@18.3.1)(zod@3.23.8)': + '@ai-sdk/react@1.0.7(react@18.3.1)(zod@3.23.8)': dependencies: - '@ai-sdk/provider-utils': 2.0.4(zod@3.23.8) - '@ai-sdk/ui-utils': 1.0.5(zod@3.23.8) + '@ai-sdk/provider-utils': 2.0.5(zod@3.23.8) + '@ai-sdk/ui-utils': 1.0.6(zod@3.23.8) swr: 2.2.5(react@18.3.1) throttleit: 2.1.0 optionalDependencies: react: 18.3.1 zod: 3.23.8 - '@ai-sdk/ui-utils@1.0.5(zod@3.23.8)': + '@ai-sdk/ui-utils@1.0.6(zod@3.23.8)': dependencies: - '@ai-sdk/provider': 1.0.2 - '@ai-sdk/provider-utils': 2.0.4(zod@3.23.8) + '@ai-sdk/provider': 1.0.3 + '@ai-sdk/provider-utils': 2.0.5(zod@3.23.8) zod-to-json-schema: 3.23.5(zod@3.23.8) optionalDependencies: zod: 3.23.8 @@ -7942,12 +7978,12 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ai@4.0.18(react@18.3.1)(zod@3.23.8): + ai@4.0.22(react@18.3.1)(zod@3.23.8): dependencies: - '@ai-sdk/provider': 1.0.2 - '@ai-sdk/provider-utils': 2.0.4(zod@3.23.8) - '@ai-sdk/react': 1.0.6(react@18.3.1)(zod@3.23.8) - '@ai-sdk/ui-utils': 1.0.5(zod@3.23.8) + '@ai-sdk/provider': 1.0.3 + '@ai-sdk/provider-utils': 2.0.5(zod@3.23.8) + '@ai-sdk/react': 1.0.7(react@18.3.1)(zod@3.23.8) + '@ai-sdk/ui-utils': 1.0.6(zod@3.23.8) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 zod-to-json-schema: 3.23.5(zod@3.23.8) @@ -8011,10 +8047,20 @@ snapshots: async-lock@1.4.1: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 + axios@1.7.9: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -8269,6 +8315,10 @@ snapshots: colorjs.io@0.5.2: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} common-tags@1.8.2: {} @@ -8417,6 +8467,8 @@ snapshots: defu@6.1.4: {} + delayed-stream@1.0.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -8914,6 +8966,8 @@ snapshots: flatted@3.3.2: {} + follow-redirects@1.15.9: {} + for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -8923,6 +8977,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.1: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + format@0.2.2: {} formdata-polyfill@4.0.10: @@ -10676,6 +10736,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + public-encrypt@4.0.3: dependencies: bn.js: 4.12.1