import type { WebContainer } from '@webcontainer/api'; import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'; 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)); }; 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 = {}; setWebcontainer(container); setFs(getFs(container, fileData)); setReady(true); }); }, []); const gitClone = useCallback( async (url: string, retryCount = 0) => { if (!webcontainer || !fs || !ready) { throw new Error('Webcontainer not initialized. Please try again later.'); } fileData.current = {}; /* * Skip Git initialization for now - let isomorphic-git handle it * This avoids potential issues with our manual initialization */ const headers: { [x: string]: string; } = { 'User-Agent': 'bolt.diy', }; const auth = lookupSavedPassword(url); if (auth) { headers.Authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`; } try { // Add a small delay before retrying to allow for network recovery if (retryCount > 0) { await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount)); console.log(`Retrying git clone (attempt ${retryCount + 1})...`); } await git.clone({ fs, http, dir: webcontainer.workdir, url, depth: 1, singleBranch: true, corsProxy: '/api/git-proxy', headers, onProgress: (event) => { console.log('Git clone progress:', event); }, onAuth: (url) => { let auth = lookupSavedPassword(url); if (auth) { console.log('Using saved authentication for', url); return auth; } console.log('Repository requires authentication:', url); if (confirm('This repository requires authentication. Would you like to enter your GitHub credentials?')) { auth = { username: prompt('Enter username') || '', password: prompt('Enter password or personal access token') || '', }; return auth; } else { return { cancel: true }; } }, onAuthFailure: (url, _auth) => { console.error(`Authentication failed for ${url}`); toast.error(`Authentication failed for ${url.split('/')[2]}. Please check your credentials and try again.`); throw new Error( `Authentication failed for ${url.split('/')[2]}. Please check your credentials and try again.`, ); }, onAuthSuccess: (url, auth) => { console.log(`Authentication successful for ${url}`); saveGitAuth(url, auth); }, }); const data: Record = {}; for (const [key, value] of Object.entries(fileData.current)) { data[key] = value; } return { workdir: webcontainer.workdir, data }; } catch (error) { console.error('Git clone error:', error); // Handle specific error types const errorMessage = error instanceof Error ? error.message : String(error); // Check for common error patterns if (errorMessage.includes('Authentication failed')) { toast.error(`Authentication failed. Please check your GitHub credentials and try again.`); throw error; } else if ( errorMessage.includes('ENOTFOUND') || errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ECONNREFUSED') ) { toast.error(`Network error while connecting to repository. Please check your internet connection.`); // Retry for network errors, up to 3 times if (retryCount < 3) { return gitClone(url, retryCount + 1); } throw new Error( `Failed to connect to repository after multiple attempts. Please check your internet connection.`, ); } else if (errorMessage.includes('404')) { toast.error(`Repository not found. Please check the URL and make sure the repository exists.`); throw new Error(`Repository not found. Please check the URL and make sure the repository exists.`); } else if (errorMessage.includes('401')) { toast.error(`Unauthorized access to repository. Please connect your GitHub account with proper permissions.`); throw new Error( `Unauthorized access to repository. Please connect your GitHub account with proper permissions.`, ); } else { toast.error(`Failed to clone repository: ${errorMessage}`); throw error; } } }, [webcontainer, fs, ready], ); return { ready, gitClone }; } const getFs = ( webcontainer: WebContainer, record: MutableRefObject>, ) => ({ promises: { readFile: async (path: string, options: any) => { const encoding = options?.encoding; const relativePath = pathUtils.relative(webcontainer.workdir, path); try { const result = await webcontainer.fs.readFile(relativePath, encoding); return result; } catch (error) { throw error; } }, writeFile: async (path: string, data: any, options: any = {}) => { const relativePath = pathUtils.relative(webcontainer.workdir, path); if (record.current) { record.current[relativePath] = { data, encoding: options?.encoding }; } try { if (data instanceof Uint8Array) { const existing = await webcontainer.fs.readFile(relativePath).catch(() => null); if (existing && Buffer.compare(existing, data) === 0) { return; } await webcontainer.fs.writeFile(relativePath, data); } else { const encoding = options?.encoding || 'utf8'; const existing = await webcontainer.fs.readFile(relativePath, encoding).catch(() => null); if (typeof existing === 'string' && existing === data) { return; } await webcontainer.fs.writeFile(relativePath, data, encoding); } } catch (error) { throw error; } }, mkdir: async (path: string, options: any) => { const relativePath = pathUtils.relative(webcontainer.workdir, path); try { const result = await webcontainer.fs.mkdir(relativePath, { ...options, recursive: true }); return result; } catch (error) { throw error; } }, readdir: async (path: string, options: any) => { const relativePath = pathUtils.relative(webcontainer.workdir, path); try { const result = await webcontainer.fs.readdir(relativePath, options); return result; } catch (error) { throw error; } }, rm: async (path: string, options: any) => { const relativePath = pathUtils.relative(webcontainer.workdir, path); try { const result = await webcontainer.fs.rm(relativePath, { ...(options || {}) }); return result; } catch (error) { throw error; } }, rmdir: async (path: string, options: any) => { const relativePath = pathUtils.relative(webcontainer.workdir, path); try { const result = await webcontainer.fs.rm(relativePath, { recursive: true, ...options }); return result; } catch (error) { throw error; } }, unlink: async (path: string) => { const relativePath = pathUtils.relative(webcontainer.workdir, path); try { return await webcontainer.fs.rm(relativePath, { recursive: false }); } catch (error) { throw error; } }, stat: async (path: string) => { try { const relativePath = pathUtils.relative(webcontainer.workdir, path); const dirPath = pathUtils.dirname(relativePath); const fileName = pathUtils.basename(relativePath); // Special handling for .git/index file if (relativePath === '.git/index') { return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false, size: 12, // Size of our empty index mode: 0o100644, // Regular file mtimeMs: Date.now(), ctimeMs: Date.now(), birthtimeMs: Date.now(), atimeMs: Date.now(), uid: 1000, gid: 1000, dev: 1, ino: 1, nlink: 1, rdev: 0, blksize: 4096, blocks: 1, mtime: new Date(), ctime: new Date(), birthtime: new Date(), atime: new Date(), }; } const resp = await webcontainer.fs.readdir(dirPath, { withFileTypes: true }); const fileInfo = resp.find((x) => x.name === fileName); if (!fileInfo) { const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException; err.code = 'ENOENT'; err.errno = -2; err.syscall = 'stat'; err.path = path; throw err; } return { isFile: () => fileInfo.isFile(), isDirectory: () => fileInfo.isDirectory(), isSymbolicLink: () => false, size: fileInfo.isDirectory() ? 4096 : 1, mode: fileInfo.isDirectory() ? 0o040755 : 0o100644, // Directory or regular file mtimeMs: Date.now(), ctimeMs: Date.now(), birthtimeMs: Date.now(), atimeMs: Date.now(), uid: 1000, gid: 1000, dev: 1, ino: 1, nlink: 1, rdev: 0, blksize: 4096, blocks: 8, mtime: new Date(), ctime: new Date(), birthtime: new Date(), atime: new Date(), }; } catch (error: any) { if (!error.code) { error.code = 'ENOENT'; error.errno = -2; error.syscall = 'stat'; error.path = path; } throw error; } }, lstat: async (path: string) => { return await getFs(webcontainer, record).promises.stat(path); }, readlink: async (path: string) => { 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(); }, }, }); 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); for (let i = 0; i < minLength; i++) { if (fromParts[i] !== toParts[i]) { break; } 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('/'); }, };