@siteboon/claude-code-ui 1.24.0 → 1.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,84 +1,124 @@
1
1
  import { spawn } from 'child_process';
2
2
  import crossSpawn from 'cross-spawn';
3
- import { promises as fs } from 'fs';
4
- import path from 'path';
5
- import os from 'os';
6
3
 
7
4
  // Use cross-spawn on Windows for better command execution
8
5
  const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
9
6
 
10
7
  let activeCursorProcesses = new Map(); // Track active processes by session ID
11
8
 
9
+ const WORKSPACE_TRUST_PATTERNS = [
10
+ /workspace trust required/i,
11
+ /do you trust the contents of this directory/i,
12
+ /working with untrusted contents/i,
13
+ /pass --trust,\s*--yolo,\s*or -f/i
14
+ ];
15
+
16
+ function isWorkspaceTrustPrompt(text = '') {
17
+ if (!text || typeof text !== 'string') {
18
+ return false;
19
+ }
20
+
21
+ return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text));
22
+ }
23
+
12
24
  async function spawnCursor(command, options = {}, ws) {
13
25
  return new Promise(async (resolve, reject) => {
14
- const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options;
26
+ const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
15
27
  let capturedSessionId = sessionId; // Track session ID throughout the process
16
28
  let sessionCreatedSent = false; // Track if we've already sent session-created event
17
- let messageBuffer = ''; // Buffer for accumulating assistant messages
18
-
29
+ let hasRetriedWithTrust = false;
30
+ let settled = false;
31
+
19
32
  // Use tools settings passed from frontend, or defaults
20
33
  const settings = toolsSettings || {
21
34
  allowedShellCommands: [],
22
35
  skipPermissions: false
23
36
  };
24
-
37
+
25
38
  // Build Cursor CLI command
26
- const args = [];
27
-
39
+ const baseArgs = [];
40
+
28
41
  // Build flags allowing both resume and prompt together (reply in existing session)
29
42
  // Treat presence of sessionId as intention to resume, regardless of resume flag
30
43
  if (sessionId) {
31
- args.push('--resume=' + sessionId);
44
+ baseArgs.push('--resume=' + sessionId);
32
45
  }
33
46
 
34
47
  if (command && command.trim()) {
35
48
  // Provide a prompt (works for both new and resumed sessions)
36
- args.push('-p', command);
49
+ baseArgs.push('-p', command);
37
50
 
38
51
  // Add model flag if specified (only meaningful for new sessions; harmless on resume)
39
52
  if (!sessionId && model) {
40
- args.push('--model', model);
53
+ baseArgs.push('--model', model);
41
54
  }
42
55
 
43
56
  // Request streaming JSON when we are providing a prompt
44
- args.push('--output-format', 'stream-json');
57
+ baseArgs.push('--output-format', 'stream-json');
45
58
  }
46
-
59
+
47
60
  // Add skip permissions flag if enabled
48
61
  if (skipPermissions || settings.skipPermissions) {
49
- args.push('-f');
50
- console.log('⚠️ Using -f flag (skip permissions)');
62
+ baseArgs.push('-f');
63
+ console.log('Using -f flag (skip permissions)');
51
64
  }
52
-
65
+
53
66
  // Use cwd (actual project directory) instead of projectPath
54
67
  const workingDir = cwd || projectPath || process.cwd();
55
-
56
- console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
57
- console.log('Working directory:', workingDir);
58
- console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
59
-
60
- const cursorProcess = spawnFunction('cursor-agent', args, {
61
- cwd: workingDir,
62
- stdio: ['pipe', 'pipe', 'pipe'],
63
- env: { ...process.env } // Inherit all environment variables
64
- });
65
-
68
+
66
69
  // Store process reference for potential abort
67
70
  const processKey = capturedSessionId || Date.now().toString();
68
- activeCursorProcesses.set(processKey, cursorProcess);
69
-
70
- // Handle stdout (streaming JSON responses)
71
- cursorProcess.stdout.on('data', (data) => {
72
- const rawOutput = data.toString();
73
- console.log('📤 Cursor CLI stdout:', rawOutput);
74
-
75
- const lines = rawOutput.split('\n').filter(line => line.trim());
76
-
77
- for (const line of lines) {
71
+
72
+ const settleOnce = (callback) => {
73
+ if (settled) {
74
+ return;
75
+ }
76
+ settled = true;
77
+ callback();
78
+ };
79
+
80
+ const runCursorProcess = (args, runReason = 'initial') => {
81
+ const isTrustRetry = runReason === 'trust-retry';
82
+ let runSawWorkspaceTrustPrompt = false;
83
+ let stdoutLineBuffer = '';
84
+
85
+ if (isTrustRetry) {
86
+ console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
87
+ }
88
+
89
+ console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
90
+ console.log('Working directory:', workingDir);
91
+ console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
92
+
93
+ const cursorProcess = spawnFunction('cursor-agent', args, {
94
+ cwd: workingDir,
95
+ stdio: ['pipe', 'pipe', 'pipe'],
96
+ env: { ...process.env } // Inherit all environment variables
97
+ });
98
+
99
+ activeCursorProcesses.set(processKey, cursorProcess);
100
+
101
+ const shouldSuppressForTrustRetry = (text) => {
102
+ if (hasRetriedWithTrust || args.includes('--trust')) {
103
+ return false;
104
+ }
105
+ if (!isWorkspaceTrustPrompt(text)) {
106
+ return false;
107
+ }
108
+
109
+ runSawWorkspaceTrustPrompt = true;
110
+ return true;
111
+ };
112
+
113
+ const processCursorOutputLine = (line) => {
114
+ if (!line || !line.trim()) {
115
+ return;
116
+ }
117
+
78
118
  try {
79
119
  const response = JSON.parse(line);
80
- console.log('📄 Parsed JSON response:', response);
81
-
120
+ console.log('Parsed JSON response:', response);
121
+
82
122
  // Handle different message types
83
123
  switch (response.type) {
84
124
  case 'system':
@@ -86,14 +126,14 @@ async function spawnCursor(command, options = {}, ws) {
86
126
  // Capture session ID
87
127
  if (response.session_id && !capturedSessionId) {
88
128
  capturedSessionId = response.session_id;
89
- console.log('📝 Captured session ID:', capturedSessionId);
90
-
129
+ console.log('Captured session ID:', capturedSessionId);
130
+
91
131
  // Update process key with captured session ID
92
132
  if (processKey !== capturedSessionId) {
93
133
  activeCursorProcesses.delete(processKey);
94
134
  activeCursorProcesses.set(capturedSessionId, cursorProcess);
95
135
  }
96
-
136
+
97
137
  // Set session ID on writer (for API endpoint compatibility)
98
138
  if (ws.setSessionId && typeof ws.setSessionId === 'function') {
99
139
  ws.setSessionId(capturedSessionId);
@@ -110,7 +150,7 @@ async function spawnCursor(command, options = {}, ws) {
110
150
  });
111
151
  }
112
152
  }
113
-
153
+
114
154
  // Send system info to frontend
115
155
  ws.send({
116
156
  type: 'cursor-system',
@@ -119,7 +159,7 @@ async function spawnCursor(command, options = {}, ws) {
119
159
  });
120
160
  }
121
161
  break;
122
-
162
+
123
163
  case 'user':
124
164
  // Forward user message
125
165
  ws.send({
@@ -128,13 +168,12 @@ async function spawnCursor(command, options = {}, ws) {
128
168
  sessionId: capturedSessionId || sessionId || null
129
169
  });
130
170
  break;
131
-
171
+
132
172
  case 'assistant':
133
173
  // Accumulate assistant message chunks
134
174
  if (response.message && response.message.content && response.message.content.length > 0) {
135
175
  const textContent = response.message.content[0].text;
136
- messageBuffer += textContent;
137
-
176
+
138
177
  // Send as Claude-compatible format for frontend
139
178
  ws.send({
140
179
  type: 'claude-response',
@@ -149,23 +188,14 @@ async function spawnCursor(command, options = {}, ws) {
149
188
  });
150
189
  }
151
190
  break;
152
-
191
+
153
192
  case 'result':
154
193
  // Session complete
155
194
  console.log('Cursor session result:', response);
156
-
157
- // Send final message if we have buffered content
158
- if (messageBuffer) {
159
- ws.send({
160
- type: 'claude-response',
161
- data: {
162
- type: 'content_block_stop'
163
- },
164
- sessionId: capturedSessionId || sessionId || null
165
- });
166
- }
167
-
168
- // Send completion event
195
+
196
+ // Do not emit an extra content_block_stop here.
197
+ // The UI already finalizes the streaming message in cursor-result handling,
198
+ // and emitting both can produce duplicate assistant messages.
169
199
  ws.send({
170
200
  type: 'cursor-result',
171
201
  sessionId: capturedSessionId || sessionId,
@@ -173,7 +203,7 @@ async function spawnCursor(command, options = {}, ws) {
173
203
  success: response.subtype === 'success'
174
204
  });
175
205
  break;
176
-
206
+
177
207
  default:
178
208
  // Forward any other message types
179
209
  ws.send({
@@ -183,7 +213,12 @@ async function spawnCursor(command, options = {}, ws) {
183
213
  });
184
214
  }
185
215
  } catch (parseError) {
186
- console.log('📄 Non-JSON response:', line);
216
+ console.log('Non-JSON response:', line);
217
+
218
+ if (shouldSuppressForTrustRetry(line)) {
219
+ return;
220
+ }
221
+
187
222
  // If not JSON, send as raw text
188
223
  ws.send({
189
224
  type: 'cursor-output',
@@ -191,67 +226,106 @@ async function spawnCursor(command, options = {}, ws) {
191
226
  sessionId: capturedSessionId || sessionId || null
192
227
  });
193
228
  }
194
- }
195
- });
196
-
197
- // Handle stderr
198
- cursorProcess.stderr.on('data', (data) => {
199
- console.error('Cursor CLI stderr:', data.toString());
200
- ws.send({
201
- type: 'cursor-error',
202
- error: data.toString(),
203
- sessionId: capturedSessionId || sessionId || null
229
+ };
230
+
231
+ // Handle stdout (streaming JSON responses)
232
+ cursorProcess.stdout.on('data', (data) => {
233
+ const rawOutput = data.toString();
234
+ console.log('Cursor CLI stdout:', rawOutput);
235
+
236
+ // Stream chunks can split JSON objects across packets; keep trailing partial line.
237
+ stdoutLineBuffer += rawOutput;
238
+ const completeLines = stdoutLineBuffer.split(/\r?\n/);
239
+ stdoutLineBuffer = completeLines.pop() || '';
240
+
241
+ completeLines.forEach((line) => {
242
+ processCursorOutputLine(line.trim());
243
+ });
244
+ });
245
+
246
+ // Handle stderr
247
+ cursorProcess.stderr.on('data', (data) => {
248
+ const stderrText = data.toString();
249
+ console.error('Cursor CLI stderr:', stderrText);
250
+
251
+ if (shouldSuppressForTrustRetry(stderrText)) {
252
+ return;
253
+ }
254
+
255
+ ws.send({
256
+ type: 'cursor-error',
257
+ error: stderrText,
258
+ sessionId: capturedSessionId || sessionId || null
259
+ });
204
260
  });
205
- });
206
-
207
- // Handle process completion
208
- cursorProcess.on('close', async (code) => {
209
- console.log(`Cursor CLI process exited with code ${code}`);
210
-
211
- // Clean up process reference
212
- const finalSessionId = capturedSessionId || sessionId || processKey;
213
- activeCursorProcesses.delete(finalSessionId);
214
-
215
- ws.send({
216
- type: 'claude-complete',
217
- sessionId: finalSessionId,
218
- exitCode: code,
219
- isNewSession: !sessionId && !!command // Flag to indicate this was a new session
261
+
262
+ // Handle process completion
263
+ cursorProcess.on('close', async (code) => {
264
+ console.log(`Cursor CLI process exited with code ${code}`);
265
+
266
+ const finalSessionId = capturedSessionId || sessionId || processKey;
267
+ activeCursorProcesses.delete(finalSessionId);
268
+
269
+ // Flush any final unterminated stdout line before completion handling.
270
+ if (stdoutLineBuffer.trim()) {
271
+ processCursorOutputLine(stdoutLineBuffer.trim());
272
+ stdoutLineBuffer = '';
273
+ }
274
+
275
+ if (
276
+ runSawWorkspaceTrustPrompt &&
277
+ code !== 0 &&
278
+ !hasRetriedWithTrust &&
279
+ !args.includes('--trust')
280
+ ) {
281
+ hasRetriedWithTrust = true;
282
+ runCursorProcess([...args, '--trust'], 'trust-retry');
283
+ return;
284
+ }
285
+
286
+ ws.send({
287
+ type: 'claude-complete',
288
+ sessionId: finalSessionId,
289
+ exitCode: code,
290
+ isNewSession: !sessionId && !!command // Flag to indicate this was a new session
291
+ });
292
+
293
+ if (code === 0) {
294
+ settleOnce(() => resolve());
295
+ } else {
296
+ settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
297
+ }
220
298
  });
221
-
222
- if (code === 0) {
223
- resolve();
224
- } else {
225
- reject(new Error(`Cursor CLI exited with code ${code}`));
226
- }
227
- });
228
-
229
- // Handle process errors
230
- cursorProcess.on('error', (error) => {
231
- console.error('Cursor CLI process error:', error);
232
-
233
- // Clean up process reference on error
234
- const finalSessionId = capturedSessionId || sessionId || processKey;
235
- activeCursorProcesses.delete(finalSessionId);
236
-
237
- ws.send({
238
- type: 'cursor-error',
239
- error: error.message,
240
- sessionId: capturedSessionId || sessionId || null
299
+
300
+ // Handle process errors
301
+ cursorProcess.on('error', (error) => {
302
+ console.error('Cursor CLI process error:', error);
303
+
304
+ // Clean up process reference on error
305
+ const finalSessionId = capturedSessionId || sessionId || processKey;
306
+ activeCursorProcesses.delete(finalSessionId);
307
+
308
+ ws.send({
309
+ type: 'cursor-error',
310
+ error: error.message,
311
+ sessionId: capturedSessionId || sessionId || null
312
+ });
313
+
314
+ settleOnce(() => reject(error));
241
315
  });
242
316
 
243
- reject(error);
244
- });
245
-
246
- // Close stdin since Cursor doesn't need interactive input
247
- cursorProcess.stdin.end();
317
+ // Close stdin since Cursor doesn't need interactive input
318
+ cursorProcess.stdin.end();
319
+ };
320
+
321
+ runCursorProcess(baseArgs, 'initial');
248
322
  });
249
323
  }
250
324
 
251
325
  function abortCursorSession(sessionId) {
252
326
  const process = activeCursorProcesses.get(sessionId);
253
327
  if (process) {
254
- console.log(`🛑 Aborting Cursor session: ${sessionId}`);
328
+ console.log(`Aborting Cursor session: ${sessionId}`);
255
329
  process.kill('SIGTERM');
256
330
  activeCursorProcesses.delete(sessionId);
257
331
  return true;
@@ -59,6 +59,15 @@ if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGAC
59
59
  // Create database connection
60
60
  const db = new Database(DB_PATH);
61
61
 
62
+ // app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
63
+ // runMigrations() also creates this table, but it runs too late for existing installations
64
+ // where auth.js is imported before initializeDatabase() is called.
65
+ db.exec(`CREATE TABLE IF NOT EXISTS app_config (
66
+ key TEXT PRIMARY KEY,
67
+ value TEXT NOT NULL,
68
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
69
+ )`);
70
+
62
71
  // Show app installation path prominently
63
72
  const appInstallPath = path.join(__dirname, '../..');
64
73
  console.log('');
@@ -91,6 +100,13 @@ const runMigrations = () => {
91
100
  db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
92
101
  }
93
102
 
103
+ // Create app_config table if it doesn't exist (for existing installations)
104
+ db.exec(`CREATE TABLE IF NOT EXISTS app_config (
105
+ key TEXT PRIMARY KEY,
106
+ value TEXT NOT NULL,
107
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
108
+ )`);
109
+
94
110
  // Create session_names table if it doesn't exist (for existing installations)
95
111
  db.exec(`CREATE TABLE IF NOT EXISTS session_names (
96
112
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -414,6 +430,33 @@ function applyCustomSessionNames(sessions, provider) {
414
430
  }
415
431
  }
416
432
 
433
+ // App config database operations
434
+ const appConfigDb = {
435
+ get: (key) => {
436
+ try {
437
+ const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
438
+ return row?.value || null;
439
+ } catch (err) {
440
+ return null;
441
+ }
442
+ },
443
+
444
+ set: (key, value) => {
445
+ db.prepare(
446
+ 'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
447
+ ).run(key, value);
448
+ },
449
+
450
+ getOrCreateJwtSecret: () => {
451
+ let secret = appConfigDb.get('jwt_secret');
452
+ if (!secret) {
453
+ secret = crypto.randomBytes(64).toString('hex');
454
+ appConfigDb.set('jwt_secret', secret);
455
+ }
456
+ return secret;
457
+ }
458
+ };
459
+
417
460
  // Backward compatibility - keep old names pointing to new system
418
461
  const githubTokensDb = {
419
462
  createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -441,5 +484,6 @@ export {
441
484
  credentialsDb,
442
485
  sessionNamesDb,
443
486
  applyCustomSessionNames,
487
+ appConfigDb,
444
488
  githubTokensDb // Backward compatibility
445
489
  };
@@ -62,4 +62,11 @@ CREATE TABLE IF NOT EXISTS session_names (
62
62
  UNIQUE(session_id, provider)
63
63
  );
64
64
 
65
- CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
65
+ CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
66
+
67
+ -- App configuration table (auto-generated secrets, settings, etc.)
68
+ CREATE TABLE IF NOT EXISTS app_config (
69
+ key TEXT PRIMARY KEY,
70
+ value TEXT NOT NULL,
71
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
72
+ );