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