mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 10:16:01 +00:00
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
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, 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<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);
|
|
|
|
// 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<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 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('/');
|
|
},
|
|
};
|