@siteboon/claude-code-ui 1.13.6 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,11 +7,17 @@ import { addProjectManually } from '../projects.js';
7
7
 
8
8
  const router = express.Router();
9
9
 
10
+ function sanitizeGitError(message, token) {
11
+ if (!message || !token) return message;
12
+ return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
13
+ }
14
+
10
15
  // Configure allowed workspace root (defaults to user's home directory)
11
16
  const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
12
17
 
13
18
  // System-critical paths that should never be used as workspace directories
14
- const FORBIDDEN_PATHS = [
19
+ export const FORBIDDEN_PATHS = [
20
+ // Unix
15
21
  '/',
16
22
  '/etc',
17
23
  '/bin',
@@ -27,7 +33,14 @@ const FORBIDDEN_PATHS = [
27
33
  '/lib64',
28
34
  '/opt',
29
35
  '/tmp',
30
- '/run'
36
+ '/run',
37
+ // Windows
38
+ 'C:\\Windows',
39
+ 'C:\\Program Files',
40
+ 'C:\\Program Files (x86)',
41
+ 'C:\\ProgramData',
42
+ 'C:\\System Volume Information',
43
+ 'C:\\$Recycle.Bin'
31
44
  ];
32
45
 
33
46
  /**
@@ -212,20 +225,7 @@ router.post('/create-workspace', async (req, res) => {
212
225
 
213
226
  // Handle new workspace creation
214
227
  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
228
+ // Create the directory if it doesn't exist
229
229
  await fs.mkdir(absolutePath, { recursive: true });
230
230
 
231
231
  // If GitHub URL is provided, clone the repository
@@ -246,30 +246,55 @@ router.post('/create-workspace', async (req, res) => {
246
246
  githubToken = newGithubToken;
247
247
  }
248
248
 
249
- // Clone the repository
249
+ // Extract repo name from URL for the clone destination
250
+ const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
251
+ const repoName = normalizedUrl.split('/').pop() || 'repository';
252
+ const clonePath = path.join(absolutePath, repoName);
253
+
254
+ // Check if clone destination already exists to prevent data loss
250
255
  try {
251
- await cloneGitHubRepository(githubUrl, absolutePath, githubToken);
256
+ await fs.access(clonePath);
257
+ return res.status(409).json({
258
+ error: 'Directory already exists',
259
+ details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.`
260
+ });
261
+ } catch (err) {
262
+ // Directory doesn't exist, which is what we want
263
+ }
264
+
265
+ // Clone the repository into a subfolder
266
+ try {
267
+ await cloneGitHubRepository(githubUrl, clonePath, githubToken);
252
268
  } catch (error) {
253
- // Clean up created directory on failure
269
+ // Only clean up if clone created partial data (check if dir exists and is empty or partial)
254
270
  try {
255
- await fs.rm(absolutePath, { recursive: true, force: true });
271
+ const stats = await fs.stat(clonePath);
272
+ if (stats.isDirectory()) {
273
+ await fs.rm(clonePath, { recursive: true, force: true });
274
+ }
256
275
  } catch (cleanupError) {
257
- console.error('Failed to clean up directory after clone failure:', cleanupError);
258
- // Continue to throw original error
276
+ // Directory doesn't exist or cleanup failed - ignore
259
277
  }
260
278
  throw new Error(`Failed to clone repository: ${error.message}`);
261
279
  }
280
+
281
+ // Add the cloned repo path to the project list
282
+ const project = await addProjectManually(clonePath);
283
+
284
+ return res.json({
285
+ success: true,
286
+ project,
287
+ message: 'New workspace created and repository cloned successfully'
288
+ });
262
289
  }
263
290
 
264
- // Add the new workspace to the project list
291
+ // Add the new workspace to the project list (no clone)
265
292
  const project = await addProjectManually(absolutePath);
266
293
 
267
294
  return res.json({
268
295
  success: true,
269
296
  project,
270
- message: githubUrl
271
- ? 'New workspace created and repository cloned successfully'
272
- : 'New workspace created successfully'
297
+ message: 'New workspace created successfully'
273
298
  });
274
299
  }
275
300
 
@@ -305,31 +330,179 @@ async function getGithubTokenById(tokenId, userId) {
305
330
  return null;
306
331
  }
307
332
 
333
+ /**
334
+ * Clone repository with progress streaming (SSE)
335
+ * GET /api/projects/clone-progress
336
+ */
337
+ router.get('/clone-progress', async (req, res) => {
338
+ const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query;
339
+
340
+ res.setHeader('Content-Type', 'text/event-stream');
341
+ res.setHeader('Cache-Control', 'no-cache');
342
+ res.setHeader('Connection', 'keep-alive');
343
+ res.flushHeaders();
344
+
345
+ const sendEvent = (type, data) => {
346
+ res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
347
+ };
348
+
349
+ try {
350
+ if (!workspacePath || !githubUrl) {
351
+ sendEvent('error', { message: 'workspacePath and githubUrl are required' });
352
+ res.end();
353
+ return;
354
+ }
355
+
356
+ const validation = await validateWorkspacePath(workspacePath);
357
+ if (!validation.valid) {
358
+ sendEvent('error', { message: validation.error });
359
+ res.end();
360
+ return;
361
+ }
362
+
363
+ const absolutePath = validation.resolvedPath;
364
+
365
+ await fs.mkdir(absolutePath, { recursive: true });
366
+
367
+ let githubToken = null;
368
+ if (githubTokenId) {
369
+ const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
370
+ if (!token) {
371
+ await fs.rm(absolutePath, { recursive: true, force: true });
372
+ sendEvent('error', { message: 'GitHub token not found' });
373
+ res.end();
374
+ return;
375
+ }
376
+ githubToken = token.github_token;
377
+ } else if (newGithubToken) {
378
+ githubToken = newGithubToken;
379
+ }
380
+
381
+ const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
382
+ const repoName = normalizedUrl.split('/').pop() || 'repository';
383
+ const clonePath = path.join(absolutePath, repoName);
384
+
385
+ // Check if clone destination already exists to prevent data loss
386
+ try {
387
+ await fs.access(clonePath);
388
+ sendEvent('error', { message: `Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.` });
389
+ res.end();
390
+ return;
391
+ } catch (err) {
392
+ // Directory doesn't exist, which is what we want
393
+ }
394
+
395
+ let cloneUrl = githubUrl;
396
+ if (githubToken) {
397
+ try {
398
+ const url = new URL(githubUrl);
399
+ url.username = githubToken;
400
+ url.password = '';
401
+ cloneUrl = url.toString();
402
+ } catch (error) {
403
+ // SSH URL or invalid - use as-is
404
+ }
405
+ }
406
+
407
+ sendEvent('progress', { message: `Cloning into '${repoName}'...` });
408
+
409
+ const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], {
410
+ stdio: ['ignore', 'pipe', 'pipe'],
411
+ env: {
412
+ ...process.env,
413
+ GIT_TERMINAL_PROMPT: '0'
414
+ }
415
+ });
416
+
417
+ let lastError = '';
418
+
419
+ gitProcess.stdout.on('data', (data) => {
420
+ const message = data.toString().trim();
421
+ if (message) {
422
+ sendEvent('progress', { message });
423
+ }
424
+ });
425
+
426
+ gitProcess.stderr.on('data', (data) => {
427
+ const message = data.toString().trim();
428
+ lastError = message;
429
+ if (message) {
430
+ sendEvent('progress', { message });
431
+ }
432
+ });
433
+
434
+ gitProcess.on('close', async (code) => {
435
+ if (code === 0) {
436
+ try {
437
+ const project = await addProjectManually(clonePath);
438
+ sendEvent('complete', { project, message: 'Repository cloned successfully' });
439
+ } catch (error) {
440
+ sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });
441
+ }
442
+ } else {
443
+ const sanitizedError = sanitizeGitError(lastError, githubToken);
444
+ let errorMessage = 'Git clone failed';
445
+ if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
446
+ errorMessage = 'Authentication failed. Please check your credentials.';
447
+ } else if (lastError.includes('Repository not found')) {
448
+ errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
449
+ } else if (lastError.includes('already exists')) {
450
+ errorMessage = 'Directory already exists';
451
+ } else if (sanitizedError) {
452
+ errorMessage = sanitizedError;
453
+ }
454
+ try {
455
+ await fs.rm(clonePath, { recursive: true, force: true });
456
+ } catch (cleanupError) {
457
+ console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken));
458
+ }
459
+ sendEvent('error', { message: errorMessage });
460
+ }
461
+ res.end();
462
+ });
463
+
464
+ gitProcess.on('error', (error) => {
465
+ if (error.code === 'ENOENT') {
466
+ sendEvent('error', { message: 'Git is not installed or not in PATH' });
467
+ } else {
468
+ sendEvent('error', { message: error.message });
469
+ }
470
+ res.end();
471
+ });
472
+
473
+ req.on('close', () => {
474
+ gitProcess.kill();
475
+ });
476
+
477
+ } catch (error) {
478
+ sendEvent('error', { message: error.message });
479
+ res.end();
480
+ }
481
+ });
482
+
308
483
  /**
309
484
  * Helper function to clone a GitHub repository
310
485
  */
311
486
  function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
312
487
  return new Promise((resolve, reject) => {
313
- // Parse GitHub URL and inject token if provided
314
488
  let cloneUrl = githubUrl;
315
489
 
316
490
  if (githubToken) {
317
491
  try {
318
492
  const url = new URL(githubUrl);
319
- // Format: https://TOKEN@github.com/user/repo.git
320
493
  url.username = githubToken;
321
494
  url.password = '';
322
495
  cloneUrl = url.toString();
323
496
  } catch (error) {
324
- return reject(new Error('Invalid GitHub URL format'));
497
+ // SSH URL - use as-is
325
498
  }
326
499
  }
327
500
 
328
- const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
501
+ const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
329
502
  stdio: ['ignore', 'pipe', 'pipe'],
330
503
  env: {
331
504
  ...process.env,
332
- GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
505
+ GIT_TERMINAL_PROMPT: '0'
333
506
  }
334
507
  });
335
508
 
@@ -348,7 +521,6 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
348
521
  if (code === 0) {
349
522
  resolve({ stdout, stderr });
350
523
  } else {
351
- // Parse git error messages to provide helpful feedback
352
524
  let errorMessage = 'Git clone failed';
353
525
 
354
526
  if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {