vg-coder-cli 2.0.24 → 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();
@@ -1,10 +1,15 @@
1
1
  const os = require('os');
2
2
  const pty = require('node-pty');
3
+ const path = require('path');
4
+ const { stripAnsiCodes, classifyLogLine, extractErrors } = require(path.join(__dirname, 'views/js/utils/log-utils'));
3
5
 
4
6
  class TerminalManager {
5
7
  constructor() {
6
- // Map: termId -> { process: pty, socketId: string }
8
+ // Map: termId -> { process: pty, socketId: string, projectId: string }
7
9
  this.sessions = new Map();
10
+ // Map: termId -> Array of log lines (circular buffer, max 10000)
11
+ this.logBuffers = new Map();
12
+ this.MAX_LOG_LINES = 10000;
8
13
  // Use full path to shell to avoid posix_spawnp errors
9
14
  if (os.platform() === 'win32') {
10
15
  this.shell = 'powershell.exe';
@@ -17,7 +22,7 @@ class TerminalManager {
17
22
  }
18
23
  }
19
24
 
20
- createTerminal(socket, termId, cols = 80, rows = 24, cwd) {
25
+ createTerminal(socket, termId, cols = 80, rows = 24, cwd, projectId = null) {
21
26
  try {
22
27
  const term = pty.spawn(this.shell, [], {
23
28
  name: 'xterm-256color',
@@ -29,11 +34,19 @@ class TerminalManager {
29
34
 
30
35
  this.sessions.set(termId, {
31
36
  process: term,
32
- socketId: socket.id
37
+ socketId: socket.id,
38
+ projectId: projectId // Store project association
33
39
  });
34
40
 
41
+ // Initialize log buffer for this terminal
42
+ this.logBuffers.set(termId, []);
43
+
35
44
  // Gửi dữ liệu kèm theo termId để Frontend biết của cửa sổ nào
36
45
  term.onData((data) => {
46
+ // Store in log buffer (strip ANSI for storage)
47
+ this.addToLogBuffer(termId, data);
48
+
49
+ // Send raw data to frontend (keep ANSI for display)
37
50
  socket.emit('terminal:data', { termId, data });
38
51
  });
39
52
 
@@ -41,6 +54,8 @@ class TerminalManager {
41
54
  if (this.sessions.has(termId)) {
42
55
  socket.emit('terminal:exit', { termId });
43
56
  this.sessions.delete(termId);
57
+ // Clean up log buffer
58
+ this.logBuffers.delete(termId);
44
59
  }
45
60
  });
46
61
 
@@ -83,8 +98,120 @@ class TerminalManager {
83
98
  if (session.socketId === socketId) {
84
99
  session.process.kill();
85
100
  this.sessions.delete(termId);
101
+ this.logBuffers.delete(termId);
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Add data to log buffer (circular buffer with max 10000 lines)
108
+ * @param {string} termId - Terminal ID
109
+ * @param {string} data - Raw terminal data (may contain ANSI codes)
110
+ */
111
+ addToLogBuffer(termId, data) {
112
+ if (!this.logBuffers.has(termId)) {
113
+ this.logBuffers.set(termId, []);
114
+ }
115
+
116
+ const buffer = this.logBuffers.get(termId);
117
+
118
+ // Strip ANSI codes before storing
119
+ const cleanData = stripAnsiCodes(data);
120
+
121
+ // Split by newlines and add to buffer
122
+ const lines = cleanData.split(/\r?\n/);
123
+
124
+ lines.forEach(line => {
125
+ // Only add non-empty lines
126
+ if (line.trim().length > 0) {
127
+ buffer.push(line);
128
+
129
+ // Maintain circular buffer - remove oldest if exceeds limit
130
+ if (buffer.length > this.MAX_LOG_LINES) {
131
+ buffer.shift();
132
+ }
86
133
  }
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Get log buffer for a terminal
139
+ * @param {string} termId - Terminal ID
140
+ * @returns {string[]} Array of log lines
141
+ */
142
+ getLogBuffer(termId) {
143
+ return this.logBuffers.get(termId) || [];
144
+ }
145
+
146
+ /**
147
+ * Analyze log buffer and return statistics
148
+ * @param {string} termId - Terminal ID
149
+ * @returns {Object} Analysis with error counts, line counts, etc.
150
+ */
151
+ analyzeLogBuffer(termId) {
152
+ const lines = this.getLogBuffer(termId);
153
+
154
+ if (lines.length === 0) {
155
+ return {
156
+ totalLines: 0,
157
+ errorLines: 0,
158
+ warningLines: 0,
159
+ normalLines: 0,
160
+ errors: []
161
+ };
87
162
  }
163
+
164
+ let errorCount = 0;
165
+ let warningCount = 0;
166
+ let normalCount = 0;
167
+
168
+ lines.forEach(line => {
169
+ const type = classifyLogLine(line);
170
+ if (type === 'ERROR') errorCount++;
171
+ else if (type === 'WARNING') warningCount++;
172
+ else normalCount++;
173
+ });
174
+
175
+ const errors = extractErrors(lines, 2);
176
+
177
+ return {
178
+ totalLines: lines.length,
179
+ errorLines: errorCount,
180
+ warningLines: warningCount,
181
+ normalLines: normalCount,
182
+ errors: errors.map(e => ({
183
+ line: e.line,
184
+ type: e.type,
185
+ lineIndex: e.lineIndex
186
+ }))
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}`);
88
215
  }
89
216
  }
90
217
 
@@ -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 {
@@ -69,6 +69,94 @@
69
69
  text-overflow: ellipsis;
70
70
  }
71
71
 
72
+ /* Copy Button Group */
73
+ .terminal-copy-group {
74
+ display: flex;
75
+ gap: 4px;
76
+ margin-left: auto;
77
+ margin-right: 8px;
78
+ align-items: center;
79
+ }
80
+
81
+ .copy-btn {
82
+ padding: 3px 6px;
83
+ font-size: 10px;
84
+ border-radius: 3px;
85
+ background: #2d2d30;
86
+ border: 1px solid #3e3e42;
87
+ color: #ccc;
88
+ cursor: pointer;
89
+ transition: all 0.2s;
90
+ display: flex;
91
+ align-items: center;
92
+ gap: 3px;
93
+ white-space: nowrap;
94
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
95
+ }
96
+
97
+ .copy-btn:hover {
98
+ background: #3e3e42;
99
+ border-color: #555;
100
+ transform: translateY(-1px);
101
+ }
102
+
103
+ .copy-btn:active {
104
+ transform: translateY(0);
105
+ }
106
+
107
+ .token-badge {
108
+ font-size: 9px;
109
+ color: #888;
110
+ font-weight: 600;
111
+ min-width: 20px;
112
+ text-align: right;
113
+ }
114
+
115
+ /* Specific button colors */
116
+ .copy-smart:hover {
117
+ border-color: #4a9eff;
118
+ }
119
+
120
+ .copy-errors:hover {
121
+ border-color: #f48771;
122
+ }
123
+
124
+ .copy-recent:hover {
125
+ border-color: #89d185;
126
+ }
127
+
128
+ .copy-all:hover {
129
+ border-color: #dcdcaa;
130
+ }
131
+
132
+ /* Clear Button */
133
+ .term-btn-clear {
134
+ width: 24px;
135
+ height: 24px;
136
+ border-radius: 4px;
137
+ background: #2d2d30;
138
+ border: 1px solid #3e3e42;
139
+ color: #ccc;
140
+ cursor: pointer;
141
+ display: flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ font-size: 14px;
145
+ margin-left: 4px;
146
+ margin-right: 8px;
147
+ transition: all 0.2s;
148
+ }
149
+
150
+ .term-btn-clear:hover {
151
+ background: #3e3e42;
152
+ border-color: #f48771;
153
+ transform: translateY(-1px);
154
+ }
155
+
156
+ .term-btn-clear:active {
157
+ transform: translateY(0);
158
+ }
159
+
72
160
  .terminal-controls {
73
161
  display: flex;
74
162
  gap: 6px;