@arela/uploader 0.2.13 → 1.0.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.
Files changed (43) hide show
  1. package/.env.template +66 -0
  2. package/README.md +263 -62
  3. package/docs/API_ENDPOINTS_FOR_DETECTION.md +647 -0
  4. package/docs/QUICK_REFERENCE_API_DETECTION.md +264 -0
  5. package/docs/REFACTORING_SUMMARY_DETECT_PEDIMENTOS.md +200 -0
  6. package/package.json +3 -2
  7. package/scripts/cleanup-ds-store.js +109 -0
  8. package/scripts/cleanup-system-files.js +69 -0
  9. package/scripts/tests/phase-7-features.test.js +415 -0
  10. package/scripts/tests/signal-handling.test.js +275 -0
  11. package/scripts/tests/smart-watch-integration.test.js +554 -0
  12. package/scripts/tests/watch-service-integration.test.js +584 -0
  13. package/src/commands/UploadCommand.js +31 -4
  14. package/src/commands/WatchCommand.js +1342 -0
  15. package/src/config/config.js +270 -2
  16. package/src/document-type-shared.js +2 -0
  17. package/src/document-types/support-document.js +200 -0
  18. package/src/file-detection.js +9 -1
  19. package/src/index.js +163 -4
  20. package/src/services/AdvancedFilterService.js +505 -0
  21. package/src/services/AutoProcessingService.js +749 -0
  22. package/src/services/BenchmarkingService.js +381 -0
  23. package/src/services/DatabaseService.js +1019 -539
  24. package/src/services/ErrorMonitor.js +275 -0
  25. package/src/services/LoggingService.js +419 -1
  26. package/src/services/MonitoringService.js +401 -0
  27. package/src/services/PerformanceOptimizer.js +511 -0
  28. package/src/services/ReportingService.js +511 -0
  29. package/src/services/SignalHandler.js +255 -0
  30. package/src/services/SmartWatchDatabaseService.js +527 -0
  31. package/src/services/WatchService.js +783 -0
  32. package/src/services/upload/ApiUploadService.js +447 -3
  33. package/src/services/upload/MultiApiUploadService.js +233 -0
  34. package/src/services/upload/SupabaseUploadService.js +12 -5
  35. package/src/services/upload/UploadServiceFactory.js +24 -0
  36. package/src/utils/CleanupManager.js +262 -0
  37. package/src/utils/FileOperations.js +44 -0
  38. package/src/utils/WatchEventHandler.js +522 -0
  39. package/supabase/migrations/001_create_initial_schema.sql +366 -0
  40. package/supabase/migrations/002_align_with_arela_api_schema.sql +145 -0
  41. package/.envbackup +0 -37
  42. package/SUPABASE_UPLOAD_FIX.md +0 -157
  43. package/commands.md +0 -14
@@ -0,0 +1,1342 @@
1
+ import cliProgress from 'cli-progress';
2
+ import { randomUUID } from 'crypto';
3
+ import FormData from 'form-data';
4
+ import fs from 'fs';
5
+ import mime from 'mime-types';
6
+ import path from 'path';
7
+
8
+ import databaseService from '../services/DatabaseService.js';
9
+ import logger from '../services/LoggingService.js';
10
+ import { createSignalHandler } from '../services/SignalHandler.js';
11
+ import watchService from '../services/WatchService.js';
12
+ import uploadServiceFactory from '../services/upload/UploadServiceFactory.js';
13
+
14
+ import appConfig from '../config/config.js';
15
+ import ErrorHandler from '../errors/ErrorHandler.js';
16
+ import { cleanupManager } from '../utils/CleanupManager.js';
17
+ import FileOperations from '../utils/FileOperations.js';
18
+ import fileSanitizer from '../utils/FileSanitizer.js';
19
+ import watchEventHandler from '../utils/WatchEventHandler.js';
20
+ import UploadCommand from './UploadCommand.js';
21
+
22
+ /**
23
+ * Watch Command Handler
24
+ * Monitors directories for changes and triggers uploads
25
+ */
26
+ export class WatchCommand {
27
+ constructor() {
28
+ this.errorHandler = new ErrorHandler(logger);
29
+ this.uploadCommand = new UploadCommand();
30
+ this.uploadService = null;
31
+ this.databaseService = databaseService;
32
+ this.isShuttingDown = false;
33
+
34
+ // Progress bars tracking
35
+ this.progressBars = null;
36
+ this.progressMetrics = {
37
+ individual: { start: null, processed: 0, total: 0 },
38
+ batch: { start: null, processed: 0, total: 0 },
39
+ 'full-structure': { start: null, processed: 0, total: 0 },
40
+ };
41
+
42
+ // Upload statistics tracking
43
+ this.uploadStats = {
44
+ totalUploads: 0,
45
+ totalFiles: 0,
46
+ successCount: 0,
47
+ failureCount: 0,
48
+ retryCount: 0,
49
+ lastUploadTime: null,
50
+ uploadDetails: [],
51
+ };
52
+
53
+ // Signal handling
54
+ this.signalHandler = createSignalHandler(logger, cleanupManager);
55
+ this.sessionId = null;
56
+ }
57
+
58
+ /**
59
+ * Execute the watch command
60
+ * @param {Object} options - Command options
61
+ * @returns {Promise<void>}
62
+ */
63
+ async execute(options) {
64
+ try {
65
+ // Validate configuration
66
+ this.#validateOptions(options); // TDOO: Looks like this function is empty
67
+
68
+ // Parse directories to watch
69
+ const directories = this.#parseDirectories(options.directories);
70
+
71
+ if (directories.length === 0) {
72
+ throw new Error(
73
+ 'No directories specified. Use --directories or WATCH_DIRECTORIES env var',
74
+ );
75
+ }
76
+
77
+ // Validate watch configuration
78
+ try {
79
+ appConfig.validateWatchConfig(directories);
80
+ } catch (error) {
81
+ logger.error(`Configuration error: ${error.message}`);
82
+ throw error;
83
+ }
84
+
85
+ // Parse watch options
86
+ const watchOptions = this.#parseWatchOptions(options);
87
+
88
+ // Clear log if requested
89
+ if (options.clearLog) {
90
+ logger.clearLogFile();
91
+ logger.info('Log file cleared');
92
+ }
93
+
94
+ // Initialize upload service
95
+ // In cross-tenant mode, use the sourceApi for initial connection check
96
+ // In single API mode, use the specified API target or default
97
+ try {
98
+ if (appConfig.isCrossTenantMode()) {
99
+ // Cross-tenant mode: use sourceApi for the upload service
100
+ this.uploadService =
101
+ await uploadServiceFactory.getApiServiceForTarget(
102
+ appConfig.api.sourceTarget,
103
+ );
104
+ logger.info(
105
+ `Upload service initialized for cross-tenant mode (source: ${appConfig.api.sourceTarget})`,
106
+ );
107
+ } else if (
108
+ appConfig.api.activeTarget &&
109
+ appConfig.api.activeTarget !== 'default'
110
+ ) {
111
+ // Single API mode with specific target
112
+ this.uploadService =
113
+ await uploadServiceFactory.getApiServiceForTarget(
114
+ appConfig.api.activeTarget,
115
+ );
116
+ logger.info(
117
+ `Upload service initialized for API target: ${appConfig.api.activeTarget}`,
118
+ );
119
+ } else {
120
+ // Default mode
121
+ this.uploadService = await uploadServiceFactory.getUploadService(
122
+ options.forceSupabase,
123
+ );
124
+ logger.info(
125
+ `Upload service initialized: ${this.uploadService.getServiceName()}`,
126
+ );
127
+ }
128
+ } catch (error) {
129
+ logger.error(`Failed to initialize upload service: ${error.message}`);
130
+ throw error;
131
+ }
132
+
133
+ // Reset upload statistics
134
+ this.uploadStats = {
135
+ totalUploads: 0,
136
+ totalFiles: 0,
137
+ successCount: 0,
138
+ failureCount: 0,
139
+ retryCount: 0,
140
+ lastUploadTime: null,
141
+ uploadDetails: [],
142
+ };
143
+
144
+ // Initialize session tracking
145
+ const sessionId = randomUUID();
146
+ logger.initializeSession(sessionId);
147
+
148
+ // Initialize progress bars
149
+ this.#initializeProgressBars();
150
+
151
+ // Log startup
152
+ logger.info('═══════════════════════════════════════════════════════');
153
+ logger.info('🟢 WATCH MODE STARTED');
154
+ logger.info('═══════════════════════════════════════════════════════');
155
+ logger.info(`📁 Watching ${directories.length} director(y/ies):`);
156
+ directories.forEach((dirConfig) => {
157
+ const dirPath = dirConfig.path || dirConfig;
158
+ const folderStructure = dirConfig.folderStructure || 'default';
159
+ logger.info(
160
+ ` → ${path.resolve(dirPath)} [structure: ${folderStructure}]`,
161
+ );
162
+ });
163
+ logger.info(`🎯 Strategy: ${watchOptions.strategy}`);
164
+ logger.info(`⏱️ Debounce: ${watchOptions.debounceMs}ms`);
165
+ logger.info(`📦 Batch size: ${watchOptions.batchSize}`);
166
+ logger.info('═══════════════════════════════════════════════════════\n');
167
+
168
+ // Initialize watchers with directory configurations
169
+ for (const dirConfig of directories) {
170
+ const dirPath = dirConfig.path || dirConfig;
171
+ await watchService.addWatcher(
172
+ dirPath,
173
+ {
174
+ usePolling: watchOptions.usePolling,
175
+ interval: watchOptions.interval,
176
+ stabilityThreshold: watchOptions.stabilityThreshold,
177
+ ignored: this.#parseIgnorePatterns(options.ignore),
178
+ },
179
+ dirConfig,
180
+ );
181
+ }
182
+
183
+ // Enable auto-processing if configured
184
+ if (options.autoProcessing !== false) {
185
+ watchService.enableAutoProcessing({
186
+ batchSize: watchOptions.batchSize,
187
+ });
188
+ logger.info('🔄 Auto-processing pipeline enabled');
189
+ }
190
+
191
+ // Set event handler with directory configurations
192
+ watchService.setEventHandler(async (eventType, filePath) => {
193
+ await this.#handleFileEvent(
194
+ eventType,
195
+ filePath,
196
+ options,
197
+ watchOptions,
198
+ directories,
199
+ );
200
+ });
201
+
202
+ // Start watching
203
+ await watchService.start();
204
+
205
+ // Wait for all watchers to complete initial scan before processing
206
+ await watchService.waitForWatchersReady();
207
+
208
+ // Setup signal handlers for graceful shutdown
209
+ this.#setupSignalHandlers();
210
+
211
+ // Keep process alive
212
+ logger.info('💡 Press Ctrl+C to stop watching\n');
213
+ await this.#keepProcessAlive();
214
+ } catch (error) {
215
+ this.errorHandler.handleFatalError(error, { command: 'watch' });
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Handle file events
221
+ * @private
222
+ * @param {string} eventType - Type of event
223
+ * @param {string} filePath - Path to file
224
+ * @param {Object} options - Command options
225
+ * @param {Object} watchOptions - Parsed watch options
226
+ * @param {Array<Object>} directories - Array of directory configurations
227
+ * @returns {Promise<void>}
228
+ */
229
+ async #handleFileEvent(
230
+ eventType,
231
+ filePath,
232
+ options,
233
+ watchOptions,
234
+ directories = [],
235
+ ) {
236
+ try {
237
+ // Skip if dry-run
238
+ if (options.dryRun) {
239
+ logger.info(`[DRY RUN] Would upload: ${filePath}`);
240
+ return;
241
+ }
242
+
243
+ // Skip remove events
244
+ if (eventType === 'remove' || eventType === 'unlink') {
245
+ logger.debug(`Skipping file removal event: ${filePath}`);
246
+ return;
247
+ }
248
+
249
+ // Skip if auto-processing is enabled (it handles file processing)
250
+ if (options.autoProcessing !== false) {
251
+ logger.debug(
252
+ `[AutoProcessing] File event skipped (auto-processing is handling it): ${filePath}`,
253
+ );
254
+ return;
255
+ }
256
+
257
+ // Find which directory this file belongs to
258
+ const watchDir = this.#findWatchDirectory(filePath, directories);
259
+ const folderStructure = watchDir?.folderStructure || 'default';
260
+
261
+ // Execute upload based on strategy
262
+ switch (watchOptions.strategy) {
263
+ case 'individual':
264
+ try {
265
+ await this.#uploadIndividual(filePath, options);
266
+ } catch (strategyError) {
267
+ logger.error(
268
+ `Error in individual upload: ${strategyError.message}`,
269
+ );
270
+ throw strategyError;
271
+ }
272
+ break;
273
+ case 'batch':
274
+ try {
275
+ await this.#uploadBatch(
276
+ filePath,
277
+ options,
278
+ watchOptions,
279
+ directories,
280
+ );
281
+ } catch (strategyError) {
282
+ logger.error(`Error in batch upload: ${strategyError.message}`);
283
+ throw strategyError;
284
+ }
285
+ break;
286
+ case 'full-structure':
287
+ try {
288
+ await this.#uploadFullStructure(
289
+ filePath,
290
+ options,
291
+ watchOptions,
292
+ directories,
293
+ );
294
+ } catch (strategyError) {
295
+ logger.error(
296
+ `Error in full-structure upload: ${strategyError.message}`,
297
+ );
298
+ throw strategyError;
299
+ }
300
+ break;
301
+ default:
302
+ logger.warn(`Unknown strategy: ${watchOptions.strategy}`);
303
+ }
304
+
305
+ // Increment upload counter
306
+ const stats = watchService.getStats();
307
+ logger.info(
308
+ `📊 Stats: +${stats.filesAdded} files, +${stats.filesModified} modified`,
309
+ );
310
+ } catch (error) {
311
+ logger.error(`Error handling file event: ${error.message}`);
312
+ logger.error(`Stack: ${error.stack}`);
313
+ // Log the full error for debugging
314
+ if (error.code) {
315
+ logger.debug(`Error code: ${error.code}`);
316
+ }
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Find which watch directory a file belongs to
322
+ * @private
323
+ * @param {string} filePath - Path to file
324
+ * @param {Array<Object>} directories - Array of directory configurations
325
+ * @returns {Object|null} Directory configuration or null
326
+ */
327
+ #findWatchDirectory(filePath, directories) {
328
+ const normalizedFilePath = path.resolve(filePath);
329
+
330
+ for (const dirConfig of directories) {
331
+ const dirPath = dirConfig.path || dirConfig;
332
+ const normalizedDirPath = path.resolve(dirPath);
333
+
334
+ if (normalizedFilePath.startsWith(normalizedDirPath)) {
335
+ return dirConfig;
336
+ }
337
+ }
338
+
339
+ return null;
340
+ }
341
+
342
+ /**
343
+ * Retry upload with exponential backoff
344
+ * @private
345
+ * @param {Function} uploadFn - Function that performs the upload
346
+ * @param {number} maxRetries - Maximum number of retries
347
+ * @param {number} initialBackoffMs - Initial backoff in milliseconds
348
+ * @returns {Promise<any>} Upload result
349
+ * @throws {Error} If all retries fail
350
+ */
351
+ async #retryUpload(uploadFn, maxRetries = 3, initialBackoffMs = 1000) {
352
+ let lastError;
353
+
354
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
355
+ try {
356
+ return await uploadFn();
357
+ } catch (error) {
358
+ lastError = error;
359
+
360
+ if (attempt === maxRetries) {
361
+ throw error;
362
+ }
363
+
364
+ const backoffMs = initialBackoffMs * Math.pow(2, attempt - 1);
365
+ logger.warn(
366
+ `Upload failed (attempt ${attempt}/${maxRetries}), retrying in ${backoffMs}ms: ${error.message}`,
367
+ );
368
+
369
+ this.uploadStats.retryCount++;
370
+
371
+ // Wait before retrying
372
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
373
+ }
374
+ }
375
+
376
+ throw lastError;
377
+ }
378
+
379
+ /**
380
+ * Prepare a file for upload with metadata
381
+ * @private
382
+ * @param {string} filePath - Absolute path to file
383
+ * @param {Object} options - Command options
384
+ * @returns {Object} File object for upload
385
+ */
386
+ #prepareFileForUpload(filePath, options) {
387
+ const stats = FileOperations.getFileStats(filePath);
388
+
389
+ return {
390
+ path: filePath,
391
+ name: path.basename(filePath),
392
+ relativePath: path.basename(filePath),
393
+ contentType: mime.lookup(filePath) || 'application/octet-stream',
394
+ size: stats?.size || 0,
395
+ metadata: {
396
+ source: 'watch-mode',
397
+ timestamp: new Date().toISOString(),
398
+ watchSource: path.dirname(filePath),
399
+ },
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Upload individual file
405
+ * @private
406
+ * @param {string} filePath - File path to upload
407
+ * @param {Object} options - Command options
408
+ * @returns {Promise<void>}
409
+ */
410
+ async #uploadIndividual(filePath, options) {
411
+ const startTime = Date.now();
412
+ try {
413
+ logger.info(`📤 [INDIVIDUAL] Uploading: ${filePath}`);
414
+
415
+ // Get file object from event handler
416
+ const files = await watchEventHandler.getFilesForUpload('individual', {
417
+ sourceDir: path.dirname(filePath),
418
+ });
419
+
420
+ if (files.length === 0) {
421
+ logger.warn(`No valid files to upload for: ${filePath}`);
422
+ return;
423
+ }
424
+
425
+ const file = files[0];
426
+ logger.info(` File: ${file.name} (${this.#formatFileSize(file.size)})`);
427
+
428
+ // Validate file exists and is accessible
429
+ if (!FileOperations.fileExists(file.path)) {
430
+ throw new Error(`File not accessible: ${file.path}`);
431
+ }
432
+
433
+ // ✅ CRITICAL: Validate file has been evaluated by watcher
434
+ const validatedFiles = this.#validateFilesAreWatched([file]);
435
+ if (validatedFiles.length === 0) {
436
+ logger.warn(
437
+ `⚠️ File was not properly evaluated by watcher: ${file.name}`,
438
+ );
439
+ this.uploadStats.failureCount++;
440
+ this.uploadStats.uploadDetails.push({
441
+ timestamp: new Date().toISOString(),
442
+ fileName: file.name,
443
+ strategy: 'individual',
444
+ status: 'rejected',
445
+ reason: 'Not evaluated by watcher',
446
+ duration: Date.now() - startTime,
447
+ });
448
+ return;
449
+ }
450
+
451
+ // Prepare file for upload with metadata
452
+ const fileForUpload = this.#prepareFileForUpload(file.path, options);
453
+
454
+ // Create upload with retries
455
+ const result = await this.#retryUpload(
456
+ async () => {
457
+ const uploadResult = await this.uploadService.upload(
458
+ [fileForUpload],
459
+ {
460
+ strategy: 'individual',
461
+ sourceType: 'watch-mode',
462
+ },
463
+ );
464
+ return uploadResult;
465
+ },
466
+ 3,
467
+ 1000,
468
+ );
469
+
470
+ // Update statistics
471
+ this.uploadStats.totalUploads++;
472
+ this.uploadStats.totalFiles++;
473
+ this.uploadStats.successCount++;
474
+ this.uploadStats.lastUploadTime = new Date();
475
+
476
+ // Record upload details
477
+ this.uploadStats.uploadDetails.push({
478
+ timestamp: new Date().toISOString(),
479
+ fileName: file.name,
480
+ size: file.size,
481
+ strategy: 'individual',
482
+ status: 'success',
483
+ duration: Date.now() - startTime,
484
+ });
485
+
486
+ logger.info(`✅ Upload successful: ${file.name}`);
487
+ watchService.stats.uploadsTriggered++;
488
+ } catch (error) {
489
+ this.uploadStats.failureCount++;
490
+ logger.error(`❌ Error uploading individual file: ${error.message}`);
491
+
492
+ // Record failed upload
493
+ this.uploadStats.uploadDetails.push({
494
+ timestamp: new Date().toISOString(),
495
+ fileName: path.basename(filePath),
496
+ strategy: 'individual',
497
+ status: 'failed',
498
+ error: error.message,
499
+ duration: Date.now() - startTime,
500
+ });
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Upload batch of files
506
+ * @private
507
+ * @param {string} filePath - Triggering file path
508
+ * @param {Object} options - Command options
509
+ * @param {Object} watchOptions - Watch options
510
+ * @returns {Promise<void>}
511
+ */
512
+ /**
513
+ * Upload batch of files
514
+ * @private
515
+ * @param {string} filePath - Triggering file path
516
+ * @param {Object} options - Command options
517
+ * @param {Object} watchOptions - Watch options
518
+ * @param {Array<Object>} directories - Array of directory configurations
519
+ * @returns {Promise<void>}
520
+ */
521
+ async #uploadBatch(filePath, options, watchOptions, directories = []) {
522
+ const startTime = Date.now();
523
+ try {
524
+ logger.info(`📤 [BATCH] Upload triggered by: ${filePath}`);
525
+
526
+ // Find the watch directory for this file
527
+ const watchDirConfig = this.#findWatchDirectory(filePath, directories);
528
+ const folderStructure = watchDirConfig?.folderStructure || 'default';
529
+
530
+ // Get batch of files from event handler
531
+ const files = await watchEventHandler.getFilesForUpload('batch', {
532
+ batchSize: watchOptions.batchSize,
533
+ sourceDir: path.dirname(filePath),
534
+ });
535
+
536
+ if (files.length === 0) {
537
+ logger.warn('No valid files found for batch upload');
538
+ return;
539
+ }
540
+
541
+ // Log batch details
542
+ logger.info(` Batch size: ${files.length} files`);
543
+ logger.info(` Folder structure: ${folderStructure}`);
544
+ let totalSize = 0;
545
+ files.forEach((file) => {
546
+ totalSize += file.size;
547
+ logger.debug(` - ${file.name} (${this.#formatFileSize(file.size)})`);
548
+ });
549
+
550
+ logger.info(` Total size: ${this.#formatFileSize(totalSize)}`);
551
+
552
+ // Validate all files exist
553
+ const invalidFiles = files.filter(
554
+ (f) => !FileOperations.fileExists(f.path),
555
+ );
556
+ if (invalidFiles.length > 0) {
557
+ throw new Error(`${invalidFiles.length} file(s) not accessible`);
558
+ }
559
+
560
+ // ✅ CRITICAL: Validate files have been evaluated by watcher
561
+ const validatedFiles = this.#validateFilesAreWatched(files);
562
+ if (validatedFiles.length === 0) {
563
+ logger.warn(
564
+ `⚠️ None of the ${files.length} files were properly evaluated by watcher`,
565
+ );
566
+ this.uploadStats.failureCount += files.length;
567
+ this.uploadStats.uploadDetails.push({
568
+ timestamp: new Date().toISOString(),
569
+ fileCount: files.length,
570
+ strategy: 'batch',
571
+ status: 'rejected',
572
+ reason: 'No files evaluated by watcher',
573
+ duration: Date.now() - startTime,
574
+ });
575
+ return;
576
+ }
577
+
578
+ if (validatedFiles.length < files.length) {
579
+ logger.warn(
580
+ `⚠️ Only ${validatedFiles.length}/${files.length} files were properly evaluated by watcher. Uploading only validated files.`,
581
+ );
582
+ }
583
+
584
+ // Prepare files for upload
585
+ const filesForUpload = validatedFiles.map((f) =>
586
+ this.#prepareFileForUpload(f.path, options),
587
+ );
588
+
589
+ // Create upload with retries
590
+ const result = await this.#retryUpload(
591
+ async () => {
592
+ const uploadResult = await this.uploadService.upload(filesForUpload, {
593
+ strategy: 'batch',
594
+ batchSize: watchOptions.batchSize,
595
+ sourceType: 'watch-mode',
596
+ });
597
+ return uploadResult;
598
+ },
599
+ 3,
600
+ 1000,
601
+ );
602
+
603
+ // Update statistics
604
+ this.uploadStats.totalUploads++;
605
+ this.uploadStats.totalFiles += files.length;
606
+ this.uploadStats.successCount += files.length;
607
+ this.uploadStats.lastUploadTime = new Date();
608
+
609
+ // Record upload details
610
+ this.uploadStats.uploadDetails.push({
611
+ timestamp: new Date().toISOString(),
612
+ fileCount: files.length,
613
+ strategy: 'batch',
614
+ folderStructure,
615
+ status: 'success',
616
+ duration: Date.now() - startTime,
617
+ });
618
+
619
+ logger.info(`✅ Batch upload successful: ${files.length} files`);
620
+ watchService.stats.uploadsTriggered++;
621
+
622
+ // Clear processed events
623
+ watchEventHandler.clearProcessed();
624
+ } catch (error) {
625
+ this.uploadStats.failureCount += files?.length || 1;
626
+ logger.error(`❌ Error uploading batch: ${error.message}`);
627
+
628
+ // Record failed upload
629
+ this.uploadStats.uploadDetails.push({
630
+ timestamp: new Date().toISOString(),
631
+ strategy: 'batch',
632
+ status: 'failed',
633
+ error: error.message,
634
+ duration: Date.now() - startTime,
635
+ });
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Upload full directory structure
641
+ * @private
642
+ * @param {string} filePath - Triggering file path
643
+ * @param {Object} options - Command options
644
+ * @param {Object} watchOptions - Watch options
645
+ * @param {Array<Object>} directories - Array of directory configurations
646
+ * @returns {Promise<void>}
647
+ */
648
+ async #uploadFullStructure(
649
+ filePath,
650
+ options,
651
+ watchOptions,
652
+ directories = [],
653
+ ) {
654
+ const startTime = Date.now();
655
+ try {
656
+ logger.info(`📤 [FULL-STRUCTURE] Upload triggered by: ${filePath}`);
657
+
658
+ // Find the watch directory for this file
659
+ const watchDirConfig = this.#findWatchDirectory(filePath, directories);
660
+ const folderStructure = watchDirConfig?.folderStructure || 'default';
661
+
662
+ // Get full structure from event handler
663
+ const files = await watchEventHandler.getFilesForUpload(
664
+ 'full-structure',
665
+ {
666
+ sourceDir: path.dirname(filePath),
667
+ },
668
+ );
669
+
670
+ if (files.length === 0) {
671
+ logger.warn('No valid files found for full structure upload');
672
+ return;
673
+ }
674
+
675
+ // Log structure details
676
+ logger.info(` Structure size: ${files.length} files`);
677
+ logger.info(` Folder structure: ${folderStructure}`);
678
+ let totalSize = 0;
679
+ const directoriesInStructure = new Set();
680
+
681
+ files.forEach((file) => {
682
+ totalSize += file.size;
683
+ const dir = path.dirname(file.path);
684
+ directoriesInStructure.add(dir);
685
+ });
686
+
687
+ logger.info(` Directories: ${directoriesInStructure.size}`);
688
+ logger.info(` Total size: ${this.#formatFileSize(totalSize)}`);
689
+
690
+ // Validate all files exist
691
+ const invalidFiles = files.filter(
692
+ (f) => !FileOperations.fileExists(f.path),
693
+ );
694
+ if (invalidFiles.length > 0) {
695
+ throw new Error(
696
+ `${invalidFiles.length} file(s) not accessible in structure`,
697
+ );
698
+ }
699
+
700
+ // ✅ CRITICAL: Validate files have been evaluated by watcher
701
+ const validatedFiles = this.#validateFilesAreWatched(files);
702
+ if (validatedFiles.length === 0) {
703
+ logger.warn(
704
+ `⚠️ None of the ${files.length} files were properly evaluated by watcher`,
705
+ );
706
+ this.uploadStats.failureCount += files.length;
707
+ this.uploadStats.uploadDetails.push({
708
+ timestamp: new Date().toISOString(),
709
+ fileCount: files.length,
710
+ strategy: 'full-structure',
711
+ status: 'rejected',
712
+ reason: 'No files evaluated by watcher',
713
+ duration: Date.now() - startTime,
714
+ });
715
+ return;
716
+ }
717
+
718
+ if (validatedFiles.length < files.length) {
719
+ logger.warn(
720
+ `⚠️ Only ${validatedFiles.length}/${files.length} files were properly evaluated by watcher. Uploading only validated files.`,
721
+ );
722
+ }
723
+
724
+ // Prepare files for upload
725
+ const filesForUpload = validatedFiles.map((f) =>
726
+ this.#prepareFileForUpload(f.path, options),
727
+ );
728
+
729
+ // Create upload with retries
730
+ const result = await this.#retryUpload(
731
+ async () => {
732
+ const uploadResult = await this.uploadService.upload(filesForUpload, {
733
+ strategy: 'full-structure',
734
+ sourceType: 'watch-mode',
735
+ metadata: {
736
+ totalFiles: files.length,
737
+ totalDirectories: directoriesInStructure.size,
738
+ totalSize: totalSize,
739
+ folderStructure: folderStructure,
740
+ },
741
+ });
742
+ return uploadResult;
743
+ },
744
+ 3,
745
+ 1000,
746
+ );
747
+
748
+ // Update statistics
749
+ this.uploadStats.totalUploads++;
750
+ this.uploadStats.totalFiles += files.length;
751
+ this.uploadStats.successCount += files.length;
752
+ this.uploadStats.lastUploadTime = new Date();
753
+
754
+ // Record upload details
755
+ this.uploadStats.uploadDetails.push({
756
+ timestamp: new Date().toISOString(),
757
+ fileCount: files.length,
758
+ dirCount: directories.size,
759
+ strategy: 'full-structure',
760
+ status: 'success',
761
+ duration: Date.now() - startTime,
762
+ });
763
+
764
+ logger.info(
765
+ `✅ Full structure upload successful: ${files.length} files in ${directories.size} directories`,
766
+ );
767
+ watchService.stats.uploadsTriggered++;
768
+
769
+ // Clear processed events
770
+ watchEventHandler.clearProcessed();
771
+ } catch (error) {
772
+ this.uploadStats.failureCount += files?.length || 1;
773
+ logger.error(`❌ Error uploading full structure: ${error.message}`);
774
+
775
+ // Record failed upload
776
+ this.uploadStats.uploadDetails.push({
777
+ timestamp: new Date().toISOString(),
778
+ strategy: 'full-structure',
779
+ status: 'failed',
780
+ error: error.message,
781
+ duration: Date.now() - startTime,
782
+ });
783
+ }
784
+ }
785
+
786
+ /**
787
+ * Validate that files have been properly evaluated by the watcher
788
+ * CRITICAL: Ensures NO files are uploaded without watcher evaluation
789
+ * @private
790
+ * @param {Array<Object>} files - Files to validate
791
+ * @returns {Array<Object>} Only files that have been evaluated by watcher
792
+ */
793
+ #validateFilesAreWatched(files) {
794
+ if (!files || files.length === 0) {
795
+ return [];
796
+ }
797
+
798
+ // Get watched directories from WatchService
799
+ const watchedDirs = watchService.getWatchedDirs();
800
+
801
+ if (!watchedDirs || watchedDirs.length === 0) {
802
+ logger.warn(
803
+ '⚠️ No directories are being watched. Cannot validate files.',
804
+ );
805
+ // ALLOW all files if no watch dirs configured (fail-safe)
806
+ return files;
807
+ }
808
+
809
+ // Normalize all watched directories for comparison
810
+ const normalizedWatchedDirs = watchedDirs.map((dir) => path.resolve(dir));
811
+
812
+ // Filter files that are within watched directories
813
+ const validatedFiles = files.filter((file) => {
814
+ const normalizedFilePath = path.resolve(file.path);
815
+
816
+ const isWatched = normalizedWatchedDirs.some((watchDir) => {
817
+ return (
818
+ normalizedFilePath.startsWith(watchDir + path.sep) ||
819
+ normalizedFilePath.startsWith(watchDir)
820
+ );
821
+ });
822
+
823
+ if (!isWatched) {
824
+ logger.debug(`📍 File not in watched directories: ${file.path}`);
825
+ }
826
+
827
+ return isWatched;
828
+ });
829
+
830
+ // Log validation results
831
+ if (validatedFiles.length < files.length) {
832
+ logger.warn(
833
+ `⚠️ Only ${validatedFiles.length}/${files.length} files passed watcher validation`,
834
+ );
835
+ }
836
+
837
+ return validatedFiles;
838
+ }
839
+
840
+ /**
841
+ * Validate command options
842
+ * @private
843
+ * @param {Object} options - Options to validate
844
+ * @throws {Error} If validation fails
845
+ */
846
+ #validateOptions(options) {
847
+ // Options are validated during parsing
848
+ }
849
+
850
+ /**
851
+ * Format file size for display
852
+ * @private
853
+ * @param {number} bytes - Number of bytes
854
+ * @returns {string} Formatted file size
855
+ */
856
+ #formatFileSize(bytes) {
857
+ if (bytes === 0) return '0 B';
858
+ const k = 1024;
859
+ const sizes = ['B', 'KB', 'MB', 'GB'];
860
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
861
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
862
+ }
863
+
864
+ /**
865
+ * Parse directories from string or array
866
+ * @private
867
+ * @param {string|Array} directoriesStr - Directories string or array
868
+ * @returns {Array<Object>} Parsed directory configurations
869
+ */
870
+ #parseDirectories(directoriesStr) {
871
+ try {
872
+ const config = appConfig.getWatchConfig();
873
+ let directoriesArray = [];
874
+
875
+ // Priority 1: CLI option
876
+ if (directoriesStr) {
877
+ directoriesArray = directoriesStr
878
+ .split(',')
879
+ .map((dir) => dir.trim())
880
+ .filter((dir) => dir.length > 0)
881
+ .map((dir) => ({
882
+ path: dir,
883
+ folderStructure: 'default',
884
+ }));
885
+
886
+ return directoriesArray;
887
+ }
888
+
889
+ // Priority 2: Environment config with JSON format
890
+ if (
891
+ config.directoryConfigs &&
892
+ Object.keys(config.directoryConfigs).length > 0
893
+ ) {
894
+ directoriesArray = Object.entries(config.directoryConfigs).map(
895
+ ([dirPath, folderStructure]) => ({
896
+ path: dirPath,
897
+ folderStructure: folderStructure || 'default',
898
+ }),
899
+ );
900
+
901
+ return directoriesArray;
902
+ }
903
+
904
+ // Priority 3: Environment config with legacy format
905
+ if (config.directories && config.directories.length > 0) {
906
+ directoriesArray = config.directories.map((dir) => ({
907
+ path: dir,
908
+ folderStructure: 'default',
909
+ }));
910
+
911
+ return directoriesArray;
912
+ }
913
+
914
+ return [];
915
+ } catch (error) {
916
+ logger.debug(`Error parsing directories: ${error.message}`);
917
+ return [];
918
+ }
919
+ }
920
+
921
+ /**
922
+ * Parse watch-specific options
923
+ * @private
924
+ * @param {Object} options - Command options
925
+ * @returns {Object} Parsed watch options
926
+ */
927
+ #parseWatchOptions(options) {
928
+ const config = appConfig.getWatchConfig();
929
+
930
+ return {
931
+ strategy: options.strategy || config.strategy || 'batch',
932
+ debounceMs: parseInt(options.debounce) || config.debounceMs || 1000,
933
+ batchSize: parseInt(options.batchSize) || config.batchSize || 10,
934
+ usePolling:
935
+ options.poll !== undefined ? true : config.usePolling || false,
936
+ interval: parseInt(options.poll) || config.pollInterval || 100,
937
+ stabilityThreshold: config.stabilityThreshold || 300,
938
+ autoDetect: options.autoDetect || config.autoDetect || false,
939
+ autoOrganize: options.autoOrganize || config.autoOrganize || false,
940
+ };
941
+ }
942
+
943
+ /**
944
+ * Parse ignore patterns from CLI
945
+ * @private
946
+ * @param {string|undefined} ignoreStr - Comma-separated patterns
947
+ * @returns {Array<string>} Ignore patterns
948
+ */
949
+ #parseIgnorePatterns(ignoreStr) {
950
+ const defaultPatterns = [
951
+ '/(^|[\\/\\\\])\\.|node_modules|\\.git/', // Hidden files, node_modules, .git
952
+ ];
953
+
954
+ if (!ignoreStr && !process.env.WATCH_IGNORE_PATTERNS) {
955
+ return defaultPatterns;
956
+ }
957
+
958
+ const patterns = (ignoreStr || process.env.WATCH_IGNORE_PATTERNS || '')
959
+ .split(',')
960
+ .map((p) => p.trim())
961
+ .filter((p) => p.length > 0);
962
+
963
+ return [...defaultPatterns, ...patterns];
964
+ }
965
+
966
+ /**
967
+ * Setup signal handlers for graceful shutdown
968
+ * @private
969
+ */
970
+ #setupSignalHandlers() {
971
+ // Register database cleanup
972
+ cleanupManager.registerResource('Database', databaseService, async () => {
973
+ return await databaseService.cleanup();
974
+ });
975
+
976
+ // Register watch service cleanup
977
+ cleanupManager.registerResource('WatchService', watchService, async () => {
978
+ return await watchService.stop('signal-received');
979
+ });
980
+
981
+ // Register logging flush
982
+ cleanupManager.registerResource('Logging', logger, () => {
983
+ logger.flush();
984
+ });
985
+
986
+ // Register progress bars cleanup
987
+ cleanupManager.registerResource('ProgressBars', this.progressBars, () => {
988
+ this.#stopProgressBars();
989
+ });
990
+
991
+ // Create signal handlers
992
+ const signalHandlers = {
993
+ SIGINT: async () => {
994
+ await this.#onShutdown('SIGINT');
995
+ },
996
+ SIGTERM: async () => {
997
+ await this.#onShutdown('SIGTERM');
998
+ },
999
+ SIGHUP: async () => {
1000
+ await this.#onShutdown('SIGHUP');
1001
+ },
1002
+ SIGQUIT: async () => {
1003
+ await this.#onShutdown('SIGQUIT');
1004
+ },
1005
+ };
1006
+
1007
+ // Register with signal handler
1008
+ this.signalHandler.registerSignalHandlers(signalHandlers);
1009
+
1010
+ logger.debug('WatchCommand: Signal handlers configured');
1011
+ }
1012
+
1013
+ /**
1014
+ * Handle graceful shutdown triggered by signal
1015
+ * @private
1016
+ * @param {string} signal - Signal name that triggered shutdown
1017
+ * @returns {Promise<void>}
1018
+ */
1019
+ async #onShutdown(signal) {
1020
+ try {
1021
+ // Print final statistics
1022
+ const stats = watchService.getStats();
1023
+ logger.info('\n═══════════════════════════════════════════════════════');
1024
+ logger.info('📊 WATCH SESSION SUMMARY');
1025
+ logger.info('═══════════════════════════════════════════════════════');
1026
+ logger.info(`📄 Files added: ${stats.filesAdded}`);
1027
+ logger.info(`✏️ Files modified: ${stats.filesModified}`);
1028
+ logger.info(`🗑️ Files removed: ${stats.filesRemoved}`);
1029
+ logger.info(`📤 Uploads triggered: ${stats.uploadsTriggered}`);
1030
+ logger.info(`⚠️ Errors encountered: ${stats.errorsEncountered}`);
1031
+ logger.info('═══════════════════════════════════════════════════════\n');
1032
+
1033
+ // Generate and display final session report
1034
+ await this.#generateAndDisplaySessionReport(this.sessionId);
1035
+ } catch (error) {
1036
+ logger.error(`Error during shutdown handling: ${error.message}`);
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * Keep process alive indefinitely
1042
+ * @private
1043
+ * @returns {Promise<void>}
1044
+ */
1045
+ #keepProcessAlive() {
1046
+ return new Promise(() => {
1047
+ // Never resolves - process will run until signal is received
1048
+ });
1049
+ }
1050
+
1051
+ /**
1052
+ * Initialize progress bars for upload tracking
1053
+ * @private
1054
+ * @returns {void}
1055
+ */
1056
+ #initializeProgressBars() {
1057
+ this.progressBars = new cliProgress.MultiBar({
1058
+ clearOnComplete: false,
1059
+ hideCursor: true,
1060
+ fps: 5,
1061
+ stopOnComplete: false,
1062
+ format:
1063
+ '{name} | {bar} | {percentage}% | {value}/{total} | ⏱️ ETA: {eta_formatted} | 🚀 {speed}',
1064
+ barCompleteChar: '█',
1065
+ barIncompleteChar: '░',
1066
+ hideCursor: true,
1067
+ formatBar: (processed, total, width) => {
1068
+ const ratio = processed / total;
1069
+ const filled = Math.round(ratio * width);
1070
+ const empty = width - filled;
1071
+ return '█'.repeat(filled) + '░'.repeat(empty);
1072
+ },
1073
+ });
1074
+
1075
+ // Create bars for each strategy
1076
+ this.progressBars.individual = this.progressBars.create(0, 0, {
1077
+ name: '📁 Individual',
1078
+ speed: '0 files/min',
1079
+ });
1080
+
1081
+ this.progressBars.batch = this.progressBars.create(0, 0, {
1082
+ name: '📦 Batch ',
1083
+ speed: '0 files/min',
1084
+ });
1085
+
1086
+ this.progressBars['full-structure'] = this.progressBars.create(0, 0, {
1087
+ name: '🗂️ Full-Str ',
1088
+ speed: '0 files/min',
1089
+ });
1090
+
1091
+ // Initialize metrics
1092
+ this.progressMetrics = {
1093
+ individual: { start: null, processed: 0, total: 0 },
1094
+ batch: { start: null, processed: 0, total: 0 },
1095
+ 'full-structure': { start: null, processed: 0, total: 0 },
1096
+ };
1097
+ }
1098
+
1099
+ /**
1100
+ * Update progress bar for a specific strategy
1101
+ * @private
1102
+ * @param {string} strategy - Upload strategy name
1103
+ * @param {number} processed - Number of processed files
1104
+ * @param {number} total - Total number of files
1105
+ * @returns {void}
1106
+ */
1107
+ #updateProgressBar(strategy, processed, total) {
1108
+ if (!this.progressBars || !this.progressBars[strategy]) {
1109
+ return;
1110
+ }
1111
+
1112
+ const metrics = this.progressMetrics[strategy];
1113
+
1114
+ // Initialize start time if not already done
1115
+ if (metrics.start === null) {
1116
+ metrics.start = Date.now();
1117
+ }
1118
+
1119
+ metrics.processed = processed;
1120
+ metrics.total = total;
1121
+
1122
+ // Calculate speed (files per minute)
1123
+ const elapsedMs = Date.now() - metrics.start;
1124
+ const elapsedMin = elapsedMs / 60000;
1125
+ const speed = elapsedMin > 0 ? (processed / elapsedMin).toFixed(1) : '0';
1126
+
1127
+ // Update bar
1128
+ this.progressBars[strategy].setTotal(total);
1129
+ this.progressBars[strategy].update(processed, {
1130
+ speed: `${speed} files/min`,
1131
+ });
1132
+ }
1133
+
1134
+ /**
1135
+ * Complete a progress bar for a strategy
1136
+ * @private
1137
+ * @param {string} strategy - Upload strategy name
1138
+ * @param {Object} results - Upload results (successCount, failureCount, duration)
1139
+ * @returns {void}
1140
+ */
1141
+ #completeProgressBar(strategy, results) {
1142
+ if (!this.progressBars || !this.progressBars[strategy]) {
1143
+ return;
1144
+ }
1145
+
1146
+ const metrics = this.progressMetrics[strategy];
1147
+ const bar = this.progressBars[strategy];
1148
+ const total = results.fileCount || metrics.total;
1149
+ const duration = results.duration || 0;
1150
+
1151
+ // Calculate final speed
1152
+ const durationSec = duration / 1000;
1153
+ const speed =
1154
+ durationSec > 0 ? (total / (durationSec / 60)).toFixed(1) : '0';
1155
+
1156
+ // Set to complete
1157
+ bar.setTotal(total);
1158
+ bar.update(total, {
1159
+ speed: `${speed} files/min`,
1160
+ });
1161
+ }
1162
+
1163
+ /**
1164
+ * Calculate ETA (Estimated Time to Arrival)
1165
+ * @private
1166
+ * @param {number} startTime - Start timestamp
1167
+ * @param {number} processed - Number of processed items
1168
+ * @param {number} total - Total number of items
1169
+ * @returns {string} Formatted ETA string
1170
+ */
1171
+ #calculateETA(startTime, processed, total) {
1172
+ if (processed === 0 || total === 0) {
1173
+ return '--:--:--';
1174
+ }
1175
+
1176
+ const elapsedMs = Date.now() - startTime;
1177
+ const avgTimePerItem = elapsedMs / processed;
1178
+ const remainingItems = total - processed;
1179
+ const remainingMs = remainingItems * avgTimePerItem;
1180
+
1181
+ const hours = Math.floor(remainingMs / 3600000);
1182
+ const minutes = Math.floor((remainingMs % 3600000) / 60000);
1183
+ const seconds = Math.floor((remainingMs % 60000) / 1000);
1184
+
1185
+ if (hours > 0) {
1186
+ return `${hours}h ${minutes}m`;
1187
+ } else if (minutes > 0) {
1188
+ return `${minutes}m ${seconds}s`;
1189
+ } else {
1190
+ return `${seconds}s`;
1191
+ }
1192
+ }
1193
+
1194
+ /**
1195
+ * Stop and cleanup progress bars
1196
+ * @private
1197
+ * @returns {void}
1198
+ */
1199
+ #stopProgressBars() {
1200
+ if (this.progressBars) {
1201
+ try {
1202
+ this.progressBars.stop();
1203
+ } catch (error) {
1204
+ logger.debug(`Error stopping progress bars: ${error.message}`);
1205
+ }
1206
+ }
1207
+ }
1208
+
1209
+ /**
1210
+ * Generate and display final session report
1211
+ * @private
1212
+ * @param {string} sessionId - Session ID to generate report for
1213
+ * @returns {Promise<void>}
1214
+ */
1215
+ async #generateAndDisplaySessionReport(sessionId) {
1216
+ try {
1217
+ // Get final statistics from DatabaseService
1218
+ const stats = await databaseService.getSessionStatistics(sessionId);
1219
+
1220
+ // Generate formatted report
1221
+ const report = logger.formatSessionReport();
1222
+
1223
+ // Display report in console
1224
+ console.log(report);
1225
+
1226
+ // Save report to file
1227
+ await this.#saveReportToFile(sessionId, report);
1228
+
1229
+ logger.info(
1230
+ `Session report generated successfully for session: ${sessionId}`,
1231
+ );
1232
+ } catch (error) {
1233
+ logger.error(`Error generating session report: ${error.message}`);
1234
+ }
1235
+ }
1236
+
1237
+ /**
1238
+ * Save session report to file
1239
+ * @private
1240
+ * @param {string} sessionId - Session ID
1241
+ * @param {string} report - Report content to save
1242
+ * @returns {Promise<void>}
1243
+ */
1244
+ async #saveReportToFile(sessionId, report) {
1245
+ try {
1246
+ const logDir = path.join(process.cwd(), 'logs', 'sessions');
1247
+ const timestamp = Date.now();
1248
+ const fileName = `session-${sessionId}-${timestamp}.log`;
1249
+ const filePath = path.join(logDir, fileName);
1250
+
1251
+ // Create directory if it doesn't exist
1252
+ if (!fs.existsSync(logDir)) {
1253
+ fs.mkdirSync(logDir, { recursive: true });
1254
+ }
1255
+
1256
+ // Write report to file
1257
+ fs.writeFileSync(filePath, report, 'utf-8');
1258
+
1259
+ logger.info(`Session report saved to: ${filePath}`);
1260
+ } catch (error) {
1261
+ logger.error(`Error saving report to file: ${error.message}`);
1262
+ }
1263
+ }
1264
+
1265
+ /**
1266
+ * Generate intelligent recommendations based on session statistics
1267
+ * @private
1268
+ * @param {Object} stats - Session statistics object
1269
+ * @returns {string} Formatted recommendations
1270
+ */
1271
+ #generateRecommendations(stats) {
1272
+ const recommendations = [];
1273
+
1274
+ // Analyze success rate
1275
+ const successRate =
1276
+ (stats.totalSuccessCount /
1277
+ (stats.totalSuccessCount + stats.totalFailureCount)) *
1278
+ 100;
1279
+ if (successRate >= 95) {
1280
+ recommendations.push(
1281
+ '✅ Excellent performance! Success rate is outstanding.',
1282
+ );
1283
+ } else if (successRate >= 80) {
1284
+ recommendations.push(
1285
+ '⚠️ Good performance, but there is room for improvement.',
1286
+ );
1287
+ } else {
1288
+ recommendations.push('❌ Poor performance. Check logs for issues.');
1289
+ }
1290
+
1291
+ // Analyze retries
1292
+ if (stats.retryStats.totalRetries === 0) {
1293
+ recommendations.push(
1294
+ '✅ No retries needed - upload system is very stable.',
1295
+ );
1296
+ } else if (stats.retryStats.totalRetries <= 5) {
1297
+ recommendations.push('ℹ️ Low retry count - system is fairly stable.');
1298
+ } else {
1299
+ recommendations.push(
1300
+ '⚠️ High retry count - consider optimizing upload settings.',
1301
+ );
1302
+ }
1303
+
1304
+ // Analyze strategies
1305
+ const byStrategy = stats.byStrategy;
1306
+ const strategies = Object.entries(byStrategy)
1307
+ .filter(([_, s]) => s.uploadCount > 0)
1308
+ .sort((a, b) => {
1309
+ const aSpeed =
1310
+ a[1].totalDuration > 0
1311
+ ? a[1].totalFiles / (a[1].totalDuration / 60000)
1312
+ : 0;
1313
+ const bSpeed =
1314
+ b[1].totalDuration > 0
1315
+ ? b[1].totalFiles / (b[1].totalDuration / 60000)
1316
+ : 0;
1317
+ return bSpeed - aSpeed;
1318
+ });
1319
+
1320
+ if (strategies.length > 0) {
1321
+ const fastest = strategies[0];
1322
+ const speed = (
1323
+ fastest[1].totalFiles /
1324
+ (fastest[1].totalDuration / 60000)
1325
+ ).toFixed(1);
1326
+ recommendations.push(
1327
+ `💡 Most efficient strategy: ${fastest[0]} (${speed} files/min)`,
1328
+ );
1329
+ }
1330
+
1331
+ // Format recommendations for display
1332
+ let result = '';
1333
+ recommendations.forEach((rec, idx) => {
1334
+ result += `│ ${rec}\n`;
1335
+ });
1336
+
1337
+ return result;
1338
+ }
1339
+ }
1340
+
1341
+ // Export singleton instance
1342
+ export default new WatchCommand();