Skip to content

Commit 519b5e5

Browse files
authored
Merge pull request #227 from siteboon/feature/new-project-creation
feat(projects): add project creation wizard with enhanced UX
2 parents 401223d + 7ab1475 commit 519b5e5

File tree

9 files changed

+1000
-396
lines changed

9 files changed

+1000
-396
lines changed

server/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
6969
import commandsRoutes from './routes/commands.js';
7070
import settingsRoutes from './routes/settings.js';
7171
import agentRoutes from './routes/agent.js';
72+
import projectsRoutes from './routes/projects.js';
7273
import { initializeDatabase } from './database/db.js';
7374
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
7475

@@ -201,6 +202,9 @@ app.use('/api', validateApiKey);
201202
// Authentication routes (public)
202203
app.use('/api/auth', authRoutes);
203204

205+
// Projects API Routes (protected)
206+
app.use('/api/projects', authenticateToken, projectsRoutes);
207+
204208
// Git API Routes (protected)
205209
app.use('/api/git', authenticateToken, gitRoutes);
206210

server/routes/projects.js

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
import express from 'express';
2+
import { promises as fs } from 'fs';
3+
import path from 'path';
4+
import { spawn } from 'child_process';
5+
import os from 'os';
6+
import { addProjectManually } from '../projects.js';
7+
8+
const router = express.Router();
9+
10+
// Configure allowed workspace root (defaults to user's home directory)
11+
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
12+
13+
// System-critical paths that should never be used as workspace directories
14+
const FORBIDDEN_PATHS = [
15+
'/',
16+
'/etc',
17+
'/bin',
18+
'/sbin',
19+
'/usr',
20+
'/dev',
21+
'/proc',
22+
'/sys',
23+
'/var',
24+
'/boot',
25+
'/root',
26+
'/lib',
27+
'/lib64',
28+
'/opt',
29+
'/tmp',
30+
'/run'
31+
];
32+
33+
/**
34+
* Validates that a path is safe for workspace operations
35+
* @param {string} requestedPath - The path to validate
36+
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
37+
*/
38+
async function validateWorkspacePath(requestedPath) {
39+
try {
40+
// Resolve to absolute path
41+
let absolutePath = path.resolve(requestedPath);
42+
43+
// Check if path is a forbidden system directory
44+
const normalizedPath = path.normalize(absolutePath);
45+
if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {
46+
return {
47+
valid: false,
48+
error: 'Cannot use system-critical directories as workspace locations'
49+
};
50+
}
51+
52+
// Additional check for paths starting with forbidden directories
53+
for (const forbidden of FORBIDDEN_PATHS) {
54+
if (normalizedPath === forbidden ||
55+
normalizedPath.startsWith(forbidden + path.sep)) {
56+
// Exception: /var/tmp and similar user-accessible paths might be allowed
57+
// but /var itself and most /var subdirectories should be blocked
58+
if (forbidden === '/var' &&
59+
(normalizedPath.startsWith('/var/tmp') ||
60+
normalizedPath.startsWith('/var/folders'))) {
61+
continue; // Allow these specific cases
62+
}
63+
64+
return {
65+
valid: false,
66+
error: `Cannot create workspace in system directory: ${forbidden}`
67+
};
68+
}
69+
}
70+
71+
// Try to resolve the real path (following symlinks)
72+
let realPath;
73+
try {
74+
// Check if path exists to resolve real path
75+
await fs.access(absolutePath);
76+
realPath = await fs.realpath(absolutePath);
77+
} catch (error) {
78+
if (error.code === 'ENOENT') {
79+
// Path doesn't exist yet - check parent directory
80+
let parentPath = path.dirname(absolutePath);
81+
try {
82+
const parentRealPath = await fs.realpath(parentPath);
83+
84+
// Reconstruct the full path with real parent
85+
realPath = path.join(parentRealPath, path.basename(absolutePath));
86+
} catch (parentError) {
87+
if (parentError.code === 'ENOENT') {
88+
// Parent doesn't exist either - use the absolute path as-is
89+
// We'll validate it's within allowed root
90+
realPath = absolutePath;
91+
} else {
92+
throw parentError;
93+
}
94+
}
95+
} else {
96+
throw error;
97+
}
98+
}
99+
100+
// Resolve the workspace root to its real path
101+
const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);
102+
103+
// Ensure the resolved path is contained within the allowed workspace root
104+
if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) &&
105+
realPath !== resolvedWorkspaceRoot) {
106+
return {
107+
valid: false,
108+
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`
109+
};
110+
}
111+
112+
// Additional symlink check for existing paths
113+
try {
114+
await fs.access(absolutePath);
115+
const stats = await fs.lstat(absolutePath);
116+
117+
if (stats.isSymbolicLink()) {
118+
// Verify symlink target is also within allowed root
119+
const linkTarget = await fs.readlink(absolutePath);
120+
const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
121+
const realTarget = await fs.realpath(resolvedTarget);
122+
123+
if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&
124+
realTarget !== resolvedWorkspaceRoot) {
125+
return {
126+
valid: false,
127+
error: 'Symlink target is outside the allowed workspace root'
128+
};
129+
}
130+
}
131+
} catch (error) {
132+
if (error.code !== 'ENOENT') {
133+
throw error;
134+
}
135+
// Path doesn't exist - that's fine for new workspace creation
136+
}
137+
138+
return {
139+
valid: true,
140+
resolvedPath: realPath
141+
};
142+
143+
} catch (error) {
144+
return {
145+
valid: false,
146+
error: `Path validation failed: ${error.message}`
147+
};
148+
}
149+
}
150+
151+
/**
152+
* Create a new workspace
153+
* POST /api/projects/create-workspace
154+
*
155+
* Body:
156+
* - workspaceType: 'existing' | 'new'
157+
* - path: string (workspace path)
158+
* - githubUrl?: string (optional, for new workspaces)
159+
* - githubTokenId?: number (optional, ID of stored token)
160+
* - newGithubToken?: string (optional, one-time token)
161+
*/
162+
router.post('/create-workspace', async (req, res) => {
163+
try {
164+
const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
165+
166+
// Validate required fields
167+
if (!workspaceType || !workspacePath) {
168+
return res.status(400).json({ error: 'workspaceType and path are required' });
169+
}
170+
171+
if (!['existing', 'new'].includes(workspaceType)) {
172+
return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
173+
}
174+
175+
// Validate path safety before any operations
176+
const validation = await validateWorkspacePath(workspacePath);
177+
if (!validation.valid) {
178+
return res.status(400).json({
179+
error: 'Invalid workspace path',
180+
details: validation.error
181+
});
182+
}
183+
184+
const absolutePath = validation.resolvedPath;
185+
186+
// Handle existing workspace
187+
if (workspaceType === 'existing') {
188+
// Check if the path exists
189+
try {
190+
await fs.access(absolutePath);
191+
const stats = await fs.stat(absolutePath);
192+
193+
if (!stats.isDirectory()) {
194+
return res.status(400).json({ error: 'Path exists but is not a directory' });
195+
}
196+
} catch (error) {
197+
if (error.code === 'ENOENT') {
198+
return res.status(404).json({ error: 'Workspace path does not exist' });
199+
}
200+
throw error;
201+
}
202+
203+
// Add the existing workspace to the project list
204+
const project = await addProjectManually(absolutePath);
205+
206+
return res.json({
207+
success: true,
208+
project,
209+
message: 'Existing workspace added successfully'
210+
});
211+
}
212+
213+
// Handle new workspace creation
214+
if (workspaceType === 'new') {
215+
// Check if path already exists
216+
try {
217+
await fs.access(absolutePath);
218+
return res.status(400).json({
219+
error: 'Path already exists. Please choose a different path or use "existing workspace" option.'
220+
});
221+
} catch (error) {
222+
if (error.code !== 'ENOENT') {
223+
throw error;
224+
}
225+
// Path doesn't exist - good, we can create it
226+
}
227+
228+
// Create the directory
229+
await fs.mkdir(absolutePath, { recursive: true });
230+
231+
// If GitHub URL is provided, clone the repository
232+
if (githubUrl) {
233+
let githubToken = null;
234+
235+
// Get GitHub token if needed
236+
if (githubTokenId) {
237+
// Fetch token from database
238+
const token = await getGithubTokenById(githubTokenId, req.user.id);
239+
if (!token) {
240+
// Clean up created directory
241+
await fs.rm(absolutePath, { recursive: true, force: true });
242+
return res.status(404).json({ error: 'GitHub token not found' });
243+
}
244+
githubToken = token.github_token;
245+
} else if (newGithubToken) {
246+
githubToken = newGithubToken;
247+
}
248+
249+
// Clone the repository
250+
try {
251+
await cloneGitHubRepository(githubUrl, absolutePath, githubToken);
252+
} catch (error) {
253+
// Clean up created directory on failure
254+
try {
255+
await fs.rm(absolutePath, { recursive: true, force: true });
256+
} catch (cleanupError) {
257+
console.error('Failed to clean up directory after clone failure:', cleanupError);
258+
// Continue to throw original error
259+
}
260+
throw new Error(`Failed to clone repository: ${error.message}`);
261+
}
262+
}
263+
264+
// Add the new workspace to the project list
265+
const project = await addProjectManually(absolutePath);
266+
267+
return res.json({
268+
success: true,
269+
project,
270+
message: githubUrl
271+
? 'New workspace created and repository cloned successfully'
272+
: 'New workspace created successfully'
273+
});
274+
}
275+
276+
} catch (error) {
277+
console.error('Error creating workspace:', error);
278+
res.status(500).json({
279+
error: error.message || 'Failed to create workspace',
280+
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
281+
});
282+
}
283+
});
284+
285+
/**
286+
* Helper function to get GitHub token from database
287+
*/
288+
async function getGithubTokenById(tokenId, userId) {
289+
const { getDatabase } = await import('../database/db.js');
290+
const db = await getDatabase();
291+
292+
const credential = await db.get(
293+
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
294+
[tokenId, userId, 'github_token']
295+
);
296+
297+
// Return in the expected format (github_token field for compatibility)
298+
if (credential) {
299+
return {
300+
...credential,
301+
github_token: credential.credential_value
302+
};
303+
}
304+
305+
return null;
306+
}
307+
308+
/**
309+
* Helper function to clone a GitHub repository
310+
*/
311+
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
312+
return new Promise((resolve, reject) => {
313+
// Parse GitHub URL and inject token if provided
314+
let cloneUrl = githubUrl;
315+
316+
if (githubToken) {
317+
try {
318+
const url = new URL(githubUrl);
319+
// Format: https://TOKEN@github.com/user/repo.git
320+
url.username = githubToken;
321+
url.password = '';
322+
cloneUrl = url.toString();
323+
} catch (error) {
324+
return reject(new Error('Invalid GitHub URL format'));
325+
}
326+
}
327+
328+
const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
329+
stdio: ['ignore', 'pipe', 'pipe'],
330+
env: {
331+
...process.env,
332+
GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
333+
}
334+
});
335+
336+
let stdout = '';
337+
let stderr = '';
338+
339+
gitProcess.stdout.on('data', (data) => {
340+
stdout += data.toString();
341+
});
342+
343+
gitProcess.stderr.on('data', (data) => {
344+
stderr += data.toString();
345+
});
346+
347+
gitProcess.on('close', (code) => {
348+
if (code === 0) {
349+
resolve({ stdout, stderr });
350+
} else {
351+
// Parse git error messages to provide helpful feedback
352+
let errorMessage = 'Git clone failed';
353+
354+
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
355+
errorMessage = 'Authentication failed. Please check your GitHub token.';
356+
} else if (stderr.includes('Repository not found')) {
357+
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
358+
} else if (stderr.includes('already exists')) {
359+
errorMessage = 'Directory already exists';
360+
} else if (stderr) {
361+
errorMessage = stderr;
362+
}
363+
364+
reject(new Error(errorMessage));
365+
}
366+
});
367+
368+
gitProcess.on('error', (error) => {
369+
if (error.code === 'ENOENT') {
370+
reject(new Error('Git is not installed or not in PATH'));
371+
} else {
372+
reject(error);
373+
}
374+
});
375+
});
376+
}
377+
378+
export default router;

0 commit comments

Comments
 (0)