vg-coder-cli 2.0.46 → 2.0.47

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vg-coder-cli",
3
- "version": "2.0.46",
3
+ "version": "2.0.47",
4
4
  "description": "🚀 CLI tool to analyze projects, concatenate source files, count tokens, and export HTML with syntax highlighting and copy functionality",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -45,6 +45,7 @@
45
45
  "ignore": "^5.3.0",
46
46
  "markdown-it": "^14.0.0",
47
47
  "mermaid": "^11.4.1",
48
+ "multer": "^1.4.5-lts.1",
48
49
  "node-pty": "^1.1.0",
49
50
  "ora": "^5.4.1",
50
51
  "path": "^0.12.7",
@@ -17,6 +17,9 @@ const TokenManager = require('../tokenizer/token-manager');
17
17
  const BashExecutor = require('../utils/bash-executor');
18
18
  const terminalManager = require('./terminal-manager');
19
19
  const projectManager = require('./project-manager');
20
+ const taskQueue = require('./task-queue');
21
+ const taskStore = require('./task-store');
22
+ const multer = require('multer');
20
23
 
21
24
  class ApiServer {
22
25
  constructor(port = 6868) {
@@ -32,6 +35,19 @@ class ApiServer {
32
35
  }
33
36
 
34
37
  setupMiddleware() {
38
+ // Private Network Access — Chrome 130+ requires this header for HTTPS
39
+ // origins (e.g. aistudio.google.com) calling localhost.
40
+ this.app.use((req, res, next) => {
41
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
42
+ if (req.method === 'OPTIONS') {
43
+ res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
44
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
45
+ res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] || 'Content-Type, Authorization');
46
+ res.setHeader('Access-Control-Max-Age', '600');
47
+ return res.sendStatus(204);
48
+ }
49
+ next();
50
+ });
35
51
  this.app.use(cors());
36
52
  this.app.use(bodyParser.json({ limit: '50mb' }));
37
53
  this.app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
@@ -47,6 +63,7 @@ class ApiServer {
47
63
  }
48
64
 
49
65
  setupSocketIO() {
66
+ taskQueue.attachIO(this.io);
50
67
  this.io.on('connection', (socket) => {
51
68
  socket.on('terminal:init', (data) => {
52
69
  if (!data || !data.termId) return;
@@ -65,7 +82,26 @@ class ApiServer {
65
82
  socket.on('terminal:input', (data) => { if (data && data.termId) terminalManager.write(data.termId, data.data); });
66
83
  socket.on('terminal:resize', (data) => { if (data && data.termId) terminalManager.resize(data.termId, data.cols, data.rows); });
67
84
  socket.on('terminal:kill', (data) => { if (data && data.termId) terminalManager.kill(data.termId); });
68
- socket.on('disconnect', () => { terminalManager.cleanupSocket(socket.id); });
85
+
86
+ // Task worker registration & lifecycle
87
+ socket.on('worker:register', (meta) => { taskQueue.setWorker(socket, meta || {}); });
88
+ socket.on('worker:heartbeat', () => { /* keep-alive only */ });
89
+ socket.on('task:complete', (payload) => {
90
+ if (payload?.taskId) taskQueue.onWorkerComplete(payload.taskId, payload, socket.id);
91
+ });
92
+ socket.on('task:failed', (payload) => {
93
+ if (payload?.taskId) taskQueue.onWorkerFailed(payload.taskId, { code: payload.code, message: payload.message }, socket.id);
94
+ });
95
+ socket.on('task:progress', () => { /* ignored for now */ });
96
+
97
+ // Profile launcher (extension background SW). One per Chrome profile.
98
+ socket.on('launcher:register', (meta) => { taskQueue.setLauncher(socket, meta || {}); });
99
+
100
+ socket.on('disconnect', () => {
101
+ terminalManager.cleanupSocket(socket.id);
102
+ taskQueue.clearWorker(socket);
103
+ taskQueue.clearLauncher(socket);
104
+ });
69
105
  });
70
106
  }
71
107
 
@@ -408,6 +444,306 @@ class ApiServer {
408
444
  } catch (error) { res.status(500).json({ error: error.message }); }
409
445
  });
410
446
 
447
+ // Chat History (per-project, stored in .vg/chats/<id>.json)
448
+ const chatsDir = (workingDir) => path.join(workingDir, '.vg', 'chats');
449
+ const sanitizeChatId = (id) => String(id || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 200);
450
+
451
+ this.app.get('/api/chats', async (req, res) => {
452
+ try {
453
+ const dir = chatsDir(req.workingDir);
454
+ if (!await fs.pathExists(dir)) return res.json({ chats: [] });
455
+ const files = (await fs.readdir(dir)).filter(f => f.endsWith('.json'));
456
+ const chats = [];
457
+ for (const f of files) {
458
+ try {
459
+ const data = await fs.readJson(path.join(dir, f));
460
+ chats.push({
461
+ id: data.id,
462
+ title: data.title || '(untitled)',
463
+ source: data.source || 'unknown',
464
+ createdAt: data.createdAt,
465
+ updatedAt: data.updatedAt,
466
+ count: Array.isArray(data.messages) ? data.messages.length : 0
467
+ });
468
+ } catch (_) { /* skip corrupt file */ }
469
+ }
470
+ chats.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
471
+ res.json({ chats });
472
+ } catch (error) { res.status(500).json({ error: error.message }); }
473
+ });
474
+
475
+ this.app.get('/api/chats/:id', async (req, res) => {
476
+ try {
477
+ const id = sanitizeChatId(req.params.id);
478
+ const file = path.join(chatsDir(req.workingDir), `${id}.json`);
479
+ if (!await fs.pathExists(file)) return res.status(404).json({ error: 'Chat not found' });
480
+ res.json(await fs.readJson(file));
481
+ } catch (error) { res.status(500).json({ error: error.message }); }
482
+ });
483
+
484
+ this.app.post('/api/chats/:id', async (req, res) => {
485
+ try {
486
+ const id = sanitizeChatId(req.params.id);
487
+ if (!id) return res.status(400).json({ error: 'Invalid chat id' });
488
+ const dir = chatsDir(req.workingDir);
489
+ await fs.ensureDir(dir);
490
+ const file = path.join(dir, `${id}.json`);
491
+ const now = Date.now();
492
+ const incoming = req.body || {};
493
+ const existing = await fs.pathExists(file) ? await fs.readJson(file) : null;
494
+ const data = {
495
+ id,
496
+ source: incoming.source || existing?.source || 'unknown',
497
+ title: incoming.title || existing?.title || '(untitled)',
498
+ createdAt: existing?.createdAt || now,
499
+ updatedAt: now,
500
+ messages: Array.isArray(incoming.messages) ? incoming.messages : (existing?.messages || [])
501
+ };
502
+ await fs.writeJson(file, data, { spaces: 2 });
503
+ res.json({ success: true, id, count: data.messages.length });
504
+ } catch (error) { res.status(500).json({ error: error.message }); }
505
+ });
506
+
507
+ this.app.delete('/api/chats/:id', async (req, res) => {
508
+ try {
509
+ const id = sanitizeChatId(req.params.id);
510
+ const file = path.join(chatsDir(req.workingDir), `${id}.json`);
511
+ if (await fs.pathExists(file)) await fs.remove(file);
512
+ res.json({ success: true });
513
+ } catch (error) { res.status(500).json({ error: error.message }); }
514
+ });
515
+
516
+ // ---------------- Remote Task API ----------------
517
+ const taskUpload = multer({
518
+ storage: multer.diskStorage({
519
+ destination: (req, file, cb) => {
520
+ // Stage uploads in a per-request temp dir; we move into final task dir after id assigned
521
+ const tmpDir = path.join(req.workingDir, '.vg', 'tasks', '_uploads', String(Date.now()) + '_' + Math.random().toString(36).slice(2, 8));
522
+ fs.ensureDir(tmpDir).then(() => cb(null, tmpDir)).catch(cb);
523
+ req._taskTmpDir = req._taskTmpDir || tmpDir;
524
+ },
525
+ filename: (req, file, cb) => cb(null, `${Date.now()}__${file.originalname}`)
526
+ }),
527
+ limits: { fileSize: 50 * 1024 * 1024, files: 10 }
528
+ });
529
+
530
+ this.app.post('/api/tasks', taskUpload.array('files', 10), async (req, res) => {
531
+ try {
532
+ const prompt = (req.body?.prompt || '').toString();
533
+ if (!prompt && (!req.files || req.files.length === 0)) {
534
+ return res.status(400).json({ error: 'prompt or files required' });
535
+ }
536
+ let meta = null;
537
+ if (req.body?.meta) {
538
+ try { meta = JSON.parse(req.body.meta); } catch (_) { meta = req.body.meta; }
539
+ }
540
+ const webhookUrl = req.body?.webhookUrl ? String(req.body.webhookUrl) : null;
541
+ const workerLabel = req.body?.workerLabel
542
+ ? String(req.body.workerLabel).toLowerCase().trim()
543
+ : null;
544
+
545
+ const id = taskStore.newTaskId();
546
+ const finalDir = taskStore.taskDir(req.workingDir, id);
547
+ const finalFiles = path.join(finalDir, 'files');
548
+ await fs.ensureDir(finalFiles);
549
+
550
+ const fileEntries = [];
551
+ for (let i = 0; i < (req.files || []).length; i++) {
552
+ const f = req.files[i];
553
+ const safeName = path.basename(f.originalname).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || `file_${i}`;
554
+ const dest = path.join(finalFiles, `${i}__${safeName}`);
555
+ await fs.move(f.path, dest, { overwrite: true });
556
+ fileEntries.push({ idx: i, name: f.originalname, mime: f.mimetype, size: f.size, path: dest });
557
+ }
558
+ // Cleanup tmp dir
559
+ if (req._taskTmpDir) {
560
+ try { await fs.remove(req._taskTmpDir); } catch (_) {}
561
+ }
562
+
563
+ const task = {
564
+ id,
565
+ status: 'queued',
566
+ prompt,
567
+ meta,
568
+ files: fileEntries,
569
+ webhookUrl,
570
+ webhook: { attempts: [], deliveredAt: null },
571
+ worker: null,
572
+ workerLabel,
573
+ attempts: [],
574
+ result: null,
575
+ error: null,
576
+ timing: { createdAt: Date.now() },
577
+ cancelRequestedAt: null,
578
+ workingDir: req.workingDir
579
+ };
580
+
581
+ const result = await taskQueue.enqueue(task);
582
+ res.status(202).json({
583
+ taskId: id,
584
+ status: result.status,
585
+ position: result.position,
586
+ pollUrl: `/api/tasks/${id}`,
587
+ createdAt: task.timing.createdAt
588
+ });
589
+ } catch (error) {
590
+ console.error('[Tasks] create failed:', error);
591
+ res.status(500).json({ error: error.message });
592
+ }
593
+ });
594
+
595
+ this.app.get('/api/tasks', async (req, res) => {
596
+ try {
597
+ const filter = {};
598
+ if (req.query.status) filter.status = String(req.query.status);
599
+ if (req.query.limit) filter.limit = Math.min(parseInt(req.query.limit, 10) || 50, 500);
600
+ res.json({ tasks: await taskQueue.list(req.workingDir, filter) });
601
+ } catch (error) { res.status(500).json({ error: error.message }); }
602
+ });
603
+
604
+ this.app.get('/api/tasks/:id', async (req, res) => {
605
+ try {
606
+ const id = taskStore.sanitizeTaskId(req.params.id);
607
+ const task = await taskQueue.get(id, req.workingDir);
608
+ if (!task) return res.status(404).json({ error: 'Task not found' });
609
+ const result = { ...task };
610
+ if (task.status === 'done') {
611
+ result.result = { ...(task.result || {}), markdown: await taskStore.readResult(task.workingDir, task.id) };
612
+ }
613
+ res.json(result);
614
+ } catch (error) { res.status(500).json({ error: error.message }); }
615
+ });
616
+
617
+ this.app.delete('/api/tasks/:id', async (req, res) => {
618
+ try {
619
+ const id = taskStore.sanitizeTaskId(req.params.id);
620
+ const r = await taskQueue.cancel(id, req.workingDir);
621
+ if (!r.ok) return res.status(r.code || 400).json({ error: 'cannot_cancel', status: r.status });
622
+ res.json({ canceled: true, status: r.status });
623
+ } catch (error) { res.status(500).json({ error: error.message }); }
624
+ });
625
+
626
+ this.app.get('/api/tasks/:id/files/:idx', async (req, res) => {
627
+ try {
628
+ const id = taskStore.sanitizeTaskId(req.params.id);
629
+ const idx = parseInt(req.params.idx, 10);
630
+ if (!Number.isInteger(idx) || idx < 0) return res.status(400).json({ error: 'invalid idx' });
631
+ const task = await taskQueue.get(id, req.workingDir);
632
+ if (!task) return res.status(404).json({ error: 'task not found' });
633
+ const file = (task.files || []).find(f => f.idx === idx);
634
+ if (!file || !file.path) return res.status(404).json({ error: 'file not found' });
635
+ if (!await fs.pathExists(file.path)) return res.status(404).json({ error: 'file missing on disk' });
636
+ res.setHeader('Content-Type', file.mime || 'application/octet-stream');
637
+ res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.name)}"`);
638
+ fs.createReadStream(file.path).pipe(res);
639
+ } catch (error) { res.status(500).json({ error: error.message }); }
640
+ });
641
+ // ---------------- /Remote Task API ----------------
642
+
643
+ // ---------------- Remote Debug API ----------------
644
+ // Localhost-only debug surface for connected AI Studio workers.
645
+ // Pass `workerLabel` (email) in query/body to target a specific worker.
646
+
647
+ // Helper: extract worker target from query/body
648
+ const workerOpts = (req) => {
649
+ const label = (req.query?.label || req.query?.workerLabel || req.body?.workerLabel || '').toString().toLowerCase().trim();
650
+ return label ? { workerLabel: label } : {};
651
+ };
652
+
653
+ this.app.get('/api/workers', (req, res) => {
654
+ res.json({ workers: taskQueue.listWorkers() });
655
+ });
656
+
657
+ this.app.get('/api/launchers', (req, res) => {
658
+ res.json({ launchers: taskQueue.listLaunchers() });
659
+ });
660
+
661
+ // List AI Studio tabs across all profiles (or 1 if workerLabel given).
662
+ this.app.get('/api/launcher/tabs', async (req, res) => {
663
+ try {
664
+ const label = (req.query?.label || req.query?.workerLabel || '').toString().trim();
665
+ const opts = label ? { workerLabel: label } : { all: true };
666
+ const result = await taskQueue.requestLauncher('launcher:list_tabs', {}, opts, 5_000);
667
+ res.json(result);
668
+ } catch (e) { res.status(503).json({ error: e.message }); }
669
+ });
670
+
671
+ // Close tab(s). Body: { workerLabel?, tabId? } — omit tabId to close all.
672
+ this.app.post('/api/launcher/close-tab', async (req, res) => {
673
+ try {
674
+ const body = req.body || {};
675
+ const opts = body.workerLabel ? { workerLabel: body.workerLabel } : (body.all ? { all: true } : {});
676
+ const payload = body.tabId != null ? { tabId: body.tabId } : {};
677
+ const result = await taskQueue.requestLauncher('launcher:close_tab', payload, opts, 5_000);
678
+ res.json(result);
679
+ } catch (e) { res.status(503).json({ error: e.message }); }
680
+ });
681
+
682
+ // Open new AI Studio tab. Body: { workerLabel?, model?, url?, active? }
683
+ this.app.post('/api/launcher/open-tab', async (req, res) => {
684
+ try {
685
+ const body = req.body || {};
686
+ const opts = body.workerLabel ? { workerLabel: body.workerLabel } : {};
687
+ const payload = { url: body.url, model: body.model, active: body.active };
688
+ const result = await taskQueue.requestLauncher('launcher:open_tab', payload, opts, 8_000);
689
+ res.json(result);
690
+ } catch (e) { res.status(503).json({ error: e.message }); }
691
+ });
692
+
693
+ this.app.get('/api/worker/status', (req, res) => {
694
+ const label = (req.query?.label || req.query?.workerLabel || '').toString().toLowerCase().trim();
695
+ res.json(taskQueue.workerStatus(label || undefined));
696
+ });
697
+
698
+ this.app.get('/api/worker/url', async (req, res) => {
699
+ try { res.json(await taskQueue.requestWorker('debug:url', {}, workerOpts(req))); }
700
+ catch (e) { res.status(503).json({ error: e.message }); }
701
+ });
702
+
703
+ this.app.post('/api/worker/probe', async (req, res) => {
704
+ try {
705
+ const { selector, kind } = req.body || {};
706
+ if (!selector) return res.status(400).json({ error: 'selector required' });
707
+ res.json(await taskQueue.requestWorker('debug:probe', { selector, kind: kind || 'text' }, workerOpts(req)));
708
+ } catch (e) { res.status(503).json({ error: e.message }); }
709
+ });
710
+
711
+ this.app.post('/api/worker/dom', async (req, res) => {
712
+ try {
713
+ const { selector, maxBytes } = req.body || {};
714
+ res.json(await taskQueue.requestWorker('debug:dom', { selector, maxBytes: maxBytes || 200_000 }, workerOpts(req)));
715
+ } catch (e) { res.status(503).json({ error: e.message }); }
716
+ });
717
+
718
+ this.app.post('/api/worker/eval', async (req, res) => {
719
+ try {
720
+ const { code, timeoutMs } = req.body || {};
721
+ if (!code) return res.status(400).json({ error: 'code required' });
722
+ res.json(await taskQueue.requestWorker('debug:eval', { code }, workerOpts(req), timeoutMs || 15_000));
723
+ } catch (e) { res.status(503).json({ error: e.message }); }
724
+ });
725
+
726
+ this.app.post('/api/worker/logs', async (req, res) => {
727
+ try {
728
+ const { since, level, limit, clear } = req.body || {};
729
+ res.json(await taskQueue.requestWorker('debug:logs', { since, level, limit, clear }, workerOpts(req)));
730
+ } catch (e) { res.status(503).json({ error: e.message }); }
731
+ });
732
+
733
+ this.app.get('/api/worker/screenshot', async (req, res) => {
734
+ try {
735
+ const format = (req.query?.format === 'jpeg') ? 'jpeg' : 'png';
736
+ const quality = req.query?.quality ? Number(req.query.quality) : undefined;
737
+ const data = await taskQueue.requestWorker('debug:screenshot', { format, quality }, workerOpts(req), 15_000);
738
+ if (!data?.ok) return res.status(500).json({ error: data?.error || 'screenshot_failed' });
739
+ const buf = Buffer.from(data.dataUrl.split(',')[1], 'base64');
740
+ res.setHeader('Content-Type', `image/${format}`);
741
+ res.setHeader('Content-Disposition', `inline; filename="worker-screenshot.${format}"`);
742
+ res.send(buf);
743
+ } catch (e) { res.status(503).json({ error: e.message }); }
744
+ });
745
+ // ---------------- /Remote Debug API ----------------
746
+
411
747
  this.app.get('/api/extension-path', (req, res) => {
412
748
  try {
413
749
  const extensionPath = path.join(__dirname, 'views', 'vg-coder');
@@ -427,6 +763,20 @@ class ApiServer {
427
763
  res.status(500).json({ success: false, error: error.message });
428
764
  }
429
765
  });
766
+
767
+ // Clipboard API - Write system clipboard from server-side (avoids the
768
+ // browser's clipboard-write permission prompt on the page).
769
+ this.app.post('/api/clipboard', async (req, res) => {
770
+ try {
771
+ const text = (req.body?.text ?? '').toString();
772
+ const clipboardy = await import('clipboardy');
773
+ await clipboardy.default.write(text);
774
+ res.json({ success: true });
775
+ } catch (error) {
776
+ console.error('Clipboard write error:', error);
777
+ res.status(500).json({ success: false, error: error.message });
778
+ }
779
+ });
430
780
 
431
781
  this.app.post('/api/shutdown', async (req, res) => {
432
782
  res.json({ success: true });
@@ -447,11 +797,14 @@ class ApiServer {
447
797
  }
448
798
  };
449
799
  this.httpServer.once('error', onError);
450
- this.server = this.httpServer.listen(port, () => {
800
+ this.server = this.httpServer.listen(port, '127.0.0.1', () => {
451
801
  this.httpServer.removeListener('error', onError);
452
802
  this.port = this.server.address().port;
453
803
  console.log(chalk.green(`🚀 Server Online: http://localhost:${this.port}`));
454
804
  console.log(chalk.blue(`📦 Dist served at: http://localhost:${this.port}/dist`));
805
+ taskQueue.rehydrateFromProjects(this.projectManager).catch(err => {
806
+ console.log(chalk.yellow(`[TaskQueue] Rehydrate failed: ${err.message}`));
807
+ });
455
808
  resolve();
456
809
  });
457
810
  };