diff --git a/server/index.js b/server/index.js index 2f19dcd..719e1c3 100755 --- a/server/index.js +++ b/server/index.js @@ -70,7 +70,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js'; import commandsRoutes from './routes/commands.js'; import settingsRoutes from './routes/settings.js'; import agentRoutes from './routes/agent.js'; -import projectsRoutes from './routes/projects.js'; +import projectsRoutes, { FORBIDDEN_PATHS } from './routes/projects.js'; import cliAuthRoutes from './routes/cli-auth.js'; import userRoutes from './routes/user.js'; import codexRoutes from './routes/codex.js'; @@ -550,6 +550,55 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { } }); +app.post('/api/create-folder', authenticateToken, async (req, res) => { + try { + const { path: folderPath } = req.body; + if (!folderPath) { + return res.status(400).json({ error: 'Path is required' }); + } + const homeDir = os.homedir(); + const targetPath = path.resolve(folderPath.replace('~', homeDir)); + const normalizedPath = path.normalize(targetPath); + const comparePath = normalizedPath.toLowerCase(); + const forbiddenLower = FORBIDDEN_PATHS.map(p => p.toLowerCase()); + if (forbiddenLower.includes(comparePath) || comparePath === '/') { + return res.status(403).json({ error: 'Cannot create folders in system directories' }); + } + for (const forbidden of forbiddenLower) { + if (comparePath.startsWith(forbidden + path.sep)) { + if (forbidden === '/var' && (comparePath.startsWith('/var/tmp') || comparePath.startsWith('/var/folders'))) { + continue; + } + return res.status(403).json({ error: `Cannot create folders in system directory: ${forbidden}` }); + } + } + const parentDir = path.dirname(targetPath); + try { + await fs.promises.access(parentDir); + } catch (err) { + return res.status(404).json({ error: 'Parent directory does not exist' }); + } + try { + await fs.promises.access(targetPath); + return res.status(409).json({ error: 'Folder already exists' }); + } catch (err) { + // Folder doesn't exist, which is what we want + } + try { + await fs.promises.mkdir(targetPath, { recursive: false }); + res.json({ success: true, path: targetPath }); + } catch (mkdirError) { + if (mkdirError.code === 'EEXIST') { + return res.status(409).json({ error: 'Folder already exists' }); + } + throw mkdirError; + } + } catch (error) { + console.error('Error creating folder:', error); + res.status(500).json({ error: 'Failed to create folder' }); + } +}); + // Read file content endpoint app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => { try { diff --git a/server/middleware/auth.js b/server/middleware/auth.js index b9ff24f..9231e4e 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -37,7 +37,12 @@ const authenticateToken = async (req, res, next) => { // Normal OSS JWT validation const authHeader = req.headers['authorization']; - const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + // Also check query param for SSE endpoints (EventSource can't set headers) + if (!token && req.query.token) { + token = req.query.token; + } if (!token) { return res.status(401).json({ error: 'Access denied. No token provided.' }); diff --git a/server/projects.js b/server/projects.js index b4606f8..475b323 100755 --- a/server/projects.js +++ b/server/projects.js @@ -1095,7 +1095,7 @@ async function addProjectManually(projectPath, displayName = null) { } // Generate project name (encode path for use as directory name) - const projectName = absolutePath.replace(/\//g, '-'); + const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-'); // Check if project already exists in config const config = await loadProjectConfig(); diff --git a/server/routes/projects.js b/server/routes/projects.js index 042de51..7db0537 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -7,11 +7,17 @@ import { addProjectManually } from '../projects.js'; const router = express.Router(); +function sanitizeGitError(message, token) { + if (!message || !token) return message; + return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***'); +} + // Configure allowed workspace root (defaults to user's home directory) const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir(); // System-critical paths that should never be used as workspace directories -const FORBIDDEN_PATHS = [ +export const FORBIDDEN_PATHS = [ + // Unix '/', '/etc', '/bin', @@ -27,7 +33,14 @@ const FORBIDDEN_PATHS = [ '/lib64', '/opt', '/tmp', - '/run' + '/run', + // Windows + 'C:\\Windows', + 'C:\\Program Files', + 'C:\\Program Files (x86)', + 'C:\\ProgramData', + 'C:\\System Volume Information', + 'C:\\$Recycle.Bin' ]; /** @@ -212,20 +225,7 @@ router.post('/create-workspace', async (req, res) => { // Handle new workspace creation if (workspaceType === 'new') { - // Check if path already exists - try { - await fs.access(absolutePath); - return res.status(400).json({ - error: 'Path already exists. Please choose a different path or use "existing workspace" option.' - }); - } catch (error) { - if (error.code !== 'ENOENT') { - throw error; - } - // Path doesn't exist - good, we can create it - } - - // Create the directory + // Create the directory if it doesn't exist await fs.mkdir(absolutePath, { recursive: true }); // If GitHub URL is provided, clone the repository @@ -246,30 +246,55 @@ router.post('/create-workspace', async (req, res) => { githubToken = newGithubToken; } - // Clone the repository + // Extract repo name from URL for the clone destination + const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, ''); + const repoName = normalizedUrl.split('/').pop() || 'repository'; + const clonePath = path.join(absolutePath, repoName); + + // Check if clone destination already exists to prevent data loss try { - await cloneGitHubRepository(githubUrl, absolutePath, githubToken); + await fs.access(clonePath); + return res.status(409).json({ + error: 'Directory already exists', + details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.` + }); + } catch (err) { + // Directory doesn't exist, which is what we want + } + + // Clone the repository into a subfolder + try { + await cloneGitHubRepository(githubUrl, clonePath, githubToken); } catch (error) { - // Clean up created directory on failure + // Only clean up if clone created partial data (check if dir exists and is empty or partial) try { - await fs.rm(absolutePath, { recursive: true, force: true }); + const stats = await fs.stat(clonePath); + if (stats.isDirectory()) { + await fs.rm(clonePath, { recursive: true, force: true }); + } } catch (cleanupError) { - console.error('Failed to clean up directory after clone failure:', cleanupError); - // Continue to throw original error + // Directory doesn't exist or cleanup failed - ignore } throw new Error(`Failed to clone repository: ${error.message}`); } + + // Add the cloned repo path to the project list + const project = await addProjectManually(clonePath); + + return res.json({ + success: true, + project, + message: 'New workspace created and repository cloned successfully' + }); } - // Add the new workspace to the project list + // Add the new workspace to the project list (no clone) const project = await addProjectManually(absolutePath); return res.json({ success: true, project, - message: githubUrl - ? 'New workspace created and repository cloned successfully' - : 'New workspace created successfully' + message: 'New workspace created successfully' }); } @@ -305,31 +330,179 @@ async function getGithubTokenById(tokenId, userId) { return null; } +/** + * Clone repository with progress streaming (SSE) + * GET /api/projects/clone-progress + */ +router.get('/clone-progress', async (req, res) => { + const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query; + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const sendEvent = (type, data) => { + res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`); + }; + + try { + if (!workspacePath || !githubUrl) { + sendEvent('error', { message: 'workspacePath and githubUrl are required' }); + res.end(); + return; + } + + const validation = await validateWorkspacePath(workspacePath); + if (!validation.valid) { + sendEvent('error', { message: validation.error }); + res.end(); + return; + } + + const absolutePath = validation.resolvedPath; + + await fs.mkdir(absolutePath, { recursive: true }); + + let githubToken = null; + if (githubTokenId) { + const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id); + if (!token) { + await fs.rm(absolutePath, { recursive: true, force: true }); + sendEvent('error', { message: 'GitHub token not found' }); + res.end(); + return; + } + githubToken = token.github_token; + } else if (newGithubToken) { + githubToken = newGithubToken; + } + + const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, ''); + const repoName = normalizedUrl.split('/').pop() || 'repository'; + const clonePath = path.join(absolutePath, repoName); + + // Check if clone destination already exists to prevent data loss + try { + await fs.access(clonePath); + sendEvent('error', { message: `Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.` }); + res.end(); + return; + } catch (err) { + // Directory doesn't exist, which is what we want + } + + let cloneUrl = githubUrl; + if (githubToken) { + try { + const url = new URL(githubUrl); + url.username = githubToken; + url.password = ''; + cloneUrl = url.toString(); + } catch (error) { + // SSH URL or invalid - use as-is + } + } + + sendEvent('progress', { message: `Cloning into '${repoName}'...` }); + + const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0' + } + }); + + let lastError = ''; + + gitProcess.stdout.on('data', (data) => { + const message = data.toString().trim(); + if (message) { + sendEvent('progress', { message }); + } + }); + + gitProcess.stderr.on('data', (data) => { + const message = data.toString().trim(); + lastError = message; + if (message) { + sendEvent('progress', { message }); + } + }); + + gitProcess.on('close', async (code) => { + if (code === 0) { + try { + const project = await addProjectManually(clonePath); + sendEvent('complete', { project, message: 'Repository cloned successfully' }); + } catch (error) { + sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` }); + } + } else { + const sanitizedError = sanitizeGitError(lastError, githubToken); + let errorMessage = 'Git clone failed'; + if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) { + errorMessage = 'Authentication failed. Please check your credentials.'; + } else if (lastError.includes('Repository not found')) { + errorMessage = 'Repository not found. Please check the URL and ensure you have access.'; + } else if (lastError.includes('already exists')) { + errorMessage = 'Directory already exists'; + } else if (sanitizedError) { + errorMessage = sanitizedError; + } + try { + await fs.rm(clonePath, { recursive: true, force: true }); + } catch (cleanupError) { + console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken)); + } + sendEvent('error', { message: errorMessage }); + } + res.end(); + }); + + gitProcess.on('error', (error) => { + if (error.code === 'ENOENT') { + sendEvent('error', { message: 'Git is not installed or not in PATH' }); + } else { + sendEvent('error', { message: error.message }); + } + res.end(); + }); + + req.on('close', () => { + gitProcess.kill(); + }); + + } catch (error) { + sendEvent('error', { message: error.message }); + res.end(); + } +}); + /** * Helper function to clone a GitHub repository */ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) { return new Promise((resolve, reject) => { - // Parse GitHub URL and inject token if provided let cloneUrl = githubUrl; if (githubToken) { try { const url = new URL(githubUrl); - // Format: https://TOKEN@github.com/user/repo.git url.username = githubToken; url.password = ''; cloneUrl = url.toString(); } catch (error) { - return reject(new Error('Invalid GitHub URL format')); + // SSH URL - use as-is } } - const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], { + const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, - GIT_TERMINAL_PROMPT: '0' // Disable git password prompts + GIT_TERMINAL_PROMPT: '0' } }); @@ -348,7 +521,6 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) { if (code === 0) { resolve({ stdout, stderr }); } else { - // Parse git error messages to provide helpful feedback let errorMessage = 'Git clone failed'; if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) { diff --git a/src/components/ProjectCreationWizard.jsx b/src/components/ProjectCreationWizard.jsx index 2109e56..dd6ab72 100644 --- a/src/components/ProjectCreationWizard.jsx +++ b/src/components/ProjectCreationWizard.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff } from 'lucide-react'; +import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { api } from '../utils/api'; @@ -30,6 +30,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const [browserFolders, setBrowserFolders] = useState([]); const [loadingFolders, setLoadingFolders] = useState(false); const [showHiddenFolders, setShowHiddenFolders] = useState(false); + const [showNewFolderInput, setShowNewFolderInput] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [creatingFolder, setCreatingFolder] = useState(false); + const [cloneProgress, setCloneProgress] = useState(''); // Load available GitHub tokens when needed useEffect(() => { @@ -78,9 +82,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const data = await response.json(); if (data.suggestions) { - // Filter suggestions based on the input + // Filter suggestions based on the input, excluding exact match const filtered = data.suggestions.filter(s => - s.path.toLowerCase().startsWith(inputPath.toLowerCase()) + s.path.toLowerCase().startsWith(inputPath.toLowerCase()) && + s.path.toLowerCase() !== inputPath.toLowerCase() ); setPathSuggestions(filtered.slice(0, 5)); setShowPathDropdown(filtered.length > 0); @@ -118,24 +123,62 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const handleCreate = async () => { setIsCreating(true); setError(null); + setCloneProgress(''); try { + if (workspaceType === 'new' && githubUrl) { + const params = new URLSearchParams({ + path: workspacePath.trim(), + githubUrl: githubUrl.trim(), + }); + + if (tokenMode === 'stored' && selectedGithubToken) { + params.append('githubTokenId', selectedGithubToken); + } else if (tokenMode === 'new' && newGithubToken) { + params.append('newGithubToken', newGithubToken.trim()); + } + + const token = localStorage.getItem('auth-token'); + const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`; + + await new Promise((resolve, reject) => { + const eventSource = new EventSource(url); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.type === 'progress') { + setCloneProgress(data.message); + } else if (data.type === 'complete') { + eventSource.close(); + if (onProjectCreated) { + onProjectCreated(data.project); + } + onClose(); + resolve(); + } else if (data.type === 'error') { + eventSource.close(); + reject(new Error(data.message)); + } + } catch (e) { + console.error('Error parsing SSE event:', e); + } + }; + + eventSource.onerror = () => { + eventSource.close(); + reject(new Error('Connection lost during clone')); + }; + }); + return; + } + const payload = { workspaceType, path: workspacePath.trim(), }; - // Add GitHub info if creating new workspace with GitHub URL - if (workspaceType === 'new' && githubUrl) { - payload.githubUrl = githubUrl.trim(); - - if (tokenMode === 'stored' && selectedGithubToken) { - payload.githubTokenId = parseInt(selectedGithubToken); - } else if (tokenMode === 'new' && newGithubToken) { - payload.newGithubToken = newGithubToken.trim(); - } - } - const response = await api.createWorkspace(payload); const data = await response.json(); @@ -143,7 +186,6 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { throw new Error(data.error || t('projectWizard.errors.failedToCreate')); } - // Success! if (onProjectCreated) { onProjectCreated(data.project); } @@ -170,9 +212,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const loadBrowserFolders = async (path) => { try { setLoadingFolders(true); - setBrowserCurrentPath(path); const response = await api.browseFilesystem(path); const data = await response.json(); + setBrowserCurrentPath(data.path || path); setBrowserFolders(data.suggestions || []); } catch (error) { console.error('Error loading folders:', error); @@ -193,6 +235,29 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { await loadBrowserFolders(folderPath); }; + const createNewFolder = async () => { + if (!newFolderName.trim()) return; + setCreatingFolder(true); + setError(null); + try { + const separator = browserCurrentPath.includes('\\') ? '\\' : '/'; + const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`; + const response = await api.createFolder(folderPath); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder')); + } + setNewFolderName(''); + setShowNewFolderInput(false); + await loadBrowserFolders(data.path || folderPath); + } catch (error) { + console.error('Error creating folder:', error); + setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder')); + } finally { + setCreatingFolder(false); + } + }; + return (
- {workspaceType === 'existing' - ? t('projectWizard.step3.existingInfo') - : githubUrl - ? t('projectWizard.step3.newWithClone') - : t('projectWizard.step3.newEmpty')} -
+ {isCreating && cloneProgress ? ( +{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}
+
+ {cloneProgress}
+
+ + {workspaceType === 'existing' + ? t('projectWizard.step3.existingInfo') + : githubUrl + ? t('projectWizard.step3.newWithClone') + : t('projectWizard.step3.newEmpty')} +
+ )}