polydev-ai 1.4.5 → 1.5.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.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  **Advanced Model Context Protocol Platform with Multi-LLM Integrations**
4
4
 
5
+ <!-- Last updated: 2024-12-12 -->
6
+
5
7
  [polydev.ai](https://polydev.ai) | Live Platform
6
8
 
7
9
  ---
package/lib/cliManager.js CHANGED
@@ -5,13 +5,34 @@ const fs = require('fs');
5
5
  const os = require('os');
6
6
  const which = require('which');
7
7
 
8
+ // Status reporter for sending CLI status to polydev.ai server
9
+ let StatusReporter;
10
+ try {
11
+ StatusReporter = require('./statusReporter').StatusReporter;
12
+ } catch (e) {
13
+ // StatusReporter not available, continue without it
14
+ StatusReporter = null;
15
+ }
16
+
8
17
  const execAsync = promisify(exec);
9
18
 
10
19
  class CLIManager {
11
- constructor() {
20
+ constructor(options = {}) {
12
21
  this.providers = new Map();
13
22
  this.statusCache = new Map();
14
23
  this.CACHE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
24
+
25
+ // Initialize status reporter if available
26
+ this.statusReporter = null;
27
+ if (StatusReporter && options.enableStatusReporting !== false) {
28
+ this.statusReporter = new StatusReporter({
29
+ userToken: options.userToken || process.env.POLYDEV_USER_TOKEN,
30
+ reportingEnabled: options.reportingEnabled !== false,
31
+ heartbeatIntervalMs: options.heartbeatIntervalMs,
32
+ debug: options.debug || process.env.POLYDEV_CLI_DEBUG === 'true'
33
+ });
34
+ }
35
+
15
36
  this.initializeProviders();
16
37
  }
17
38
 
@@ -96,6 +117,13 @@ class CLIManager {
96
117
  }
97
118
  }
98
119
 
120
+ // Report status to polydev.ai server (non-blocking)
121
+ if (this.statusReporter && this.statusReporter.isConfigured()) {
122
+ this.statusReporter.reportAllStatuses(results).catch(err => {
123
+ console.warn(`[Polydev CLI] Status reporting failed:`, err.message);
124
+ });
125
+ }
126
+
99
127
  return results;
100
128
  }
101
129
 
@@ -710,6 +738,115 @@ This is a known issue with @google/gemini-cli@0.3.4 and older Node.js versions.`
710
738
  getProvider(providerId) {
711
739
  return this.providers.get(providerId);
712
740
  }
741
+
742
+ // ============================================
743
+ // Status Reporting Methods
744
+ // ============================================
745
+
746
+ /**
747
+ * Enable status reporting to polydev.ai server
748
+ * @param {string} userToken - User's MCP token (pd_xxx or polydev_xxx)
749
+ * @param {object} options - Additional configuration options
750
+ */
751
+ enableStatusReporting(userToken, options = {}) {
752
+ if (!StatusReporter) {
753
+ console.warn('[Polydev CLI] StatusReporter not available');
754
+ return false;
755
+ }
756
+
757
+ if (!this.statusReporter) {
758
+ this.statusReporter = new StatusReporter({
759
+ userToken,
760
+ reportingEnabled: true,
761
+ ...options
762
+ });
763
+ } else {
764
+ this.statusReporter.configure({
765
+ userToken,
766
+ reportingEnabled: true,
767
+ ...options
768
+ });
769
+ }
770
+
771
+ console.log('[Polydev CLI] Status reporting enabled');
772
+ return true;
773
+ }
774
+
775
+ /**
776
+ * Disable status reporting
777
+ */
778
+ disableStatusReporting() {
779
+ if (this.statusReporter) {
780
+ this.statusReporter.configure({ reportingEnabled: false });
781
+ this.statusReporter.stopHeartbeat();
782
+ console.log('[Polydev CLI] Status reporting disabled');
783
+ }
784
+ }
785
+
786
+ /**
787
+ * Start automatic heartbeat for periodic status updates
788
+ * @param {number} intervalMs - Interval in milliseconds (default: 15 minutes)
789
+ */
790
+ startStatusHeartbeat(intervalMs) {
791
+ if (this.statusReporter) {
792
+ if (intervalMs) {
793
+ this.statusReporter.configure({ heartbeatIntervalMs: intervalMs });
794
+ }
795
+ this.statusReporter.startHeartbeat(this);
796
+ console.log('[Polydev CLI] Status heartbeat started');
797
+ return true;
798
+ }
799
+ return false;
800
+ }
801
+
802
+ /**
803
+ * Stop automatic heartbeat
804
+ */
805
+ stopStatusHeartbeat() {
806
+ if (this.statusReporter) {
807
+ this.statusReporter.stopHeartbeat();
808
+ console.log('[Polydev CLI] Status heartbeat stopped');
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Get status reporting configuration and history
814
+ */
815
+ getStatusReportingInfo() {
816
+ if (!this.statusReporter) {
817
+ return {
818
+ available: false,
819
+ reason: 'StatusReporter not initialized'
820
+ };
821
+ }
822
+
823
+ return {
824
+ available: true,
825
+ config: this.statusReporter.getConfig(),
826
+ history: this.statusReporter.getHistory()
827
+ };
828
+ }
829
+
830
+ /**
831
+ * Manually trigger a status report
832
+ * @returns {Promise<object>} - Report results
833
+ */
834
+ async reportStatusNow() {
835
+ if (!this.statusReporter || !this.statusReporter.isConfigured()) {
836
+ return {
837
+ success: false,
838
+ reason: 'Status reporting not configured'
839
+ };
840
+ }
841
+
842
+ const statuses = await this.forceCliDetection();
843
+ // forceCliDetection already reports, but we return the result
844
+ return {
845
+ success: true,
846
+ statuses,
847
+ reported: true
848
+ };
849
+ }
713
850
  }
714
851
 
715
852
  module.exports = { CLIManager, default: CLIManager };
@@ -0,0 +1,516 @@
1
+ /**
2
+ * StatusReporter - Reports CLI status to polydev.ai server
3
+ *
4
+ * This module enables the NPM package to report CLI tool status back to the
5
+ * polydev.ai server, allowing users to see their CLI status on the dashboard.
6
+ *
7
+ * Key Features:
8
+ * 1. Automatic reporting on CLI detection
9
+ * 2. Heartbeat system for periodic updates
10
+ * 3. Event-triggered updates (auth change, install/uninstall)
11
+ * 4. Retry logic with exponential backoff
12
+ * 5. Offline queue for failed reports
13
+ * 6. Deduplication to avoid redundant calls
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+
20
+ class StatusReporter {
21
+ constructor(options = {}) {
22
+ // Server configuration
23
+ this.serverUrl = options.serverUrl || process.env.POLYDEV_STATUS_SERVER || 'https://www.polydev.ai/api/mcp';
24
+ this.userToken = options.userToken || process.env.POLYDEV_USER_TOKEN;
25
+
26
+ // Feature flags
27
+ this.reportingEnabled = options.reportingEnabled !== false && process.env.POLYDEV_REPORTING_ENABLED !== 'false';
28
+
29
+ // Timing configuration
30
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ||
31
+ parseInt(process.env.POLYDEV_HEARTBEAT_INTERVAL || '900000', 10); // Default: 15 minutes
32
+
33
+ // Retry configuration
34
+ this.maxRetries = options.maxRetries || 3;
35
+ this.retryDelayMs = options.retryDelayMs || 1000;
36
+ this.requestTimeoutMs = options.requestTimeoutMs || 10000; // 10 seconds
37
+
38
+ // State management
39
+ this.pendingReports = [];
40
+ this.isOnline = true;
41
+ this.lastReportedStatus = new Map();
42
+ this.heartbeatTimer = null;
43
+ this.reportHistory = [];
44
+ this.maxHistorySize = 100;
45
+
46
+ // Debug mode
47
+ this.debug = options.debug || process.env.POLYDEV_CLI_DEBUG === 'true';
48
+
49
+ // Persistence for offline queue
50
+ this.queueFilePath = path.join(os.tmpdir(), '.polydev-pending-reports.json');
51
+ this.loadPendingReports();
52
+ }
53
+
54
+ /**
55
+ * Log debug messages
56
+ */
57
+ log(message, ...args) {
58
+ if (this.debug) {
59
+ console.log(`[StatusReporter] ${message}`, ...args);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Log errors (always shown)
65
+ */
66
+ error(message, ...args) {
67
+ console.error(`[StatusReporter] ${message}`, ...args);
68
+ }
69
+
70
+ /**
71
+ * Check if reporting is properly configured
72
+ */
73
+ isConfigured() {
74
+ return this.reportingEnabled && !!this.userToken;
75
+ }
76
+
77
+ /**
78
+ * Report status for a single provider
79
+ * @param {string} provider - Provider ID (claude_code, codex_cli, gemini_cli)
80
+ * @param {object} status - Status object with available, authenticated, version, error fields
81
+ * @returns {Promise<object>} - Report result
82
+ */
83
+ async reportStatus(provider, status) {
84
+ if (!this.isConfigured()) {
85
+ return {
86
+ success: false,
87
+ reason: this.reportingEnabled ? 'No user token configured' : 'Reporting disabled',
88
+ skipped: true
89
+ };
90
+ }
91
+
92
+ // Validate provider
93
+ if (!['claude_code', 'codex_cli', 'gemini_cli'].includes(provider)) {
94
+ return { success: false, reason: `Invalid provider: ${provider}` };
95
+ }
96
+
97
+ // Deduplicate - don't report if status unchanged within last 5 minutes
98
+ const statusKey = this.generateStatusKey(provider, status);
99
+ const lastReported = this.lastReportedStatus.get(provider);
100
+
101
+ if (lastReported && lastReported.key === statusKey) {
102
+ const timeSinceLastReport = Date.now() - lastReported.timestamp;
103
+ if (timeSinceLastReport < 5 * 60 * 1000) { // 5 minutes
104
+ this.log(`Skipping duplicate report for ${provider} (unchanged for ${Math.round(timeSinceLastReport/1000)}s)`);
105
+ return { success: true, reason: 'Status unchanged, skipped', skipped: true };
106
+ }
107
+ }
108
+
109
+ // Build payload
110
+ const payload = {
111
+ provider,
112
+ status: this.mapStatusToServerFormat(status),
113
+ authenticated: status.authenticated || false,
114
+ version: status.version || null,
115
+ message: this.buildStatusMessage(status)
116
+ };
117
+
118
+ this.log(`Reporting status for ${provider}:`, payload);
119
+
120
+ try {
121
+ const result = await this.sendReport(payload);
122
+
123
+ if (result.success) {
124
+ // Update last reported status
125
+ this.lastReportedStatus.set(provider, {
126
+ key: statusKey,
127
+ timestamp: Date.now()
128
+ });
129
+
130
+ // Record in history
131
+ this.addToHistory({
132
+ provider,
133
+ status: payload.status,
134
+ success: true,
135
+ timestamp: new Date().toISOString()
136
+ });
137
+ }
138
+
139
+ return result;
140
+
141
+ } catch (error) {
142
+ this.error(`Failed to report status for ${provider}:`, error.message);
143
+
144
+ // Queue for retry
145
+ this.queueReport({ payload, timestamp: Date.now(), retries: 0 });
146
+
147
+ // Record failure in history
148
+ this.addToHistory({
149
+ provider,
150
+ status: payload.status,
151
+ success: false,
152
+ error: error.message,
153
+ timestamp: new Date().toISOString()
154
+ });
155
+
156
+ return { success: false, error: error.message, queued: true };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Report all CLI statuses at once
162
+ * @param {object} statuses - Map of provider ID to status object
163
+ * @returns {Promise<object>} - Results for each provider
164
+ */
165
+ async reportAllStatuses(statuses) {
166
+ if (!this.isConfigured()) {
167
+ return {
168
+ success: false,
169
+ reason: 'Not configured',
170
+ results: {}
171
+ };
172
+ }
173
+
174
+ const results = {};
175
+ const promises = [];
176
+
177
+ for (const [provider, status] of Object.entries(statuses)) {
178
+ promises.push(
179
+ this.reportStatus(provider, status)
180
+ .then(result => { results[provider] = result; })
181
+ .catch(error => { results[provider] = { success: false, error: error.message }; })
182
+ );
183
+ }
184
+
185
+ await Promise.all(promises);
186
+
187
+ const successCount = Object.values(results).filter(r => r.success).length;
188
+ const totalCount = Object.keys(results).length;
189
+
190
+ this.log(`Reported ${successCount}/${totalCount} statuses successfully`);
191
+
192
+ return {
193
+ success: successCount > 0,
194
+ successCount,
195
+ totalCount,
196
+ results
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Send report to server with retry logic
202
+ */
203
+ async sendReport(payload, retryCount = 0) {
204
+ const body = JSON.stringify({
205
+ jsonrpc: '2.0',
206
+ method: 'tools/call',
207
+ params: {
208
+ name: 'report_cli_status',
209
+ arguments: payload
210
+ },
211
+ id: `status-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
212
+ });
213
+
214
+ this.log(`Sending report to ${this.serverUrl} (attempt ${retryCount + 1}/${this.maxRetries + 1})`);
215
+
216
+ try {
217
+ // Use native fetch with timeout
218
+ const controller = new AbortController();
219
+ const timeoutId = setTimeout(() => controller.abort(), this.requestTimeoutMs);
220
+
221
+ const response = await fetch(this.serverUrl, {
222
+ method: 'POST',
223
+ headers: {
224
+ 'Content-Type': 'application/json',
225
+ 'Authorization': `Bearer ${this.userToken}`,
226
+ 'User-Agent': 'polydev-ai-npm/1.4.0'
227
+ },
228
+ body,
229
+ signal: controller.signal
230
+ });
231
+
232
+ clearTimeout(timeoutId);
233
+
234
+ if (!response.ok) {
235
+ const errorText = await response.text().catch(() => 'Unknown error');
236
+ throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 200)}`);
237
+ }
238
+
239
+ const result = await response.json();
240
+
241
+ if (result.error) {
242
+ throw new Error(result.error.message || 'Server returned error');
243
+ }
244
+
245
+ this.isOnline = true;
246
+ return { success: true, response: result };
247
+
248
+ } catch (error) {
249
+ // Handle abort (timeout)
250
+ if (error.name === 'AbortError') {
251
+ throw new Error(`Request timed out after ${this.requestTimeoutMs}ms`);
252
+ }
253
+
254
+ // Check if we should retry
255
+ if (retryCount < this.maxRetries) {
256
+ const delay = this.retryDelayMs * Math.pow(2, retryCount); // Exponential backoff
257
+ this.log(`Retrying in ${delay}ms...`);
258
+ await new Promise(resolve => setTimeout(resolve, delay));
259
+ return this.sendReport(payload, retryCount + 1);
260
+ }
261
+
262
+ // Mark as offline if we've exhausted retries
263
+ this.isOnline = false;
264
+ throw error;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Generate a unique key for status deduplication
270
+ */
271
+ generateStatusKey(provider, status) {
272
+ return `${provider}:${status.available}:${status.authenticated}:${status.version || 'none'}`;
273
+ }
274
+
275
+ /**
276
+ * Map local status object to server-expected format
277
+ */
278
+ mapStatusToServerFormat(status) {
279
+ if (!status.available) return 'not_installed';
280
+ if (status.error?.includes('Compatibility Issue')) return 'error';
281
+ if (!status.authenticated) return 'unavailable';
282
+ return 'available';
283
+ }
284
+
285
+ /**
286
+ * Build a human-readable status message
287
+ */
288
+ buildStatusMessage(status) {
289
+ const parts = [];
290
+
291
+ if (status.error) {
292
+ parts.push(status.error);
293
+ } else if (status.available && status.authenticated) {
294
+ parts.push('CLI is installed and authenticated');
295
+ } else if (status.available && !status.authenticated) {
296
+ parts.push('CLI is installed but not authenticated');
297
+ } else {
298
+ parts.push('CLI is not installed');
299
+ }
300
+
301
+ parts.push(`(reported by polydev-ai NPM package)`);
302
+
303
+ return parts.join(' ');
304
+ }
305
+
306
+ /**
307
+ * Start the heartbeat system for periodic status updates
308
+ * @param {CLIManager} cliManager - Reference to CLIManager instance
309
+ */
310
+ startHeartbeat(cliManager) {
311
+ if (this.heartbeatTimer) {
312
+ this.log('Heartbeat already running');
313
+ return;
314
+ }
315
+
316
+ if (!this.isConfigured()) {
317
+ this.log('Heartbeat not started - not configured');
318
+ return;
319
+ }
320
+
321
+ this.log(`Starting heartbeat (interval: ${this.heartbeatIntervalMs}ms)`);
322
+
323
+ // Initial report
324
+ this.runHeartbeat(cliManager);
325
+
326
+ // Set up periodic heartbeat
327
+ this.heartbeatTimer = setInterval(() => {
328
+ this.runHeartbeat(cliManager);
329
+ }, this.heartbeatIntervalMs);
330
+
331
+ // Make sure interval doesn't prevent process exit
332
+ if (this.heartbeatTimer.unref) {
333
+ this.heartbeatTimer.unref();
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Run a single heartbeat iteration
339
+ */
340
+ async runHeartbeat(cliManager) {
341
+ try {
342
+ this.log('Running heartbeat...');
343
+
344
+ // Force fresh detection
345
+ const statuses = await cliManager.forceCliDetection();
346
+
347
+ // Report all statuses
348
+ await this.reportAllStatuses(statuses);
349
+
350
+ // Process any queued reports
351
+ await this.processQueue();
352
+
353
+ this.log('Heartbeat complete');
354
+ } catch (error) {
355
+ this.error('Heartbeat failed:', error.message);
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Stop the heartbeat system
361
+ */
362
+ stopHeartbeat() {
363
+ if (this.heartbeatTimer) {
364
+ this.log('Stopping heartbeat');
365
+ clearInterval(this.heartbeatTimer);
366
+ this.heartbeatTimer = null;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Queue a report for later retry
372
+ */
373
+ queueReport(report) {
374
+ this.pendingReports.push(report);
375
+ this.savePendingReports();
376
+ this.log(`Queued report for ${report.payload.provider} (queue size: ${this.pendingReports.length})`);
377
+ }
378
+
379
+ /**
380
+ * Process queued reports (for offline handling)
381
+ */
382
+ async processQueue() {
383
+ if (this.pendingReports.length === 0) return;
384
+
385
+ this.log(`Processing ${this.pendingReports.length} queued reports...`);
386
+
387
+ const queue = [...this.pendingReports];
388
+ this.pendingReports = [];
389
+
390
+ for (const item of queue) {
391
+ // Skip if too many retries
392
+ if (item.retries >= this.maxRetries) {
393
+ this.log(`Dropping report for ${item.payload.provider} after ${item.retries} retries`);
394
+ continue;
395
+ }
396
+
397
+ // Skip if report is too old (> 24 hours)
398
+ if (Date.now() - item.timestamp > 24 * 60 * 60 * 1000) {
399
+ this.log(`Dropping stale report for ${item.payload.provider}`);
400
+ continue;
401
+ }
402
+
403
+ try {
404
+ await this.sendReport(item.payload);
405
+ this.log(`Successfully sent queued report for ${item.payload.provider}`);
406
+ } catch (error) {
407
+ item.retries++;
408
+ this.pendingReports.push(item);
409
+ this.log(`Re-queued report for ${item.payload.provider} (retry ${item.retries})`);
410
+ }
411
+ }
412
+
413
+ this.savePendingReports();
414
+ }
415
+
416
+ /**
417
+ * Save pending reports to disk for persistence across sessions
418
+ */
419
+ savePendingReports() {
420
+ try {
421
+ fs.writeFileSync(this.queueFilePath, JSON.stringify(this.pendingReports, null, 2));
422
+ } catch (error) {
423
+ // Silently fail - not critical
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Load pending reports from disk
429
+ */
430
+ loadPendingReports() {
431
+ try {
432
+ if (fs.existsSync(this.queueFilePath)) {
433
+ const data = fs.readFileSync(this.queueFilePath, 'utf8');
434
+ this.pendingReports = JSON.parse(data);
435
+ this.log(`Loaded ${this.pendingReports.length} pending reports from disk`);
436
+ }
437
+ } catch (error) {
438
+ this.pendingReports = [];
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Add entry to history
444
+ */
445
+ addToHistory(entry) {
446
+ this.reportHistory.push(entry);
447
+ if (this.reportHistory.length > this.maxHistorySize) {
448
+ this.reportHistory.shift();
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Get report history
454
+ */
455
+ getHistory() {
456
+ return [...this.reportHistory];
457
+ }
458
+
459
+ /**
460
+ * Get current configuration
461
+ */
462
+ getConfig() {
463
+ return {
464
+ serverUrl: this.serverUrl,
465
+ reportingEnabled: this.reportingEnabled,
466
+ heartbeatIntervalMs: this.heartbeatIntervalMs,
467
+ maxRetries: this.maxRetries,
468
+ isConfigured: this.isConfigured(),
469
+ isOnline: this.isOnline,
470
+ pendingReportsCount: this.pendingReports.length,
471
+ heartbeatRunning: !!this.heartbeatTimer
472
+ };
473
+ }
474
+
475
+ /**
476
+ * Update configuration
477
+ */
478
+ configure(options) {
479
+ if (options.userToken !== undefined) {
480
+ this.userToken = options.userToken;
481
+ }
482
+ if (options.reportingEnabled !== undefined) {
483
+ this.reportingEnabled = options.reportingEnabled;
484
+ }
485
+ if (options.heartbeatIntervalMs !== undefined) {
486
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs;
487
+ }
488
+ if (options.debug !== undefined) {
489
+ this.debug = options.debug;
490
+ }
491
+
492
+ this.log('Configuration updated:', this.getConfig());
493
+ }
494
+
495
+ /**
496
+ * Report status change event (for immediate updates)
497
+ */
498
+ async reportStatusChange(provider, previousStatus, newStatus) {
499
+ // Skip if status hasn't actually changed
500
+ if (this.generateStatusKey(provider, previousStatus) === this.generateStatusKey(provider, newStatus)) {
501
+ return { success: true, reason: 'No actual change', skipped: true };
502
+ }
503
+
504
+ this.log(`Status change detected for ${provider}:`, {
505
+ from: this.mapStatusToServerFormat(previousStatus),
506
+ to: this.mapStatusToServerFormat(newStatus)
507
+ });
508
+
509
+ // Force report (bypass deduplication)
510
+ this.lastReportedStatus.delete(provider);
511
+
512
+ return this.reportStatus(provider, newStatus);
513
+ }
514
+ }
515
+
516
+ module.exports = { StatusReporter, default: StatusReporter };