vg-coder-cli 2.0.25 → 2.0.26

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.
@@ -0,0 +1,353 @@
1
+ const path = require('path');
2
+ const fs = require('fs-extra');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+ const chalk = require('chalk');
6
+ const http = require('http');
7
+
8
+ /**
9
+ * ProjectManager - Singleton for managing multiple projects in one server
10
+ */
11
+ class ProjectManager {
12
+ constructor() {
13
+ if (ProjectManager.instance) {
14
+ return ProjectManager.instance;
15
+ }
16
+
17
+ this.projects = new Map(); // projectId -> ProjectContext
18
+ this.currentProjectId = null;
19
+ this.lockFilePath = path.join(os.homedir(), '.vg', 'vg-leader.lock');
20
+
21
+ ProjectManager.instance = this;
22
+ }
23
+
24
+ /**
25
+ * Register a new project
26
+ * @param {string} workingDir - Absolute path to project directory
27
+ * @returns {string} projectId
28
+ */
29
+ registerProject(workingDir) {
30
+ const resolvedPath = path.resolve(workingDir);
31
+ const projectId = this.generateProjectId(resolvedPath);
32
+
33
+ // Check if already registered
34
+ if (this.projects.has(projectId)) {
35
+ console.log(chalk.yellow(`⚠️ Project already registered: ${path.basename(resolvedPath)}`));
36
+ return projectId;
37
+ }
38
+
39
+ const projectContext = {
40
+ id: projectId,
41
+ name: path.basename(resolvedPath),
42
+ workingDir: resolvedPath,
43
+ active: this.projects.size === 0, // First project is active by default
44
+ createdAt: Date.now(),
45
+ lastActive: Date.now()
46
+ };
47
+
48
+ this.projects.set(projectId, projectContext);
49
+
50
+ // Set as current if first project
51
+ if (this.projects.size === 1) {
52
+ this.currentProjectId = projectId;
53
+ }
54
+
55
+ console.log(chalk.green(`✓ Registered project: ${chalk.cyan(projectContext.name)} (${this.projects.size} total)`));
56
+
57
+ return projectId;
58
+ }
59
+
60
+ /**
61
+ * Switch to a different project
62
+ * @param {string} projectId
63
+ * @returns {boolean} success
64
+ */
65
+ switchProject(projectId) {
66
+ if (!this.projects.has(projectId)) {
67
+ console.error(chalk.red(`❌ Project not found: ${projectId}`));
68
+ return false;
69
+ }
70
+
71
+ // Deactivate current project
72
+ if (this.currentProjectId) {
73
+ const current = this.projects.get(this.currentProjectId);
74
+ if (current) {
75
+ current.active = false;
76
+ }
77
+ }
78
+
79
+ // Activate new project
80
+ const newProject = this.projects.get(projectId);
81
+ newProject.active = true;
82
+ newProject.lastActive = Date.now();
83
+ this.currentProjectId = projectId;
84
+
85
+ console.log(chalk.blue(`Switched to project: ${chalk.cyan(newProject.name)}`));
86
+
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Get currently active project
92
+ * @returns {ProjectContext|null}
93
+ */
94
+ getActiveProject() {
95
+ if (!this.currentProjectId) {
96
+ return null;
97
+ }
98
+ return this.projects.get(this.currentProjectId);
99
+ }
100
+
101
+ /**
102
+ * Get all projects
103
+ * @returns {Array<ProjectContext>}
104
+ */
105
+ getAllProjects() {
106
+ return Array.from(this.projects.values())
107
+ .sort((a, b) => b.lastActive - a.lastActive); // Sort by last active
108
+ }
109
+
110
+ /**
111
+ * Remove a project
112
+ * @param {string} projectId
113
+ */
114
+ removeProject(projectId) {
115
+ const project = this.projects.get(projectId);
116
+ if (!project) return;
117
+
118
+ this.projects.delete(projectId);
119
+ console.log(chalk.yellow(`Removed project: ${project.name}`));
120
+
121
+ // If removed project was active, switch to another
122
+ if (projectId === this.currentProjectId) {
123
+ const remaining = Array.from(this.projects.keys());
124
+ if (remaining.length > 0) {
125
+ this.switchProject(remaining[0]);
126
+ } else {
127
+ this.currentProjectId = null;
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Generate unique project ID from path
134
+ * @param {string} projectPath
135
+ * @returns {string}
136
+ */
137
+ generateProjectId(projectPath) {
138
+ return crypto
139
+ .createHash('md5')
140
+ .update(projectPath)
141
+ .digest('hex')
142
+ .substring(0, 8);
143
+ }
144
+
145
+ /**
146
+ * Acquire leader lock
147
+ * @param {number} port
148
+ * @param {number} pid
149
+ * @returns {Promise<boolean>}
150
+ */
151
+ async acquireLock(port, pid = process.pid) {
152
+ try {
153
+ // Ensure .vg directory exists
154
+ await fs.ensureDir(path.dirname(this.lockFilePath));
155
+
156
+ // Check if lock already exists
157
+ if (await fs.pathExists(this.lockFilePath)) {
158
+ const existingLock = await fs.readJson(this.lockFilePath);
159
+
160
+ // Check if existing leader is still alive
161
+ const isAlive = await this.checkLeaderHealth(existingLock.port);
162
+ if (isAlive) {
163
+ console.log(chalk.yellow('⚠️ Leader already exists'));
164
+ return false;
165
+ } else {
166
+ console.log(chalk.yellow('⚠️ Stale lock file detected, cleaning up...'));
167
+ await fs.remove(this.lockFilePath);
168
+ }
169
+ }
170
+
171
+ // Write lock file
172
+ const lockData = {
173
+ port,
174
+ pid,
175
+ startTime: Date.now(),
176
+ hostname: os.hostname()
177
+ };
178
+
179
+ await fs.writeJson(this.lockFilePath, lockData, { spaces: 2 });
180
+ console.log(chalk.green(`✓ Acquired leader lock (PID: ${pid}, Port: ${port})`));
181
+
182
+ return true;
183
+ } catch (error) {
184
+ console.error(chalk.red('❌ Failed to acquire lock:'), error.message);
185
+ return false;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Release leader lock
191
+ * @returns {Promise<void>}
192
+ */
193
+ async releaseLock() {
194
+ try {
195
+ if (await fs.pathExists(this.lockFilePath)) {
196
+ await fs.remove(this.lockFilePath);
197
+ console.log(chalk.green('✓ Released leader lock'));
198
+ }
199
+ } catch (error) {
200
+ console.error(chalk.red('❌ Failed to release lock:'), error.message);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Check if leader exists and is healthy
206
+ * @returns {Promise<Object|null>} Leader info or null
207
+ */
208
+ async checkLeader() {
209
+ try {
210
+ if (!await fs.pathExists(this.lockFilePath)) {
211
+ return null;
212
+ }
213
+
214
+ const lockData = await fs.readJson(this.lockFilePath);
215
+
216
+ // Check if leader is healthy
217
+ const isHealthy = await this.checkLeaderHealth(lockData.port);
218
+
219
+ if (isHealthy) {
220
+ return lockData;
221
+ } else {
222
+ // Stale lock file
223
+ console.log(chalk.yellow('⚠️ Leader not responding, cleaning up stale lock...'));
224
+ await fs.remove(this.lockFilePath);
225
+ return null;
226
+ }
227
+ } catch (error) {
228
+ console.error(chalk.red('❌ Error checking leader:'), error.message);
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Check if leader server is healthy
235
+ * @param {number} port
236
+ * @returns {Promise<boolean>}
237
+ */
238
+ async checkLeaderHealth(port) {
239
+ return new Promise((resolve) => {
240
+ const req = http.get(`http://localhost:${port}/health`, (res) => {
241
+ resolve(res.statusCode === 200);
242
+ });
243
+
244
+ req.on('error', () => {
245
+ resolve(false);
246
+ });
247
+
248
+ req.setTimeout(2000, () => {
249
+ req.destroy();
250
+ resolve(false);
251
+ });
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Join existing leader as follower
257
+ * @param {Object} leaderInfo
258
+ * @param {string} projectPath
259
+ * @returns {Promise<boolean>}
260
+ */
261
+ async joinLeader(leaderInfo, projectPath) {
262
+ try {
263
+ const { port } = leaderInfo;
264
+ const resolvedPath = path.resolve(projectPath);
265
+ const projectName = path.basename(resolvedPath);
266
+
267
+ console.log(chalk.blue(`\n📡 Joining leader at port ${port}...`));
268
+
269
+ // Send registration request to leader
270
+ const response = await this.sendHttpRequest(port, '/api/projects/register', {
271
+ method: 'POST',
272
+ body: JSON.stringify({
273
+ workingDir: resolvedPath,
274
+ name: projectName
275
+ })
276
+ });
277
+
278
+ if (response.success) {
279
+ console.log(chalk.green('\n──────────────────────────────────────────────────'));
280
+ console.log(`🔗 ${chalk.bold('Project Registered')} ${chalk.green('● Joined')}`);
281
+ console.log(chalk.gray('──────────────────────────────────────────────────'));
282
+ console.log(`📁 Project: ${chalk.cyan(projectName)}`);
283
+ console.log(`🌐 Dashboard: ${chalk.blue(`http://localhost:${port}`)}`);
284
+ console.log(`📊 Total: ${chalk.yellow(response.totalProjects)} project(s)`);
285
+ console.log(chalk.green('──────────────────────────────────────────────────\n'));
286
+ console.log(chalk.blue('💡 Open the dashboard to switch between projects.'));
287
+ return true;
288
+ } else {
289
+ throw new Error(response.error || 'Failed to register project');
290
+ }
291
+ } catch (error) {
292
+ console.error(chalk.red('❌ Failed to join leader:'), error.message);
293
+ return false;
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Send HTTP request to server
299
+ * @param {number} port
300
+ * @param {string} path
301
+ * @param {Object} options
302
+ * @returns {Promise<Object>}
303
+ */
304
+ sendHttpRequest(port, path, options = {}) {
305
+ return new Promise((resolve, reject) => {
306
+ const postData = options.body || '';
307
+
308
+ const req = http.request({
309
+ hostname: 'localhost',
310
+ port: port,
311
+ path: path,
312
+ method: options.method || 'GET',
313
+ headers: {
314
+ 'Content-Type': 'application/json',
315
+ 'Content-Length': Buffer.byteLength(postData)
316
+ }
317
+ }, (res) => {
318
+ let data = '';
319
+
320
+ res.on('data', (chunk) => {
321
+ data += chunk;
322
+ });
323
+
324
+ res.on('end', () => {
325
+ try {
326
+ const parsed = JSON.parse(data);
327
+ resolve(parsed);
328
+ } catch (error) {
329
+ reject(new Error('Invalid JSON response'));
330
+ }
331
+ });
332
+ });
333
+
334
+ req.on('error', (error) => {
335
+ reject(error);
336
+ });
337
+
338
+ req.setTimeout(5000, () => {
339
+ req.destroy();
340
+ reject(new Error('Request timeout'));
341
+ });
342
+
343
+ if (postData) {
344
+ req.write(postData);
345
+ }
346
+
347
+ req.end();
348
+ });
349
+ }
350
+ }
351
+
352
+ // Export singleton instance
353
+ module.exports = new ProjectManager();
@@ -5,7 +5,7 @@ const { stripAnsiCodes, classifyLogLine, extractErrors } = require(path.join(__d
5
5
 
6
6
  class TerminalManager {
7
7
  constructor() {
8
- // Map: termId -> { process: pty, socketId: string }
8
+ // Map: termId -> { process: pty, socketId: string, projectId: string }
9
9
  this.sessions = new Map();
10
10
  // Map: termId -> Array of log lines (circular buffer, max 10000)
11
11
  this.logBuffers = new Map();
@@ -22,7 +22,7 @@ class TerminalManager {
22
22
  }
23
23
  }
24
24
 
25
- createTerminal(socket, termId, cols = 80, rows = 24, cwd) {
25
+ createTerminal(socket, termId, cols = 80, rows = 24, cwd, projectId = null) {
26
26
  try {
27
27
  const term = pty.spawn(this.shell, [], {
28
28
  name: 'xterm-256color',
@@ -34,7 +34,8 @@ class TerminalManager {
34
34
 
35
35
  this.sessions.set(termId, {
36
36
  process: term,
37
- socketId: socket.id
37
+ socketId: socket.id,
38
+ projectId: projectId // Store project association
38
39
  });
39
40
 
40
41
  // Initialize log buffer for this terminal
@@ -185,6 +186,33 @@ class TerminalManager {
185
186
  }))
186
187
  };
187
188
  }
189
+
190
+ /**
191
+ * Get all terminal IDs for a specific project
192
+ * @param {string} projectId - Project ID
193
+ * @returns {string[]} Array of terminal IDs
194
+ */
195
+ getProjectTerminals(projectId) {
196
+ const terminals = [];
197
+ for (const [termId, session] of this.sessions.entries()) {
198
+ if (session.projectId === projectId) {
199
+ terminals.push(termId);
200
+ }
201
+ }
202
+ return terminals;
203
+ }
204
+
205
+ /**
206
+ * Clean up all terminals for a project
207
+ * @param {string} projectId - Project ID
208
+ */
209
+ cleanupProject(projectId) {
210
+ const terminalsToRemove = this.getProjectTerminals(projectId);
211
+ terminalsToRemove.forEach(termId => {
212
+ this.kill(termId);
213
+ });
214
+ console.log(`Cleaned up ${terminalsToRemove.length} terminal(s) for project ${projectId}`);
215
+ }
188
216
  }
189
217
 
190
218
  module.exports = new TerminalManager();
@@ -251,6 +251,7 @@
251
251
  .right-panel {
252
252
  flex: 1;
253
253
  height: 50vh;
254
+ display: none;
254
255
  }
255
256
 
256
257
  .extension-guide-center {
@@ -94,44 +94,84 @@ body.resizing {
94
94
  padding-right: 0px;
95
95
  }
96
96
 
97
- /* --- COMPONENT STYLES --- */
97
+ /* --- HEADER REDESIGN --- */
98
98
  .header {
99
99
  display: flex;
100
- justify-content: space-between;
101
- align-items: center; /* Align items vertically center */
100
+ flex-direction: column;
101
+ gap: 6px; /* Space between rows */
102
102
  margin-bottom: 15px;
103
103
  }
104
104
 
105
- .header-content {
105
+ .header-top-row {
106
+ display: flex;
107
+ justify-content: space-between;
108
+ align-items: center;
109
+ width: 100%;
110
+ }
111
+
112
+ .header-left-group {
106
113
  display: flex;
107
114
  align-items: center;
108
115
  gap: 12px;
116
+ flex: 1; /* Take available space */
117
+ min-width: 0; /* Enable truncation */
109
118
  }
110
119
 
111
120
  .status {
112
121
  font-size: 14px;
113
122
  color: var(--ios-green);
114
123
  line-height: 1;
124
+ flex-shrink: 0;
115
125
  }
116
126
 
117
- /* NEW: Project Info Styles */
118
- .project-info {
127
+ /* Project Switcher replaces Project Title */
128
+ .project-switcher {
119
129
  display: flex;
120
- flex-direction: column;
121
- justify-content: center;
122
- line-height: 1.2;
130
+ align-items: center;
131
+ gap: 8px;
132
+ flex: 1;
133
+ min-width: 0;
123
134
  }
124
135
 
125
- .project-name {
126
- font-weight: 700;
127
- font-size: 16px;
136
+ .project-selector {
137
+ padding: 5px 10px;
138
+ background: var(--ios-input-bg);
139
+ border: 1px solid var(--ios-separator);
140
+ border-radius: 6px;
141
+ font-size: 14px; /* Slightly bigger font like a title */
142
+ font-weight: 700; /* Bold like a title */
128
143
  color: var(--text-primary);
144
+ cursor: pointer;
145
+ outline: none;
146
+ transition: all 0.2s;
147
+ max-width: 100%;
148
+ text-overflow: ellipsis;
129
149
  }
130
150
 
131
- .project-meta {
132
- font-size: 11px;
151
+ .project-selector:hover {
152
+ border-color: var(--ios-blue);
153
+ }
154
+
155
+ .project-selector:focus {
156
+ border-color: var(--ios-blue);
157
+ box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.1);
158
+ }
159
+
160
+ .project-count {
161
+ font-size: 10px;
133
162
  color: var(--text-secondary);
134
- font-family: monospace;
163
+ background: var(--ios-gray-light);
164
+ padding: 3px 7px;
165
+ border-radius: 10px;
166
+ font-weight: 600;
167
+ white-space: nowrap;
168
+ }
169
+
170
+ .header-actions {
171
+ display: flex;
172
+ align-items: center;
173
+ gap: 8px;
174
+ flex-shrink: 0;
135
175
  }
136
176
 
137
177
  .theme-toggle {
@@ -144,6 +184,45 @@ body.resizing {
144
184
  box-shadow: 0 2px 5px var(--shadow-color);
145
185
  }
146
186
 
187
+ .stop-server-btn {
188
+ width: 28px;
189
+ height: 28px;
190
+ border-radius: 50%;
191
+ border: none;
192
+ background: var(--ios-red);
193
+ color: white;
194
+ cursor: pointer;
195
+ box-shadow: 0 2px 5px var(--shadow-color);
196
+ display: flex;
197
+ align-items: center;
198
+ justify-content: center;
199
+ font-size: 14px;
200
+ transition: all 0.2s;
201
+ }
202
+
203
+ .stop-server-btn:hover {
204
+ opacity: 0.8;
205
+ transform: scale(1.05);
206
+ }
207
+
208
+ /* Bottom Row - Full Width Metadata */
209
+ .header-bottom-row {
210
+ width: 100%;
211
+ padding-left: 2px;
212
+ }
213
+
214
+ .project-meta {
215
+ font-size: 11px;
216
+ color: var(--text-secondary);
217
+ font-family: monospace;
218
+ white-space: nowrap;
219
+ overflow: hidden;
220
+ text-overflow: ellipsis;
221
+ display: block;
222
+ width: 100%;
223
+ line-height: 1.4;
224
+ }
225
+
147
226
  .system-prompt-card,
148
227
  .endpoint-card {
149
228
  background: var(--ios-card);
@@ -343,7 +422,7 @@ body.resizing {
343
422
  flex: none;
344
423
  width: 100%;
345
424
  max-width: 100%;
346
- height: 50vh;
425
+ height: 100vh;
347
426
  border-right: none;
348
427
  border-bottom: 1px solid var(--ios-separator);
349
428
  }
@@ -352,6 +431,7 @@ body.resizing {
352
431
  /* --- SAVED COMMANDS STYLES --- */
353
432
  .saved-commands-panel {
354
433
  margin-bottom: 12px;
434
+ background: var(--ios-card);
355
435
  }
356
436
 
357
437
  .saved-commands-header {
@@ -423,71 +503,97 @@ body.resizing {
423
503
  margin-top: 0;
424
504
  }
425
505
 
506
+ /* NEW: Grid Layout for Commands */
426
507
  .commands-list {
427
- display: flex;
428
- flex-direction: column;
508
+ display: grid;
509
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
429
510
  gap: 8px;
511
+ max-height: 300px;
512
+ overflow-y: auto;
513
+ padding-right: 2px; /* For scrollbar */
514
+ }
515
+
516
+ /* Custom Scrollbar for list */
517
+ .commands-list::-webkit-scrollbar {
518
+ width: 4px;
519
+ }
520
+ .commands-list::-webkit-scrollbar-thumb {
521
+ background: var(--ios-separator);
522
+ border-radius: 2px;
430
523
  }
431
524
 
432
525
  .command-card {
433
526
  display: flex;
434
527
  justify-content: space-between;
435
528
  align-items: center;
436
- padding: 8px 10px;
529
+ padding: 6px 8px; /* Tighter padding */
437
530
  background: var(--ios-input-bg);
438
531
  border-radius: 6px;
439
532
  cursor: pointer;
440
533
  transition: all 0.2s;
441
534
  border: 1px solid transparent;
535
+ height: 42px; /* Fixed height for consistency */
536
+ overflow: hidden;
442
537
  }
443
538
 
444
539
  .command-card:hover {
445
540
  background: var(--ios-gray-light);
446
541
  border-color: var(--ios-blue);
447
- transform: translateX(2px);
542
+ transform: translateY(-1px);
448
543
  }
449
544
 
450
545
  .command-card-main {
451
546
  display: flex;
452
547
  align-items: center;
453
- gap: 10px;
548
+ gap: 8px;
454
549
  flex: 1;
455
- min-width: 0;
550
+ min-width: 0; /* Critical for truncation */
456
551
  }
457
552
 
458
553
  .command-icon {
459
- font-size: 20px;
554
+ font-size: 16px;
460
555
  flex-shrink: 0;
556
+ width: 20px;
557
+ text-align: center;
461
558
  }
462
559
 
463
560
  .command-info {
464
561
  display: flex;
465
562
  flex-direction: column;
466
- gap: 2px;
563
+ gap: 1px; /* Tighter gap */
467
564
  min-width: 0;
468
565
  flex: 1;
469
566
  }
470
567
 
471
568
  .command-name {
472
569
  font-weight: 600;
473
- font-size: 13px;
570
+ font-size: 12px;
474
571
  color: var(--text-primary);
572
+ white-space: nowrap;
573
+ overflow: hidden;
574
+ text-overflow: ellipsis;
575
+ line-height: 1.2;
475
576
  }
476
577
 
477
578
  .command-text {
478
579
  font-family: monospace;
479
- font-size: 11px;
580
+ font-size: 10px;
480
581
  color: var(--text-secondary);
481
582
  white-space: nowrap;
482
583
  overflow: hidden;
483
584
  text-overflow: ellipsis;
585
+ line-height: 1.2;
484
586
  }
485
587
 
486
588
  .command-card-actions {
487
589
  display: flex;
488
- gap: 4px;
590
+ gap: 2px;
489
591
  opacity: 0;
490
592
  transition: opacity 0.2s;
593
+ margin-left: 4px;
594
+ flex-shrink: 0;
595
+ background: var(--ios-gray-light); /* Ensure visibility over background */
596
+ border-radius: 4px;
491
597
  }
492
598
 
493
599
  .command-card:hover .command-card-actions {
@@ -495,21 +601,23 @@ body.resizing {
495
601
  }
496
602
 
497
603
  .command-action-btn {
498
- width: 24px;
499
- height: 24px;
604
+ width: 22px;
605
+ height: 22px;
500
606
  border: none;
501
- background: rgba(0, 0, 0, 0.05);
607
+ background: transparent;
502
608
  border-radius: 4px;
503
609
  cursor: pointer;
504
610
  display: flex;
505
611
  align-items: center;
506
612
  justify-content: center;
507
613
  font-size: 12px;
614
+ color: var(--text-secondary);
508
615
  transition: all 0.2s;
509
616
  }
510
617
 
511
618
  .command-action-btn:hover {
512
619
  background: rgba(0, 0, 0, 0.1);
620
+ color: var(--text-primary);
513
621
  transform: scale(1.1);
514
622
  }
515
623