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) => { if (!webcontainer || !fs || !ready) { throw 'Webcontainer not initialized'; } fileData.current = {}; 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 { await git.clone({ fs, http, dir: webcontainer.workdir, url, depth: 1, singleBranch: true, corsProxy: '/api/git-proxy', headers, onAuth: (url) => { 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 { 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; } return { workdir: webcontainer.workdir, data }; } catch (error) { console.error('Git clone error:', error); 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 encoding = options.encoding; const relativePath = pathUtils.relative(webcontainer.workdir, path); if (record.current) { record.current[relativePath] = { data, encoding }; } try { const result = await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding }); return result; } 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 resp = await webcontainer.fs.readdir(pathUtils.dirname(relativePath), { withFileTypes: true }); const name = pathUtils.basename(relativePath); const fileInfo = resp.find((x) => x.name == name); if (!fileInfo) { throw new Error(`ENOENT: no such file or directory, stat '${path}'`); } return { isFile: () => fileInfo.isFile(), isDirectory: () => fileInfo.isDirectory(), isSymbolicLink: () => false, size: 1, mode: 0o666, // Default permissions mtimeMs: Date.now(), uid: 1000, gid: 1000, }; } catch (error: any) { console.log(error?.message); 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; } }, 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('/'); }, };