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.
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/apps/server/dist/config.js +77 -0
- package/apps/server/dist/index.js +5 -0
- package/apps/server/dist/middleware/auth.js +78 -0
- package/apps/server/dist/middleware/cors.js +26 -0
- package/apps/server/dist/middleware/security.js +16 -0
- package/apps/server/dist/pty-client.js +177 -0
- package/apps/server/dist/pty-daemon.js +246 -0
- package/apps/server/dist/routes/decks.js +95 -0
- package/apps/server/dist/routes/files.js +221 -0
- package/apps/server/dist/routes/git.js +775 -0
- package/apps/server/dist/routes/settings.js +95 -0
- package/apps/server/dist/routes/terminals.js +239 -0
- package/apps/server/dist/routes/workspaces.js +83 -0
- package/apps/server/dist/server.js +257 -0
- package/apps/server/dist/types.js +1 -0
- package/apps/server/dist/utils/database.js +136 -0
- package/apps/server/dist/utils/error.js +28 -0
- package/apps/server/dist/utils/path.js +98 -0
- package/apps/server/dist/utils/shell.js +4 -0
- package/apps/server/dist/websocket.js +207 -0
- package/apps/server/package.json +26 -0
- package/apps/web/dist/assets/index-C-usl0y0.css +32 -0
- package/apps/web/dist/assets/index-CibzlLP5.js +129 -0
- package/apps/web/dist/index.html +13 -0
- package/bin/deckide.js +79 -0
- package/package.json +77 -0
- package/packages/shared/dist/types.d.ts +124 -0
- package/packages/shared/dist/types.d.ts.map +1 -0
- package/packages/shared/dist/types.js +3 -0
- package/packages/shared/dist/types.js.map +1 -0
- package/packages/shared/dist/utils-node.d.ts +22 -0
- package/packages/shared/dist/utils-node.d.ts.map +1 -0
- package/packages/shared/dist/utils-node.js +35 -0
- package/packages/shared/dist/utils-node.js.map +1 -0
- package/packages/shared/dist/utils.d.ts +90 -0
- package/packages/shared/dist/utils.d.ts.map +1 -0
- package/packages/shared/dist/utils.js +186 -0
- package/packages/shared/dist/utils.js.map +1 -0
- 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
|
+
}
|