@epiphytic/claudecodeui 1.0.0 → 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.
@@ -1,33 +1,157 @@
1
- import express from 'express';
2
- import { promises as fs } from 'fs';
3
- import path from 'path';
4
- import { spawn } from 'child_process';
5
- import os from 'os';
6
- import { addProjectManually } from '../projects.js';
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
- '/etc',
17
- '/bin',
18
- '/sbin',
19
- '/usr',
20
- '/dev',
21
- '/proc',
22
- '/sys',
23
- '/var',
24
- '/boot',
25
- '/root',
26
- '/lib',
27
- '/lib64',
28
- '/opt',
29
- '/tmp',
30
- '/run'
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: 'Cannot use system-critical directories as workspace locations'
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 (normalizedPath === forbidden ||
55
- normalizedPath.startsWith(forbidden + path.sep)) {
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 (forbidden === '/var' &&
59
- (normalizedPath.startsWith('/var/tmp') ||
60
- normalizedPath.startsWith('/var/folders'))) {
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 === 'ENOENT') {
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 === 'ENOENT') {
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 (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) &&
105
- realPath !== resolvedWorkspaceRoot) {
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(path.dirname(absolutePath), linkTarget);
250
+ const resolvedTarget = path.resolve(
251
+ path.dirname(absolutePath),
252
+ linkTarget,
253
+ );
121
254
  const realTarget = await fs.realpath(resolvedTarget);
122
255
 
123
- if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&
124
- realTarget !== resolvedWorkspaceRoot) {
256
+ if (
257
+ !realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&
258
+ realTarget !== resolvedWorkspaceRoot
259
+ ) {
125
260
  return {
126
261
  valid: false,
127
- error: 'Symlink target is outside the allowed workspace root'
262
+ error: "Symlink target is outside the allowed workspace root",
128
263
  };
129
264
  }
130
265
  }
131
266
  } catch (error) {
132
- if (error.code !== 'ENOENT') {
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('/create-workspace', async (req, res) => {
296
+ router.post("/create-workspace", async (req, res) => {
163
297
  try {
164
- const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
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.status(400).json({ error: 'workspaceType and path are required' });
308
+ return res
309
+ .status(400)
310
+ .json({ error: "workspaceType and path are required" });
169
311
  }
170
312
 
171
- if (!['existing', 'new'].includes(workspaceType)) {
172
- return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
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: 'Invalid workspace path',
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 === 'existing') {
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.status(400).json({ error: 'Path exists but is not a directory' });
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 === 'ENOENT') {
198
- return res.status(404).json({ error: 'Workspace path does not exist' });
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: 'Existing workspace added successfully'
357
+ message: "Existing workspace added successfully",
210
358
  });
211
359
  }
212
360
 
213
361
  // Handle new workspace creation
214
- if (workspaceType === 'new') {
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: 'Path already exists. Please choose a different path or use "existing workspace" option.'
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 !== 'ENOENT') {
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: 'GitHub token not found' });
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('Failed to clean up directory after clone failure:', cleanupError);
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
- ? 'New workspace created and repository cloned successfully'
272
- : 'New workspace created successfully'
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('Error creating workspace:', error);
428
+ console.error("Error creating workspace:", error);
278
429
  res.status(500).json({
279
- error: error.message || 'Failed to create workspace',
280
- details: process.env.NODE_ENV === 'development' ? error.stack : undefined
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('../database/db.js');
440
+ const { getDatabase } = await import("../database/db.js");
290
441
  const db = await getDatabase();
291
442
 
292
443
  const credential = await db.get(
293
- 'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
294
- [tokenId, userId, 'github_token']
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('Invalid GitHub URL format'));
475
+ return reject(new Error("Invalid GitHub URL format"));
325
476
  }
326
477
  }
327
478
 
328
- const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
329
- stdio: ['ignore', 'pipe', 'pipe'],
479
+ const gitProcess = spawn("git", ["clone", cloneUrl, destinationPath], {
480
+ stdio: ["ignore", "pipe", "pipe"],
330
481
  env: {
331
482
  ...process.env,
332
- GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
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('data', (data) => {
490
+ gitProcess.stdout.on("data", (data) => {
340
491
  stdout += data.toString();
341
492
  });
342
493
 
343
- gitProcess.stderr.on('data', (data) => {
494
+ gitProcess.stderr.on("data", (data) => {
344
495
  stderr += data.toString();
345
496
  });
346
497
 
347
- gitProcess.on('close', (code) => {
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 = 'Git clone failed';
353
-
354
- if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
355
- errorMessage = 'Authentication failed. Please check your GitHub token.';
356
- } else if (stderr.includes('Repository not found')) {
357
- errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
358
- } else if (stderr.includes('already exists')) {
359
- errorMessage = 'Directory already exists';
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('error', (error) => {
369
- if (error.code === 'ENOENT') {
370
- reject(new Error('Git is not installed or not in PATH'));
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;