@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.
- package/.env.template +66 -0
- package/README.md +263 -62
- package/docs/API_ENDPOINTS_FOR_DETECTION.md +647 -0
- package/docs/QUICK_REFERENCE_API_DETECTION.md +264 -0
- package/docs/REFACTORING_SUMMARY_DETECT_PEDIMENTOS.md +200 -0
- package/package.json +3 -2
- package/scripts/cleanup-ds-store.js +109 -0
- package/scripts/cleanup-system-files.js +69 -0
- package/scripts/tests/phase-7-features.test.js +415 -0
- package/scripts/tests/signal-handling.test.js +275 -0
- package/scripts/tests/smart-watch-integration.test.js +554 -0
- package/scripts/tests/watch-service-integration.test.js +584 -0
- package/src/commands/UploadCommand.js +31 -4
- package/src/commands/WatchCommand.js +1342 -0
- package/src/config/config.js +270 -2
- package/src/document-type-shared.js +2 -0
- package/src/document-types/support-document.js +200 -0
- package/src/file-detection.js +9 -1
- package/src/index.js +163 -4
- package/src/services/AdvancedFilterService.js +505 -0
- package/src/services/AutoProcessingService.js +749 -0
- package/src/services/BenchmarkingService.js +381 -0
- package/src/services/DatabaseService.js +1019 -539
- package/src/services/ErrorMonitor.js +275 -0
- package/src/services/LoggingService.js +419 -1
- package/src/services/MonitoringService.js +401 -0
- package/src/services/PerformanceOptimizer.js +511 -0
- package/src/services/ReportingService.js +511 -0
- package/src/services/SignalHandler.js +255 -0
- package/src/services/SmartWatchDatabaseService.js +527 -0
- package/src/services/WatchService.js +783 -0
- package/src/services/upload/ApiUploadService.js +447 -3
- package/src/services/upload/MultiApiUploadService.js +233 -0
- package/src/services/upload/SupabaseUploadService.js +12 -5
- package/src/services/upload/UploadServiceFactory.js +24 -0
- package/src/utils/CleanupManager.js +262 -0
- package/src/utils/FileOperations.js +44 -0
- package/src/utils/WatchEventHandler.js +522 -0
- package/supabase/migrations/001_create_initial_schema.sql +366 -0
- package/supabase/migrations/002_align_with_arela_api_schema.sql +145 -0
- package/.envbackup +0 -37
- package/SUPABASE_UPLOAD_FIX.md +0 -157
- 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();
|