@epiphytic/claudecodeui 1.0.1 → 1.1.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/dist/assets/index-D0xTNXrF.js +1247 -0
- package/dist/assets/index-DKDK7xNY.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/database/db.js +124 -0
- package/server/database/init.sql +15 -1
- package/server/external-session-detector.js +403 -0
- package/server/index.js +816 -110
- package/server/projects-cache.js +196 -0
- package/server/projects.js +759 -464
- package/server/routes/projects.js +248 -92
- package/server/routes/sessions.js +106 -0
- package/server/session-lock.js +253 -0
- package/server/sessions-cache.js +183 -0
- package/server/tmux-manager.js +403 -0
- package/dist/assets/index-COkp1acE.js +0 -1231
- package/dist/assets/index-DfR9xEkp.css +0 -32
|
@@ -1,33 +1,157 @@
|
|
|
1
|
-
import express from
|
|
2
|
-
import { promises as fs } from
|
|
3
|
-
import path from
|
|
4
|
-
import { spawn } from
|
|
5
|
-
import os from
|
|
6
|
-
import { addProjectManually } from
|
|
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, getProjectDetailFull } from "../projects.js";
|
|
7
|
+
import {
|
|
8
|
+
getProjectsByTimeframe,
|
|
9
|
+
generateETag,
|
|
10
|
+
getCacheMeta,
|
|
11
|
+
isCacheInitialized,
|
|
12
|
+
TIMEFRAME_MS,
|
|
13
|
+
} from "../projects-cache.js";
|
|
7
14
|
|
|
8
15
|
const router = express.Router();
|
|
9
16
|
|
|
17
|
+
/**
|
|
18
|
+
* GET /api/projects/list
|
|
19
|
+
*
|
|
20
|
+
* Returns a slim list of projects for sidebar display with timeframe filtering.
|
|
21
|
+
* Supports ETag/304 caching for efficient polling.
|
|
22
|
+
*
|
|
23
|
+
* Query Parameters:
|
|
24
|
+
* - timeframe: '1h' | '8h' | '1d' | '1w' | '2w' | '1m' | 'all' (default: '1w')
|
|
25
|
+
*
|
|
26
|
+
* Headers:
|
|
27
|
+
* - If-None-Match: ETag from previous response (for 304 support)
|
|
28
|
+
*/
|
|
29
|
+
router.get("/list", (req, res) => {
|
|
30
|
+
try {
|
|
31
|
+
// Check if cache is initialized
|
|
32
|
+
if (!isCacheInitialized()) {
|
|
33
|
+
return res.status(503).json({
|
|
34
|
+
error: "Projects cache not yet initialized",
|
|
35
|
+
message: "Please wait for initial project scan to complete",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Get timeframe from query (validate against known values)
|
|
40
|
+
const timeframe =
|
|
41
|
+
TIMEFRAME_MS[req.query.timeframe] !== undefined
|
|
42
|
+
? req.query.timeframe
|
|
43
|
+
: "1w";
|
|
44
|
+
|
|
45
|
+
// Generate current ETag
|
|
46
|
+
const currentETag = generateETag(timeframe);
|
|
47
|
+
|
|
48
|
+
// Check If-None-Match header for conditional request
|
|
49
|
+
const clientETag = req.headers["if-none-match"];
|
|
50
|
+
if (clientETag && clientETag === currentETag) {
|
|
51
|
+
// Data hasn't changed - return 304
|
|
52
|
+
return res.status(304).end();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Get projects filtered by timeframe
|
|
56
|
+
const { projects, totalCount, filteredCount } =
|
|
57
|
+
getProjectsByTimeframe(timeframe);
|
|
58
|
+
const cacheMeta = getCacheMeta();
|
|
59
|
+
|
|
60
|
+
// Set caching headers
|
|
61
|
+
res.set({
|
|
62
|
+
"Cache-Control": "private, max-age=10",
|
|
63
|
+
ETag: currentETag,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Return projects data
|
|
67
|
+
res.json({
|
|
68
|
+
projects,
|
|
69
|
+
meta: {
|
|
70
|
+
totalCount,
|
|
71
|
+
filteredCount,
|
|
72
|
+
timeframe,
|
|
73
|
+
cacheTimestamp: cacheMeta.timestamp,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error("[ERROR] Projects list endpoint error:", error);
|
|
78
|
+
res.status(500).json({
|
|
79
|
+
error: "Failed to retrieve projects",
|
|
80
|
+
message: error.message,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* GET /api/projects/cache-status
|
|
87
|
+
* Returns current cache status (for debugging/monitoring)
|
|
88
|
+
*/
|
|
89
|
+
router.get("/cache-status", (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
const meta = getCacheMeta();
|
|
92
|
+
res.json({
|
|
93
|
+
initialized: isCacheInitialized(),
|
|
94
|
+
...meta,
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
res.status(500).json({ error: error.message });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* GET /api/projects/:projectName/detail
|
|
103
|
+
*
|
|
104
|
+
* Returns full project data including all sessions.
|
|
105
|
+
* Called when a project is expanded in the sidebar.
|
|
106
|
+
*/
|
|
107
|
+
router.get("/:projectName/detail", async (req, res) => {
|
|
108
|
+
try {
|
|
109
|
+
const { projectName } = req.params;
|
|
110
|
+
|
|
111
|
+
// Decode the project name (URL encoded)
|
|
112
|
+
const decodedName = decodeURIComponent(projectName);
|
|
113
|
+
|
|
114
|
+
// Fetch full project details
|
|
115
|
+
const project = await getProjectDetailFull(decodedName);
|
|
116
|
+
|
|
117
|
+
if (!project) {
|
|
118
|
+
return res.status(404).json({
|
|
119
|
+
error: "Project not found",
|
|
120
|
+
projectName: decodedName,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
res.json(project);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error("[ERROR] Project detail endpoint error:", error);
|
|
127
|
+
res.status(500).json({
|
|
128
|
+
error: "Failed to retrieve project details",
|
|
129
|
+
message: error.message,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
10
134
|
// Configure allowed workspace root (defaults to user's home directory)
|
|
11
135
|
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
|
12
136
|
|
|
13
137
|
// System-critical paths that should never be used as workspace directories
|
|
14
138
|
const FORBIDDEN_PATHS = [
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
139
|
+
"/",
|
|
140
|
+
"/etc",
|
|
141
|
+
"/bin",
|
|
142
|
+
"/sbin",
|
|
143
|
+
"/usr",
|
|
144
|
+
"/dev",
|
|
145
|
+
"/proc",
|
|
146
|
+
"/sys",
|
|
147
|
+
"/var",
|
|
148
|
+
"/boot",
|
|
149
|
+
"/root",
|
|
150
|
+
"/lib",
|
|
151
|
+
"/lib64",
|
|
152
|
+
"/opt",
|
|
153
|
+
"/tmp",
|
|
154
|
+
"/run",
|
|
31
155
|
];
|
|
32
156
|
|
|
33
157
|
/**
|
|
@@ -42,28 +166,32 @@ async function validateWorkspacePath(requestedPath) {
|
|
|
42
166
|
|
|
43
167
|
// Check if path is a forbidden system directory
|
|
44
168
|
const normalizedPath = path.normalize(absolutePath);
|
|
45
|
-
if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath ===
|
|
169
|
+
if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === "/") {
|
|
46
170
|
return {
|
|
47
171
|
valid: false,
|
|
48
|
-
error:
|
|
172
|
+
error: "Cannot use system-critical directories as workspace locations",
|
|
49
173
|
};
|
|
50
174
|
}
|
|
51
175
|
|
|
52
176
|
// Additional check for paths starting with forbidden directories
|
|
53
177
|
for (const forbidden of FORBIDDEN_PATHS) {
|
|
54
|
-
if (
|
|
55
|
-
|
|
178
|
+
if (
|
|
179
|
+
normalizedPath === forbidden ||
|
|
180
|
+
normalizedPath.startsWith(forbidden + path.sep)
|
|
181
|
+
) {
|
|
56
182
|
// Exception: /var/tmp and similar user-accessible paths might be allowed
|
|
57
183
|
// but /var itself and most /var subdirectories should be blocked
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
184
|
+
if (
|
|
185
|
+
forbidden === "/var" &&
|
|
186
|
+
(normalizedPath.startsWith("/var/tmp") ||
|
|
187
|
+
normalizedPath.startsWith("/var/folders"))
|
|
188
|
+
) {
|
|
61
189
|
continue; // Allow these specific cases
|
|
62
190
|
}
|
|
63
191
|
|
|
64
192
|
return {
|
|
65
193
|
valid: false,
|
|
66
|
-
error: `Cannot create workspace in system directory: ${forbidden}
|
|
194
|
+
error: `Cannot create workspace in system directory: ${forbidden}`,
|
|
67
195
|
};
|
|
68
196
|
}
|
|
69
197
|
}
|
|
@@ -75,7 +203,7 @@ async function validateWorkspacePath(requestedPath) {
|
|
|
75
203
|
await fs.access(absolutePath);
|
|
76
204
|
realPath = await fs.realpath(absolutePath);
|
|
77
205
|
} catch (error) {
|
|
78
|
-
if (error.code ===
|
|
206
|
+
if (error.code === "ENOENT") {
|
|
79
207
|
// Path doesn't exist yet - check parent directory
|
|
80
208
|
let parentPath = path.dirname(absolutePath);
|
|
81
209
|
try {
|
|
@@ -84,7 +212,7 @@ async function validateWorkspacePath(requestedPath) {
|
|
|
84
212
|
// Reconstruct the full path with real parent
|
|
85
213
|
realPath = path.join(parentRealPath, path.basename(absolutePath));
|
|
86
214
|
} catch (parentError) {
|
|
87
|
-
if (parentError.code ===
|
|
215
|
+
if (parentError.code === "ENOENT") {
|
|
88
216
|
// Parent doesn't exist either - use the absolute path as-is
|
|
89
217
|
// We'll validate it's within allowed root
|
|
90
218
|
realPath = absolutePath;
|
|
@@ -101,11 +229,13 @@ async function validateWorkspacePath(requestedPath) {
|
|
|
101
229
|
const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);
|
|
102
230
|
|
|
103
231
|
// Ensure the resolved path is contained within the allowed workspace root
|
|
104
|
-
if (
|
|
105
|
-
|
|
232
|
+
if (
|
|
233
|
+
!realPath.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
|
234
|
+
realPath !== resolvedWorkspaceRoot
|
|
235
|
+
) {
|
|
106
236
|
return {
|
|
107
237
|
valid: false,
|
|
108
|
-
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}
|
|
238
|
+
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`,
|
|
109
239
|
};
|
|
110
240
|
}
|
|
111
241
|
|
|
@@ -117,19 +247,24 @@ async function validateWorkspacePath(requestedPath) {
|
|
|
117
247
|
if (stats.isSymbolicLink()) {
|
|
118
248
|
// Verify symlink target is also within allowed root
|
|
119
249
|
const linkTarget = await fs.readlink(absolutePath);
|
|
120
|
-
const resolvedTarget = path.resolve(
|
|
250
|
+
const resolvedTarget = path.resolve(
|
|
251
|
+
path.dirname(absolutePath),
|
|
252
|
+
linkTarget,
|
|
253
|
+
);
|
|
121
254
|
const realTarget = await fs.realpath(resolvedTarget);
|
|
122
255
|
|
|
123
|
-
if (
|
|
124
|
-
|
|
256
|
+
if (
|
|
257
|
+
!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
|
258
|
+
realTarget !== resolvedWorkspaceRoot
|
|
259
|
+
) {
|
|
125
260
|
return {
|
|
126
261
|
valid: false,
|
|
127
|
-
error:
|
|
262
|
+
error: "Symlink target is outside the allowed workspace root",
|
|
128
263
|
};
|
|
129
264
|
}
|
|
130
265
|
}
|
|
131
266
|
} catch (error) {
|
|
132
|
-
if (error.code !==
|
|
267
|
+
if (error.code !== "ENOENT") {
|
|
133
268
|
throw error;
|
|
134
269
|
}
|
|
135
270
|
// Path doesn't exist - that's fine for new workspace creation
|
|
@@ -137,13 +272,12 @@ async function validateWorkspacePath(requestedPath) {
|
|
|
137
272
|
|
|
138
273
|
return {
|
|
139
274
|
valid: true,
|
|
140
|
-
resolvedPath: realPath
|
|
275
|
+
resolvedPath: realPath,
|
|
141
276
|
};
|
|
142
|
-
|
|
143
277
|
} catch (error) {
|
|
144
278
|
return {
|
|
145
279
|
valid: false,
|
|
146
|
-
error: `Path validation failed: ${error.message}
|
|
280
|
+
error: `Path validation failed: ${error.message}`,
|
|
147
281
|
};
|
|
148
282
|
}
|
|
149
283
|
}
|
|
@@ -159,43 +293,57 @@ async function validateWorkspacePath(requestedPath) {
|
|
|
159
293
|
* - githubTokenId?: number (optional, ID of stored token)
|
|
160
294
|
* - newGithubToken?: string (optional, one-time token)
|
|
161
295
|
*/
|
|
162
|
-
router.post(
|
|
296
|
+
router.post("/create-workspace", async (req, res) => {
|
|
163
297
|
try {
|
|
164
|
-
const {
|
|
298
|
+
const {
|
|
299
|
+
workspaceType,
|
|
300
|
+
path: workspacePath,
|
|
301
|
+
githubUrl,
|
|
302
|
+
githubTokenId,
|
|
303
|
+
newGithubToken,
|
|
304
|
+
} = req.body;
|
|
165
305
|
|
|
166
306
|
// Validate required fields
|
|
167
307
|
if (!workspaceType || !workspacePath) {
|
|
168
|
-
return res
|
|
308
|
+
return res
|
|
309
|
+
.status(400)
|
|
310
|
+
.json({ error: "workspaceType and path are required" });
|
|
169
311
|
}
|
|
170
312
|
|
|
171
|
-
if (![
|
|
172
|
-
return res
|
|
313
|
+
if (!["existing", "new"].includes(workspaceType)) {
|
|
314
|
+
return res
|
|
315
|
+
.status(400)
|
|
316
|
+
.json({ error: 'workspaceType must be "existing" or "new"' });
|
|
173
317
|
}
|
|
174
318
|
|
|
175
319
|
// Validate path safety before any operations
|
|
176
320
|
const validation = await validateWorkspacePath(workspacePath);
|
|
177
321
|
if (!validation.valid) {
|
|
178
322
|
return res.status(400).json({
|
|
179
|
-
error:
|
|
180
|
-
details: validation.error
|
|
323
|
+
error: "Invalid workspace path",
|
|
324
|
+
details: validation.error,
|
|
181
325
|
});
|
|
182
326
|
}
|
|
183
327
|
|
|
184
328
|
const absolutePath = validation.resolvedPath;
|
|
185
329
|
|
|
186
330
|
// Handle existing workspace
|
|
187
|
-
if (workspaceType ===
|
|
331
|
+
if (workspaceType === "existing") {
|
|
188
332
|
// Check if the path exists
|
|
189
333
|
try {
|
|
190
334
|
await fs.access(absolutePath);
|
|
191
335
|
const stats = await fs.stat(absolutePath);
|
|
192
336
|
|
|
193
337
|
if (!stats.isDirectory()) {
|
|
194
|
-
return res
|
|
338
|
+
return res
|
|
339
|
+
.status(400)
|
|
340
|
+
.json({ error: "Path exists but is not a directory" });
|
|
195
341
|
}
|
|
196
342
|
} catch (error) {
|
|
197
|
-
if (error.code ===
|
|
198
|
-
return res
|
|
343
|
+
if (error.code === "ENOENT") {
|
|
344
|
+
return res
|
|
345
|
+
.status(404)
|
|
346
|
+
.json({ error: "Workspace path does not exist" });
|
|
199
347
|
}
|
|
200
348
|
throw error;
|
|
201
349
|
}
|
|
@@ -206,20 +354,21 @@ router.post('/create-workspace', async (req, res) => {
|
|
|
206
354
|
return res.json({
|
|
207
355
|
success: true,
|
|
208
356
|
project,
|
|
209
|
-
message:
|
|
357
|
+
message: "Existing workspace added successfully",
|
|
210
358
|
});
|
|
211
359
|
}
|
|
212
360
|
|
|
213
361
|
// Handle new workspace creation
|
|
214
|
-
if (workspaceType ===
|
|
362
|
+
if (workspaceType === "new") {
|
|
215
363
|
// Check if path already exists
|
|
216
364
|
try {
|
|
217
365
|
await fs.access(absolutePath);
|
|
218
366
|
return res.status(400).json({
|
|
219
|
-
error:
|
|
367
|
+
error:
|
|
368
|
+
'Path already exists. Please choose a different path or use "existing workspace" option.',
|
|
220
369
|
});
|
|
221
370
|
} catch (error) {
|
|
222
|
-
if (error.code !==
|
|
371
|
+
if (error.code !== "ENOENT") {
|
|
223
372
|
throw error;
|
|
224
373
|
}
|
|
225
374
|
// Path doesn't exist - good, we can create it
|
|
@@ -239,7 +388,7 @@ router.post('/create-workspace', async (req, res) => {
|
|
|
239
388
|
if (!token) {
|
|
240
389
|
// Clean up created directory
|
|
241
390
|
await fs.rm(absolutePath, { recursive: true, force: true });
|
|
242
|
-
return res.status(404).json({ error:
|
|
391
|
+
return res.status(404).json({ error: "GitHub token not found" });
|
|
243
392
|
}
|
|
244
393
|
githubToken = token.github_token;
|
|
245
394
|
} else if (newGithubToken) {
|
|
@@ -254,7 +403,10 @@ router.post('/create-workspace', async (req, res) => {
|
|
|
254
403
|
try {
|
|
255
404
|
await fs.rm(absolutePath, { recursive: true, force: true });
|
|
256
405
|
} catch (cleanupError) {
|
|
257
|
-
console.error(
|
|
406
|
+
console.error(
|
|
407
|
+
"Failed to clean up directory after clone failure:",
|
|
408
|
+
cleanupError,
|
|
409
|
+
);
|
|
258
410
|
// Continue to throw original error
|
|
259
411
|
}
|
|
260
412
|
throw new Error(`Failed to clone repository: ${error.message}`);
|
|
@@ -268,16 +420,15 @@ router.post('/create-workspace', async (req, res) => {
|
|
|
268
420
|
success: true,
|
|
269
421
|
project,
|
|
270
422
|
message: githubUrl
|
|
271
|
-
?
|
|
272
|
-
:
|
|
423
|
+
? "New workspace created and repository cloned successfully"
|
|
424
|
+
: "New workspace created successfully",
|
|
273
425
|
});
|
|
274
426
|
}
|
|
275
|
-
|
|
276
427
|
} catch (error) {
|
|
277
|
-
console.error(
|
|
428
|
+
console.error("Error creating workspace:", error);
|
|
278
429
|
res.status(500).json({
|
|
279
|
-
error: error.message ||
|
|
280
|
-
details: process.env.NODE_ENV ===
|
|
430
|
+
error: error.message || "Failed to create workspace",
|
|
431
|
+
details: process.env.NODE_ENV === "development" ? error.stack : undefined,
|
|
281
432
|
});
|
|
282
433
|
}
|
|
283
434
|
});
|
|
@@ -286,19 +437,19 @@ router.post('/create-workspace', async (req, res) => {
|
|
|
286
437
|
* Helper function to get GitHub token from database
|
|
287
438
|
*/
|
|
288
439
|
async function getGithubTokenById(tokenId, userId) {
|
|
289
|
-
const { getDatabase } = await import(
|
|
440
|
+
const { getDatabase } = await import("../database/db.js");
|
|
290
441
|
const db = await getDatabase();
|
|
291
442
|
|
|
292
443
|
const credential = await db.get(
|
|
293
|
-
|
|
294
|
-
[tokenId, userId,
|
|
444
|
+
"SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1",
|
|
445
|
+
[tokenId, userId, "github_token"],
|
|
295
446
|
);
|
|
296
447
|
|
|
297
448
|
// Return in the expected format (github_token field for compatibility)
|
|
298
449
|
if (credential) {
|
|
299
450
|
return {
|
|
300
451
|
...credential,
|
|
301
|
-
github_token: credential.credential_value
|
|
452
|
+
github_token: credential.credential_value,
|
|
302
453
|
};
|
|
303
454
|
}
|
|
304
455
|
|
|
@@ -318,45 +469,50 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
|
|
318
469
|
const url = new URL(githubUrl);
|
|
319
470
|
// Format: https://TOKEN@github.com/user/repo.git
|
|
320
471
|
url.username = githubToken;
|
|
321
|
-
url.password =
|
|
472
|
+
url.password = "";
|
|
322
473
|
cloneUrl = url.toString();
|
|
323
474
|
} catch (error) {
|
|
324
|
-
return reject(new Error(
|
|
475
|
+
return reject(new Error("Invalid GitHub URL format"));
|
|
325
476
|
}
|
|
326
477
|
}
|
|
327
478
|
|
|
328
|
-
const gitProcess = spawn(
|
|
329
|
-
stdio: [
|
|
479
|
+
const gitProcess = spawn("git", ["clone", cloneUrl, destinationPath], {
|
|
480
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
330
481
|
env: {
|
|
331
482
|
...process.env,
|
|
332
|
-
GIT_TERMINAL_PROMPT:
|
|
333
|
-
}
|
|
483
|
+
GIT_TERMINAL_PROMPT: "0", // Disable git password prompts
|
|
484
|
+
},
|
|
334
485
|
});
|
|
335
486
|
|
|
336
|
-
let stdout =
|
|
337
|
-
let stderr =
|
|
487
|
+
let stdout = "";
|
|
488
|
+
let stderr = "";
|
|
338
489
|
|
|
339
|
-
gitProcess.stdout.on(
|
|
490
|
+
gitProcess.stdout.on("data", (data) => {
|
|
340
491
|
stdout += data.toString();
|
|
341
492
|
});
|
|
342
493
|
|
|
343
|
-
gitProcess.stderr.on(
|
|
494
|
+
gitProcess.stderr.on("data", (data) => {
|
|
344
495
|
stderr += data.toString();
|
|
345
496
|
});
|
|
346
497
|
|
|
347
|
-
gitProcess.on(
|
|
498
|
+
gitProcess.on("close", (code) => {
|
|
348
499
|
if (code === 0) {
|
|
349
500
|
resolve({ stdout, stderr });
|
|
350
501
|
} else {
|
|
351
502
|
// Parse git error messages to provide helpful feedback
|
|
352
|
-
let errorMessage =
|
|
353
|
-
|
|
354
|
-
if (
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
503
|
+
let errorMessage = "Git clone failed";
|
|
504
|
+
|
|
505
|
+
if (
|
|
506
|
+
stderr.includes("Authentication failed") ||
|
|
507
|
+
stderr.includes("could not read Username")
|
|
508
|
+
) {
|
|
509
|
+
errorMessage =
|
|
510
|
+
"Authentication failed. Please check your GitHub token.";
|
|
511
|
+
} else if (stderr.includes("Repository not found")) {
|
|
512
|
+
errorMessage =
|
|
513
|
+
"Repository not found. Please check the URL and ensure you have access.";
|
|
514
|
+
} else if (stderr.includes("already exists")) {
|
|
515
|
+
errorMessage = "Directory already exists";
|
|
360
516
|
} else if (stderr) {
|
|
361
517
|
errorMessage = stderr;
|
|
362
518
|
}
|
|
@@ -365,9 +521,9 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
|
|
365
521
|
}
|
|
366
522
|
});
|
|
367
523
|
|
|
368
|
-
gitProcess.on(
|
|
369
|
-
if (error.code ===
|
|
370
|
-
reject(new Error(
|
|
524
|
+
gitProcess.on("error", (error) => {
|
|
525
|
+
if (error.code === "ENOENT") {
|
|
526
|
+
reject(new Error("Git is not installed or not in PATH"));
|
|
371
527
|
} else {
|
|
372
528
|
reject(error);
|
|
373
529
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SESSIONS API ROUTES
|
|
3
|
+
* ===================
|
|
4
|
+
*
|
|
5
|
+
* GET /api/sessions/list
|
|
6
|
+
* Returns a flat list of all sessions with optional timeframe filtering.
|
|
7
|
+
* Supports ETag/304 caching for efficient polling.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import express from "express";
|
|
11
|
+
import {
|
|
12
|
+
getSessionsByTimeframe,
|
|
13
|
+
generateETag,
|
|
14
|
+
getCacheMeta,
|
|
15
|
+
isCacheInitialized,
|
|
16
|
+
TIMEFRAME_MS,
|
|
17
|
+
} from "../sessions-cache.js";
|
|
18
|
+
|
|
19
|
+
const router = express.Router();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* GET /api/sessions/list
|
|
23
|
+
*
|
|
24
|
+
* Query Parameters:
|
|
25
|
+
* - timeframe: '1h' | '8h' | '1d' | '1w' | '2w' | '1m' | 'all' (default: '1w')
|
|
26
|
+
*
|
|
27
|
+
* Headers:
|
|
28
|
+
* - If-None-Match: ETag from previous response (for 304 support)
|
|
29
|
+
*
|
|
30
|
+
* Response:
|
|
31
|
+
* - 304 Not Modified (if ETag matches)
|
|
32
|
+
* - 200 OK with sessions data
|
|
33
|
+
*/
|
|
34
|
+
router.get("/list", (req, res) => {
|
|
35
|
+
try {
|
|
36
|
+
// Check if cache is initialized
|
|
37
|
+
if (!isCacheInitialized()) {
|
|
38
|
+
return res.status(503).json({
|
|
39
|
+
error: "Sessions cache not yet initialized",
|
|
40
|
+
message: "Please wait for initial project scan to complete",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Get timeframe from query (validate against known values)
|
|
45
|
+
const timeframe =
|
|
46
|
+
TIMEFRAME_MS[req.query.timeframe] !== undefined
|
|
47
|
+
? req.query.timeframe
|
|
48
|
+
: "1w";
|
|
49
|
+
|
|
50
|
+
// Generate current ETag
|
|
51
|
+
const currentETag = generateETag(timeframe);
|
|
52
|
+
|
|
53
|
+
// Check If-None-Match header for conditional request
|
|
54
|
+
const clientETag = req.headers["if-none-match"];
|
|
55
|
+
if (clientETag && clientETag === currentETag) {
|
|
56
|
+
// Data hasn't changed - return 304
|
|
57
|
+
return res.status(304).end();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Get sessions filtered by timeframe
|
|
61
|
+
const { sessions, totalCount, filteredCount } =
|
|
62
|
+
getSessionsByTimeframe(timeframe);
|
|
63
|
+
const cacheMeta = getCacheMeta();
|
|
64
|
+
|
|
65
|
+
// Set caching headers
|
|
66
|
+
res.set({
|
|
67
|
+
"Cache-Control": "private, max-age=10",
|
|
68
|
+
ETag: currentETag,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Return sessions data
|
|
72
|
+
res.json({
|
|
73
|
+
sessions,
|
|
74
|
+
meta: {
|
|
75
|
+
totalCount,
|
|
76
|
+
filteredCount,
|
|
77
|
+
timeframe,
|
|
78
|
+
cacheTimestamp: cacheMeta.timestamp,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.error("[ERROR] Sessions list endpoint error:", error);
|
|
83
|
+
res.status(500).json({
|
|
84
|
+
error: "Failed to retrieve sessions",
|
|
85
|
+
message: error.message,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* GET /api/sessions/cache-status
|
|
92
|
+
* Returns current cache status (for debugging/monitoring)
|
|
93
|
+
*/
|
|
94
|
+
router.get("/cache-status", (req, res) => {
|
|
95
|
+
try {
|
|
96
|
+
const meta = getCacheMeta();
|
|
97
|
+
res.json({
|
|
98
|
+
initialized: isCacheInitialized(),
|
|
99
|
+
...meta,
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
res.status(500).json({ error: error.message });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export default router;
|