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
-
-
- {!isConnected ? (
-
- {isVerifying ? (
- <>
-
- Verifying...
- >
- ) : (
- 'Connect'
- )}
-
- ) : (
-
- Disconnect
-
- )}
- {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 && (
+
+ )}
+
+
+
+
+ {isExpanded && (
+
+
+
+
+
+
+ {!credentials.isConnected ? (
+
+ {credentials.isVerifying ? (
+ <>
+
+ Verifying...
+ >
+ ) : (
+ 'Connect'
+ )}
+
+ ) : (
+
+ Disconnect
+
+ )}
+
+
+ )}
+
+ );
+}
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