| 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<WebContainer>(); |
| const [fs, setFs] = useState<PromiseFsClient>(); |
| const fileData = useRef<Record<string, { data: any; encoding?: string }>>({}); |
| 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]}`); |
| throw `Error Authenticating with ${url.split('/')[2]}`; |
| }, |
| onAuthSuccess: (url, auth) => { |
| saveGitAuth(url, auth); |
| }, |
| }); |
|
|
| const data: Record<string, { data: any; encoding?: string }> = {}; |
|
|
| 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<Record<string, { data: any; encoding?: string }>>, |
| ) => ({ |
| 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, |
| 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) => { |
| |
| |
| |
| |
| throw new Error(`EPERM: operation not permitted, symlink '${target}' -> '${path}'`); |
| }, |
|
|
| chmod: async (_path: string, _mode: number) => { |
| |
| |
| |
| |
| return await Promise.resolve(); |
| }, |
| }, |
| }); |
|
|
| const pathUtils = { |
| dirname: (path: string) => { |
| |
| if (!path || !path.includes('/')) { |
| return '.'; |
| } |
|
|
| |
| path = path.replace(/\/+$/, ''); |
|
|
| |
| return path.split('/').slice(0, -1).join('/') || '/'; |
| }, |
|
|
| basename: (path: string, ext?: string) => { |
| |
| path = path.replace(/\/+$/, ''); |
|
|
| |
| const base = path.split('/').pop() || ''; |
|
|
| |
| if (ext && base.endsWith(ext)) { |
| return base.slice(0, -ext.length); |
| } |
|
|
| return base; |
| }, |
| relative: (from: string, to: string): string => { |
| |
| if (!from || !to) { |
| return '.'; |
| } |
|
|
| |
| const normalizePathParts = (p: string) => p.replace(/\/+$/, '').split('/').filter(Boolean); |
|
|
| const fromParts = normalizePathParts(from); |
| const toParts = normalizePathParts(to); |
|
|
| |
| 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++; |
| } |
|
|
| |
| const upCount = fromParts.length - commonLength; |
|
|
| |
| const remainingPath = toParts.slice(commonLength); |
|
|
| |
| const relativeParts = [...Array(upCount).fill('..'), ...remainingPath]; |
|
|
| |
| return relativeParts.length === 0 ? '.' : relativeParts.join('/'); |
| }, |
| }; |
|
|