deckide 3.0.0

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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +192 -0
  3. package/apps/server/dist/config.js +77 -0
  4. package/apps/server/dist/index.js +5 -0
  5. package/apps/server/dist/middleware/auth.js +78 -0
  6. package/apps/server/dist/middleware/cors.js +26 -0
  7. package/apps/server/dist/middleware/security.js +16 -0
  8. package/apps/server/dist/pty-client.js +177 -0
  9. package/apps/server/dist/pty-daemon.js +246 -0
  10. package/apps/server/dist/routes/decks.js +95 -0
  11. package/apps/server/dist/routes/files.js +221 -0
  12. package/apps/server/dist/routes/git.js +775 -0
  13. package/apps/server/dist/routes/settings.js +95 -0
  14. package/apps/server/dist/routes/terminals.js +239 -0
  15. package/apps/server/dist/routes/workspaces.js +83 -0
  16. package/apps/server/dist/server.js +257 -0
  17. package/apps/server/dist/types.js +1 -0
  18. package/apps/server/dist/utils/database.js +136 -0
  19. package/apps/server/dist/utils/error.js +28 -0
  20. package/apps/server/dist/utils/path.js +98 -0
  21. package/apps/server/dist/utils/shell.js +4 -0
  22. package/apps/server/dist/websocket.js +207 -0
  23. package/apps/server/package.json +26 -0
  24. package/apps/web/dist/assets/index-C-usl0y0.css +32 -0
  25. package/apps/web/dist/assets/index-CibzlLP5.js +129 -0
  26. package/apps/web/dist/index.html +13 -0
  27. package/bin/deckide.js +79 -0
  28. package/package.json +77 -0
  29. package/packages/shared/dist/types.d.ts +124 -0
  30. package/packages/shared/dist/types.d.ts.map +1 -0
  31. package/packages/shared/dist/types.js +3 -0
  32. package/packages/shared/dist/types.js.map +1 -0
  33. package/packages/shared/dist/utils-node.d.ts +22 -0
  34. package/packages/shared/dist/utils-node.d.ts.map +1 -0
  35. package/packages/shared/dist/utils-node.js +35 -0
  36. package/packages/shared/dist/utils-node.js.map +1 -0
  37. package/packages/shared/dist/utils.d.ts +90 -0
  38. package/packages/shared/dist/utils.d.ts.map +1 -0
  39. package/packages/shared/dist/utils.js +186 -0
  40. package/packages/shared/dist/utils.js.map +1 -0
  41. package/packages/shared/package.json +16 -0
@@ -0,0 +1,775 @@
1
+ import { Hono } from 'hono';
2
+ import { simpleGit } from 'simple-git';
3
+ import fs from 'node:fs/promises';
4
+ import nodePath from 'node:path';
5
+ import { createHttpError, handleError, readJson } from '../utils/error.js';
6
+ import { resolveSafePath } from '../utils/path.js';
7
+ // Maximum depth to search for git repos
8
+ const MAX_SEARCH_DEPTH = 5;
9
+ // Directories to skip when searching
10
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt', 'vendor', '__pycache__']);
11
+ // Security: Validate file paths to prevent command injection
12
+ const DANGEROUS_PATH_PATTERNS = [
13
+ /^-/, // Paths starting with dash (could be interpreted as options)
14
+ /\.\.[/\\]/, // Path traversal
15
+ /^[/\\]/, // Absolute paths
16
+ /[\x00-\x1f]/, // Control characters
17
+ /^~/, // Home directory expansion
18
+ ];
19
+ const MAX_PATH_LENGTH = 500;
20
+ const MAX_PATHS_COUNT = 100;
21
+ function isValidGitPath(filePath) {
22
+ if (!filePath || typeof filePath !== 'string') {
23
+ return false;
24
+ }
25
+ if (filePath.length > MAX_PATH_LENGTH) {
26
+ return false;
27
+ }
28
+ for (const pattern of DANGEROUS_PATH_PATTERNS) {
29
+ if (pattern.test(filePath)) {
30
+ return false;
31
+ }
32
+ }
33
+ return true;
34
+ }
35
+ function validateGitPaths(paths) {
36
+ if (!Array.isArray(paths)) {
37
+ throw createHttpError('paths must be an array', 400);
38
+ }
39
+ if (paths.length === 0) {
40
+ throw createHttpError('paths cannot be empty', 400);
41
+ }
42
+ if (paths.length > MAX_PATHS_COUNT) {
43
+ throw createHttpError(`Too many paths (max: ${MAX_PATHS_COUNT})`, 400);
44
+ }
45
+ const validatedPaths = [];
46
+ for (const p of paths) {
47
+ if (typeof p !== 'string') {
48
+ throw createHttpError('All paths must be strings', 400);
49
+ }
50
+ if (!isValidGitPath(p)) {
51
+ throw createHttpError(`Invalid path: ${p}`, 400);
52
+ }
53
+ validatedPaths.push(p);
54
+ }
55
+ return validatedPaths;
56
+ }
57
+ function validateCommitMessage(message) {
58
+ if (!message || typeof message !== 'string') {
59
+ throw createHttpError('message is required', 400);
60
+ }
61
+ const trimmed = message.trim();
62
+ if (trimmed.length === 0) {
63
+ throw createHttpError('message cannot be empty', 400);
64
+ }
65
+ if (trimmed.length > 10000) {
66
+ throw createHttpError('message is too long (max: 10000 characters)', 400);
67
+ }
68
+ return trimmed;
69
+ }
70
+ function requireWorkspace(workspaces, workspaceId) {
71
+ const workspace = workspaces.get(workspaceId);
72
+ if (!workspace) {
73
+ throw createHttpError('Workspace not found', 404);
74
+ }
75
+ return workspace;
76
+ }
77
+ function parseFileStatus(status) {
78
+ const files = [];
79
+ // Staged files (index changes)
80
+ for (const file of status.staged) {
81
+ files.push({
82
+ path: file,
83
+ status: 'staged',
84
+ staged: true
85
+ });
86
+ }
87
+ // Modified files (working tree changes, not staged)
88
+ for (const file of status.modified) {
89
+ // Check if already in staged list
90
+ if (!files.some((f) => f.path === file && f.staged)) {
91
+ files.push({
92
+ path: file,
93
+ status: 'modified',
94
+ staged: false
95
+ });
96
+ }
97
+ }
98
+ // Untracked files
99
+ for (const file of status.not_added) {
100
+ files.push({
101
+ path: file,
102
+ status: 'untracked',
103
+ staged: false
104
+ });
105
+ }
106
+ // Deleted files
107
+ for (const file of status.deleted) {
108
+ files.push({
109
+ path: file,
110
+ status: 'deleted',
111
+ staged: false
112
+ });
113
+ }
114
+ // Renamed files
115
+ for (const file of status.renamed) {
116
+ files.push({
117
+ path: file.to,
118
+ status: 'renamed',
119
+ staged: true
120
+ });
121
+ }
122
+ // Conflicted files
123
+ for (const file of status.conflicted) {
124
+ files.push({
125
+ path: file,
126
+ status: 'conflicted',
127
+ staged: false
128
+ });
129
+ }
130
+ // Created/added files (staged new files)
131
+ for (const file of status.created) {
132
+ if (!files.some((f) => f.path === file)) {
133
+ files.push({
134
+ path: file,
135
+ status: 'staged',
136
+ staged: true
137
+ });
138
+ }
139
+ }
140
+ return files;
141
+ }
142
+ async function isGitRepository(git) {
143
+ try {
144
+ await git.revparse(['--git-dir']);
145
+ return true;
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
151
+ /**
152
+ * Recursively find all git repositories within a directory
153
+ * Returns relative paths from the workspace root (using forward slashes for cross-platform compatibility)
154
+ */
155
+ async function findGitRepos(basePath, currentPath = '', depth = 0) {
156
+ if (depth > MAX_SEARCH_DEPTH) {
157
+ return [];
158
+ }
159
+ const repos = [];
160
+ const fullPath = currentPath ? nodePath.join(basePath, currentPath) : basePath;
161
+ try {
162
+ const entries = await fs.readdir(fullPath, { withFileTypes: true });
163
+ // Check if this directory is a git repo
164
+ const hasGitDir = entries.some(e => e.isDirectory() && e.name === '.git');
165
+ if (hasGitDir) {
166
+ // Use forward slashes for consistency
167
+ repos.push(currentPath.replace(/\\/g, '/'));
168
+ // Don't recurse into nested git repos (submodules are handled separately by git)
169
+ return repos;
170
+ }
171
+ // Recurse into subdirectories
172
+ for (const entry of entries) {
173
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
174
+ const subPath = currentPath ? nodePath.join(currentPath, entry.name) : entry.name;
175
+ const subRepos = await findGitRepos(basePath, subPath, depth + 1);
176
+ repos.push(...subRepos);
177
+ }
178
+ }
179
+ }
180
+ catch (error) {
181
+ // Ignore permission errors or other issues
182
+ console.error('Error scanning directory:', fullPath, error);
183
+ }
184
+ return repos;
185
+ }
186
+ async function readFileContent(workspacePath, filePath) {
187
+ try {
188
+ // Use resolveSafePath for proper symlink validation
189
+ const resolved = await resolveSafePath(workspacePath, filePath);
190
+ return await fs.readFile(resolved, 'utf-8');
191
+ }
192
+ catch (error) {
193
+ // Only return empty for file-not-found errors
194
+ if (error.code === 'ENOENT') {
195
+ return '';
196
+ }
197
+ // For security errors (path traversal), re-throw
198
+ if (error?.status === 400) {
199
+ throw error;
200
+ }
201
+ // Log unexpected errors but return empty to avoid breaking diff
202
+ console.error('Error reading file for git diff:', error);
203
+ return '';
204
+ }
205
+ }
206
+ async function getOriginalContent(git, filePath) {
207
+ try {
208
+ return await git.show([`HEAD:${filePath}`]);
209
+ }
210
+ catch (error) {
211
+ // New file or not in HEAD - expected error
212
+ const message = error?.message || '';
213
+ if (message.includes('does not exist') || message.includes('fatal:')) {
214
+ return '';
215
+ }
216
+ // Log unexpected git errors
217
+ console.error('Error getting original content from git:', error);
218
+ return '';
219
+ }
220
+ }
221
+ async function resolveRepoPath(workspacePath, repoPath) {
222
+ if (!repoPath)
223
+ return workspacePath;
224
+ return resolveSafePath(workspacePath, repoPath);
225
+ }
226
+ export function createGitRouter(workspaces) {
227
+ const router = new Hono();
228
+ // GET /api/git/status?workspaceId=xxx&repoPath=xxx (optional repoPath for specific repo)
229
+ router.get('/status', async (c) => {
230
+ try {
231
+ const workspaceId = c.req.query('workspaceId');
232
+ const repoPath = c.req.query('repoPath'); // Optional: specific repo path
233
+ if (!workspaceId) {
234
+ throw createHttpError('workspaceId is required', 400);
235
+ }
236
+ const workspace = requireWorkspace(workspaces, workspaceId);
237
+ // If repoPath is specified, get status for that specific repo
238
+ if (repoPath !== undefined) {
239
+ const fullRepoPath = await resolveRepoPath(workspace.path, repoPath);
240
+ const git = simpleGit(fullRepoPath);
241
+ const isRepo = await isGitRepository(git);
242
+ if (!isRepo) {
243
+ return c.json({
244
+ isGitRepo: false,
245
+ branch: '',
246
+ files: []
247
+ });
248
+ }
249
+ const status = await git.status();
250
+ const files = parseFileStatus(status);
251
+ return c.json({
252
+ isGitRepo: true,
253
+ branch: status.current ?? 'HEAD',
254
+ files
255
+ });
256
+ }
257
+ // Default behavior: check root repo only (backwards compatible)
258
+ const git = simpleGit(workspace.path);
259
+ const isRepo = await isGitRepository(git);
260
+ if (!isRepo) {
261
+ return c.json({
262
+ isGitRepo: false,
263
+ branch: '',
264
+ files: []
265
+ });
266
+ }
267
+ const status = await git.status();
268
+ const files = parseFileStatus(status);
269
+ return c.json({
270
+ isGitRepo: true,
271
+ branch: status.current ?? 'HEAD',
272
+ files
273
+ });
274
+ }
275
+ catch (error) {
276
+ return handleError(c, error);
277
+ }
278
+ });
279
+ // GET /api/git/repos?workspaceId=xxx - Find all git repos in workspace
280
+ router.get('/repos', async (c) => {
281
+ try {
282
+ const workspaceId = c.req.query('workspaceId');
283
+ if (!workspaceId) {
284
+ throw createHttpError('workspaceId is required', 400);
285
+ }
286
+ const workspace = requireWorkspace(workspaces, workspaceId);
287
+ const repoPaths = await findGitRepos(workspace.path);
288
+ // Get info for each repo
289
+ const repos = [];
290
+ for (const repoPath of repoPaths) {
291
+ const fullPath = await resolveRepoPath(workspace.path, repoPath);
292
+ const git = simpleGit(fullPath);
293
+ try {
294
+ const status = await git.status();
295
+ const files = parseFileStatus(status);
296
+ repos.push({
297
+ path: repoPath,
298
+ name: repoPath ? nodePath.basename(repoPath) : 'root',
299
+ branch: status.current ?? 'HEAD',
300
+ fileCount: files.length
301
+ });
302
+ }
303
+ catch {
304
+ // Skip repos that fail to get status
305
+ }
306
+ }
307
+ return c.json({ repos });
308
+ }
309
+ catch (error) {
310
+ return handleError(c, error);
311
+ }
312
+ });
313
+ // GET /api/git/multi-status?workspaceId=xxx - Get aggregated status from all repos
314
+ router.get('/multi-status', async (c) => {
315
+ try {
316
+ const workspaceId = c.req.query('workspaceId');
317
+ if (!workspaceId) {
318
+ throw createHttpError('workspaceId is required', 400);
319
+ }
320
+ const workspace = requireWorkspace(workspaces, workspaceId);
321
+ const repoPaths = await findGitRepos(workspace.path);
322
+ const repos = [];
323
+ const allFiles = [];
324
+ for (const repoPath of repoPaths) {
325
+ const fullPath = await resolveRepoPath(workspace.path, repoPath);
326
+ const git = simpleGit(fullPath);
327
+ try {
328
+ const status = await git.status();
329
+ const files = parseFileStatus(status);
330
+ repos.push({
331
+ path: repoPath,
332
+ name: repoPath ? nodePath.basename(repoPath) : 'root',
333
+ branch: status.current ?? 'HEAD',
334
+ fileCount: files.length
335
+ });
336
+ // Add repo path to each file
337
+ for (const file of files) {
338
+ allFiles.push({
339
+ ...file,
340
+ // Prefix file path with repo path for non-root repos
341
+ path: repoPath ? nodePath.join(repoPath, file.path) : file.path,
342
+ repoPath
343
+ });
344
+ }
345
+ }
346
+ catch {
347
+ // Skip repos that fail to get status
348
+ }
349
+ }
350
+ return c.json({
351
+ repos,
352
+ files: allFiles
353
+ });
354
+ }
355
+ catch (error) {
356
+ return handleError(c, error);
357
+ }
358
+ });
359
+ // POST /api/git/stage
360
+ router.post('/stage', async (c) => {
361
+ try {
362
+ const body = await readJson(c);
363
+ if (!body?.workspaceId) {
364
+ throw createHttpError('workspaceId is required', 400);
365
+ }
366
+ const paths = validateGitPaths(body.paths);
367
+ const workspace = requireWorkspace(workspaces, body.workspaceId);
368
+ const repoFullPath = await resolveRepoPath(workspace.path, body.repoPath);
369
+ const git = simpleGit(repoFullPath);
370
+ await git.add(paths);
371
+ return c.json({ success: true });
372
+ }
373
+ catch (error) {
374
+ return handleError(c, error);
375
+ }
376
+ });
377
+ // POST /api/git/unstage
378
+ router.post('/unstage', async (c) => {
379
+ try {
380
+ const body = await readJson(c);
381
+ if (!body?.workspaceId) {
382
+ throw createHttpError('workspaceId is required', 400);
383
+ }
384
+ const paths = validateGitPaths(body.paths);
385
+ const workspace = requireWorkspace(workspaces, body.workspaceId);
386
+ const repoFullPath = await resolveRepoPath(workspace.path, body.repoPath);
387
+ const git = simpleGit(repoFullPath);
388
+ await git.reset(['HEAD', '--', ...paths]);
389
+ return c.json({ success: true });
390
+ }
391
+ catch (error) {
392
+ return handleError(c, error);
393
+ }
394
+ });
395
+ // POST /api/git/commit
396
+ router.post('/commit', async (c) => {
397
+ try {
398
+ const body = await readJson(c);
399
+ if (!body?.workspaceId) {
400
+ throw createHttpError('workspaceId is required', 400);
401
+ }
402
+ const message = validateCommitMessage(body.message);
403
+ const workspace = requireWorkspace(workspaces, body.workspaceId);
404
+ const repoFullPath = await resolveRepoPath(workspace.path, body.repoPath);
405
+ const git = simpleGit(repoFullPath);
406
+ const result = await git.commit(message);
407
+ return c.json({
408
+ success: true,
409
+ commit: result.commit ?? '',
410
+ summary: {
411
+ changes: result.summary.changes,
412
+ insertions: result.summary.insertions,
413
+ deletions: result.summary.deletions
414
+ }
415
+ });
416
+ }
417
+ catch (error) {
418
+ return handleError(c, error);
419
+ }
420
+ });
421
+ // POST /api/git/discard
422
+ router.post('/discard', async (c) => {
423
+ try {
424
+ const body = await readJson(c);
425
+ if (!body?.workspaceId) {
426
+ throw createHttpError('workspaceId is required', 400);
427
+ }
428
+ const paths = validateGitPaths(body.paths);
429
+ const workspace = requireWorkspace(workspaces, body.workspaceId);
430
+ const repoFullPath = await resolveRepoPath(workspace.path, body.repoPath);
431
+ const git = simpleGit(repoFullPath);
432
+ // First, check if any of these are untracked files
433
+ const status = await git.status();
434
+ const untrackedPaths = paths.filter((p) => status.not_added.includes(p));
435
+ const trackedPaths = paths.filter((p) => !status.not_added.includes(p));
436
+ // For tracked files, use checkout to discard changes
437
+ if (trackedPaths.length > 0) {
438
+ await git.checkout(['--', ...trackedPaths]);
439
+ }
440
+ // For untracked files, verify each path exists and is within workspace before deleting
441
+ for (const untrackedPath of untrackedPaths) {
442
+ try {
443
+ // Use resolveSafePath for proper symlink validation (relative to repo path)
444
+ const resolved = await resolveSafePath(repoFullPath, untrackedPath);
445
+ await fs.unlink(resolved);
446
+ }
447
+ catch (error) {
448
+ // File might already be deleted or path validation failed
449
+ if (error?.status === 400) {
450
+ throw error; // Re-throw security errors
451
+ }
452
+ // Ignore ENOENT (file not found) errors
453
+ }
454
+ }
455
+ return c.json({ success: true });
456
+ }
457
+ catch (error) {
458
+ return handleError(c, error);
459
+ }
460
+ });
461
+ // GET /api/git/diff?workspaceId=xxx&path=xxx&staged=bool&repoPath=xxx
462
+ router.get('/diff', async (c) => {
463
+ try {
464
+ const workspaceId = c.req.query('workspaceId');
465
+ const filePath = c.req.query('path');
466
+ const staged = c.req.query('staged') === 'true';
467
+ const repoPath = c.req.query('repoPath');
468
+ if (!workspaceId) {
469
+ throw createHttpError('workspaceId is required', 400);
470
+ }
471
+ if (!filePath) {
472
+ throw createHttpError('path is required', 400);
473
+ }
474
+ if (!isValidGitPath(filePath)) {
475
+ throw createHttpError('Invalid path', 400);
476
+ }
477
+ const workspace = requireWorkspace(workspaces, workspaceId);
478
+ const repoFullPath = await resolveRepoPath(workspace.path, repoPath);
479
+ const git = simpleGit(repoFullPath);
480
+ let original = '';
481
+ let modified = '';
482
+ const status = await git.status();
483
+ const isUntracked = status.not_added.includes(filePath);
484
+ if (isUntracked) {
485
+ // For untracked files, original is empty
486
+ original = '';
487
+ modified = await readFileContent(repoFullPath, filePath);
488
+ }
489
+ else if (staged) {
490
+ // For staged changes, compare HEAD to working tree
491
+ original = await getOriginalContent(git, filePath);
492
+ modified = await readFileContent(repoFullPath, filePath);
493
+ }
494
+ else {
495
+ // For unstaged changes, compare HEAD to working tree
496
+ original = await getOriginalContent(git, filePath);
497
+ modified = await readFileContent(repoFullPath, filePath);
498
+ }
499
+ return c.json({
500
+ original,
501
+ modified,
502
+ path: filePath
503
+ });
504
+ }
505
+ catch (error) {
506
+ return handleError(c, error);
507
+ }
508
+ });
509
+ // POST /api/git/push
510
+ router.post('/push', async (c) => {
511
+ try {
512
+ const body = await readJson(c);
513
+ if (!body?.workspaceId) {
514
+ throw createHttpError('workspaceId is required', 400);
515
+ }
516
+ const workspace = requireWorkspace(workspaces, body.workspaceId);
517
+ const repoFullPath = await resolveRepoPath(workspace.path, body.repoPath);
518
+ const git = simpleGit(repoFullPath);
519
+ // Check if we have a remote
520
+ const remotes = await git.getRemotes(true);
521
+ if (remotes.length === 0) {
522
+ throw createHttpError('No remote configured', 400);
523
+ }
524
+ // Get current branch
525
+ const status = await git.status();
526
+ const branch = status.current;
527
+ if (!branch) {
528
+ throw createHttpError('No branch checked out', 400);
529
+ }
530
+ // Push to origin
531
+ const result = await git.push('origin', branch);
532
+ return c.json({
533
+ success: true,
534
+ pushed: result.pushed || [],
535
+ branch
536
+ });
537
+ }
538
+ catch (error) {
539
+ return handleError(c, error);
540
+ }
541
+ });
542
+ // POST /api/git/pull
543
+ router.post('/pull', async (c) => {
544
+ try {
545
+ const body = await readJson(c);
546
+ if (!body?.workspaceId) {
547
+ throw createHttpError('workspaceId is required', 400);
548
+ }
549
+ const workspace = requireWorkspace(workspaces, body.workspaceId);
550
+ const repoFullPath = await resolveRepoPath(workspace.path, body.repoPath);
551
+ const git = simpleGit(repoFullPath);
552
+ // Check if we have a remote
553
+ const remotes = await git.getRemotes(true);
554
+ if (remotes.length === 0) {
555
+ throw createHttpError('No remote configured', 400);
556
+ }
557
+ const result = await git.pull();
558
+ return c.json({
559
+ success: true,
560
+ summary: {
561
+ changes: result.summary.changes,
562
+ insertions: result.summary.insertions,
563
+ deletions: result.summary.deletions
564
+ }
565
+ });
566
+ }
567
+ catch (error) {
568
+ return handleError(c, error);
569
+ }
570
+ });
571
+ // POST /api/git/fetch
572
+ router.post('/fetch', async (c) => {
573
+ try {
574
+ const body = await readJson(c);
575
+ if (!body?.workspaceId) {
576
+ throw createHttpError('workspaceId is required', 400);
577
+ }
578
+ const workspace = requireWorkspace(workspaces, body.workspaceId);
579
+ const repoFullPath = await resolveRepoPath(workspace.path, body.repoPath);
580
+ const git = simpleGit(repoFullPath);
581
+ await git.fetch();
582
+ return c.json({ success: true });
583
+ }
584
+ catch (error) {
585
+ return handleError(c, error);
586
+ }
587
+ });
588
+ // GET /api/git/remotes?workspaceId=xxx&repoPath=xxx
589
+ router.get('/remotes', async (c) => {
590
+ try {
591
+ const workspaceId = c.req.query('workspaceId');
592
+ const repoPath = c.req.query('repoPath');
593
+ if (!workspaceId) {
594
+ throw createHttpError('workspaceId is required', 400);
595
+ }
596
+ const workspace = requireWorkspace(workspaces, workspaceId);
597
+ const repoFullPath = await resolveRepoPath(workspace.path, repoPath);
598
+ const git = simpleGit(repoFullPath);
599
+ const isRepo = await isGitRepository(git);
600
+ if (!isRepo) {
601
+ return c.json({ remotes: [], hasRemote: false });
602
+ }
603
+ const remotes = await git.getRemotes(true);
604
+ return c.json({
605
+ remotes: remotes.map((r) => ({
606
+ name: r.name,
607
+ fetchUrl: r.refs.fetch,
608
+ pushUrl: r.refs.push
609
+ })),
610
+ hasRemote: remotes.length > 0
611
+ });
612
+ }
613
+ catch (error) {
614
+ return handleError(c, error);
615
+ }
616
+ });
617
+ // GET /api/git/branch-status?workspaceId=xxx&repoPath=xxx
618
+ router.get('/branch-status', async (c) => {
619
+ try {
620
+ const workspaceId = c.req.query('workspaceId');
621
+ const repoPath = c.req.query('repoPath');
622
+ if (!workspaceId) {
623
+ throw createHttpError('workspaceId is required', 400);
624
+ }
625
+ const workspace = requireWorkspace(workspaces, workspaceId);
626
+ const repoFullPath = await resolveRepoPath(workspace.path, repoPath);
627
+ const git = simpleGit(repoFullPath);
628
+ const isRepo = await isGitRepository(git);
629
+ if (!isRepo) {
630
+ return c.json({
631
+ ahead: 0,
632
+ behind: 0,
633
+ hasUpstream: false
634
+ });
635
+ }
636
+ const status = await git.status();
637
+ return c.json({
638
+ ahead: status.ahead,
639
+ behind: status.behind,
640
+ hasUpstream: status.tracking !== null
641
+ });
642
+ }
643
+ catch (error) {
644
+ return handleError(c, error);
645
+ }
646
+ });
647
+ // GET /api/git/branches?workspaceId=xxx&repoPath=xxx
648
+ router.get('/branches', async (c) => {
649
+ try {
650
+ const workspaceId = c.req.query('workspaceId');
651
+ const repoPath = c.req.query('repoPath');
652
+ if (!workspaceId) {
653
+ throw createHttpError('workspaceId is required', 400);
654
+ }
655
+ const workspace = requireWorkspace(workspaces, workspaceId);
656
+ const repoFullPath = await resolveRepoPath(workspace.path, repoPath);
657
+ const git = simpleGit(repoFullPath);
658
+ const isRepo = await isGitRepository(git);
659
+ if (!isRepo) {
660
+ return c.json({ branches: [], currentBranch: '' });
661
+ }
662
+ const branchSummary = await git.branchLocal();
663
+ const branches = branchSummary.all.map((name) => ({
664
+ name,
665
+ current: name === branchSummary.current
666
+ }));
667
+ return c.json({
668
+ branches,
669
+ currentBranch: branchSummary.current
670
+ });
671
+ }
672
+ catch (error) {
673
+ return handleError(c, error);
674
+ }
675
+ });
676
+ // POST /api/git/checkout
677
+ router.post('/checkout', async (c) => {
678
+ try {
679
+ const body = await readJson(c);
680
+ if (!body?.workspaceId) {
681
+ throw createHttpError('workspaceId is required', 400);
682
+ }
683
+ if (!body?.branchName) {
684
+ throw createHttpError('branchName is required', 400);
685
+ }
686
+ // Validate branch name
687
+ const branchName = body.branchName.trim();
688
+ if (!branchName || branchName.length > 250) {
689
+ throw createHttpError('Invalid branch name', 400);
690
+ }
691
+ // Prevent injection via branch names
692
+ if (/[;&|`$<>\\]/.test(branchName)) {
693
+ throw createHttpError('Invalid characters in branch name', 400);
694
+ }
695
+ const workspace = requireWorkspace(workspaces, body.workspaceId);
696
+ const repoFullPath = await resolveRepoPath(workspace.path, body.repoPath);
697
+ const git = simpleGit(repoFullPath);
698
+ await git.checkout(branchName);
699
+ return c.json({ success: true });
700
+ }
701
+ catch (error) {
702
+ return handleError(c, error);
703
+ }
704
+ });
705
+ // POST /api/git/create-branch
706
+ router.post('/create-branch', async (c) => {
707
+ try {
708
+ const body = await readJson(c);
709
+ if (!body?.workspaceId) {
710
+ throw createHttpError('workspaceId is required', 400);
711
+ }
712
+ if (!body?.branchName) {
713
+ throw createHttpError('branchName is required', 400);
714
+ }
715
+ // Validate branch name
716
+ const branchName = body.branchName.trim();
717
+ if (!branchName || branchName.length > 250) {
718
+ throw createHttpError('Invalid branch name', 400);
719
+ }
720
+ // Prevent injection via branch names and validate format
721
+ if (/[;&|`$<>\\~^:?*\[\]@{}\s]/.test(branchName)) {
722
+ throw createHttpError('Invalid characters in branch name', 400);
723
+ }
724
+ if (branchName.startsWith('-') || branchName.startsWith('.') || branchName.endsWith('.') || branchName.endsWith('/')) {
725
+ throw createHttpError('Invalid branch name format', 400);
726
+ }
727
+ const workspace = requireWorkspace(workspaces, body.workspaceId);
728
+ const repoFullPath = await resolveRepoPath(workspace.path, body.repoPath);
729
+ const git = simpleGit(repoFullPath);
730
+ const checkout = body.checkout !== false;
731
+ if (checkout) {
732
+ await git.checkoutLocalBranch(branchName);
733
+ }
734
+ else {
735
+ await git.branch([branchName]);
736
+ }
737
+ return c.json({ success: true });
738
+ }
739
+ catch (error) {
740
+ return handleError(c, error);
741
+ }
742
+ });
743
+ // GET /api/git/log?workspaceId=xxx&limit=50&repoPath=xxx
744
+ router.get('/log', async (c) => {
745
+ try {
746
+ const workspaceId = c.req.query('workspaceId');
747
+ const limitStr = c.req.query('limit') || '50';
748
+ const repoPath = c.req.query('repoPath');
749
+ if (!workspaceId) {
750
+ throw createHttpError('workspaceId is required', 400);
751
+ }
752
+ const limit = Math.min(Math.max(1, parseInt(limitStr, 10) || 50), 500);
753
+ const workspace = requireWorkspace(workspaces, workspaceId);
754
+ const repoFullPath = await resolveRepoPath(workspace.path, repoPath);
755
+ const git = simpleGit(repoFullPath);
756
+ const isRepo = await isGitRepository(git);
757
+ if (!isRepo) {
758
+ return c.json({ logs: [] });
759
+ }
760
+ const logResult = await git.log({ maxCount: limit });
761
+ const logs = logResult.all.map((entry) => ({
762
+ hash: entry.hash,
763
+ hashShort: entry.hash.slice(0, 7),
764
+ message: entry.message,
765
+ author: entry.author_name,
766
+ date: entry.date
767
+ }));
768
+ return c.json({ logs });
769
+ }
770
+ catch (error) {
771
+ return handleError(c, error);
772
+ }
773
+ });
774
+ return router;
775
+ }