@arela/uploader 1.0.15 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arela/uploader",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "CLI to upload files/directories to Arela",
5
5
  "bin": {
6
6
  "arela": "./src/index.js"
@@ -31,6 +31,7 @@
31
31
  "homepage": "https://github.com/inspiraCode/arela-uploader#readme",
32
32
  "dependencies": {
33
33
  "@supabase/supabase-js": "2.49.4",
34
+ "bullmq": "^5.71.0",
34
35
  "chokidar": "^4.0.3",
35
36
  "cli-progress": "3.12.0",
36
37
  "commander": "13.1.0",
@@ -38,6 +39,7 @@
38
39
  "form-data": "4.0.4",
39
40
  "formdata-node": "^6.0.3",
40
41
  "globby": "14.1.0",
42
+ "ioredis": "^5.10.0",
41
43
  "mime-types": "3.0.1",
42
44
  "node-fetch": "3.3.2",
43
45
  "office-text-extractor": "3.0.3",
@@ -34,9 +34,11 @@ export class IdentifyCommand {
34
34
  * @param {string} options.api - API target (default, agencia, cliente)
35
35
  * @param {number} options.batchSize - Batch size for API operations
36
36
  * @param {boolean} options.showStats - Show performance statistics
37
+ * @param {Function} options.onProgress - Optional callback for progress updates (percent, message)
37
38
  */
38
39
  async execute(options = {}) {
39
40
  const startTime = Date.now();
41
+ this.onProgress = options.onProgress || null;
40
42
 
41
43
  try {
42
44
  // Validate scan configuration (need same config as scan command)
@@ -88,8 +90,19 @@ export class IdentifyCommand {
88
90
  pending: 0,
89
91
  };
90
92
 
91
- for (const table of tables) {
93
+ // Report initial progress
94
+ this.#reportProgress(
95
+ 0,
96
+ `Starting identification on ${tables.length} tables`,
97
+ );
98
+
99
+ for (let i = 0; i < tables.length; i++) {
100
+ const table = tables[i];
92
101
  logger.info(`\nšŸ” Processing table: ${table.tableName}`);
102
+ this.#reportProgress(
103
+ Math.round((i / tables.length) * 100),
104
+ `Processing table ${i + 1}/${tables.length}: ${table.tableName}`,
105
+ );
93
106
 
94
107
  // Get detection statistics for this table
95
108
  const stats = await this.#processTable(
@@ -108,6 +121,12 @@ export class IdentifyCommand {
108
121
  const avgSpeed =
109
122
  duration > 0 ? Math.round(totalStats.processed / duration) : 0;
110
123
 
124
+ // Report completion
125
+ this.#reportProgress(
126
+ 100,
127
+ `Identification completed: ${totalStats.detected} pedimentos detected`,
128
+ );
129
+
111
130
  logger.success(`\nāœ… Identification Complete!`);
112
131
  logger.info(`\nšŸ“Š Total Results:`);
113
132
  logger.info(` Tables Processed: ${tables.length}`);
@@ -479,6 +498,22 @@ export class IdentifyCommand {
479
498
  ` Heap Total: ${(memUsage.heapTotal / 1024 / 1024).toFixed(2)} MB`,
480
499
  );
481
500
  }
501
+
502
+ /**
503
+ * Report progress to callback if available (for worker mode)
504
+ * @private
505
+ * @param {number} percent - Progress percentage (0-100)
506
+ * @param {string} message - Progress message
507
+ */
508
+ #reportProgress(percent, message) {
509
+ if (this.onProgress && typeof this.onProgress === 'function') {
510
+ try {
511
+ this.onProgress(percent, message);
512
+ } catch (error) {
513
+ logger.debug(`Progress callback error: ${error.message}`);
514
+ }
515
+ }
516
+ }
482
517
  }
483
518
 
484
519
  // Export singleton instance
@@ -0,0 +1,391 @@
1
+ import logger from '../services/LoggingService.js';
2
+ import { PipelineApiService } from '../services/PipelineApiService.js';
3
+
4
+ import appConfig from '../config/config.js';
5
+ import ErrorHandler from '../errors/ErrorHandler.js';
6
+
7
+ /**
8
+ * Poll Worker Command Handler
9
+ * Runs arela-uploader as an HTTP-polling worker, fetching jobs from the API.
10
+ * Alternative to BullMQ/Redis-based worker for environments without Redis access.
11
+ *
12
+ * Usage:
13
+ * arela worker --poll
14
+ * arela worker --poll --interval 10000
15
+ *
16
+ * Environment variables:
17
+ * WORKER_POLL_INTERVAL - Polling interval in ms (default: 5000)
18
+ * WORKER_POLL_API_TARGET - API target for polling (default: 'agencia')
19
+ * ARELA_SERVER_ID - Server identifier for job assignment
20
+ */
21
+ export class PollWorkerCommand {
22
+ constructor() {
23
+ this.errorHandler = new ErrorHandler(logger);
24
+ this.isShuttingDown = false;
25
+ this.currentJob = null;
26
+ this.pipelineApi = null;
27
+ this.pollTimer = null;
28
+ }
29
+
30
+ /**
31
+ * Execute the poll worker command
32
+ * @param {Object} options - Command options
33
+ * @param {number} options.interval - Polling interval in milliseconds
34
+ * @param {string} options.api - API target for polling
35
+ */
36
+ async execute(options = {}) {
37
+ try {
38
+ const workerConfig = appConfig.getWorkerConfig();
39
+ const pollConfig = workerConfig.poll;
40
+
41
+ // Get configuration
42
+ const interval = parseInt(options.interval) || pollConfig.interval;
43
+ const apiTarget = options.api || pollConfig.apiTarget;
44
+ const serverId = appConfig.getServerId();
45
+
46
+ if (!serverId) {
47
+ throw new Error(
48
+ 'ARELA_SERVER_ID is required for poll mode. Set it in your .env file.',
49
+ );
50
+ }
51
+
52
+ // Initialize Pipeline API service
53
+ this.pipelineApi = new PipelineApiService(apiTarget);
54
+
55
+ console.log('\nšŸ”§ Starting Arela Poll Worker');
56
+ console.log(`šŸ“” API Target: ${apiTarget}`);
57
+ console.log(`šŸ–„ļø Server ID: ${serverId}`);
58
+ console.log(`ā±ļø Poll Interval: ${interval}ms`);
59
+
60
+ logger.info('šŸ”§ Starting Arela Poll Worker');
61
+ logger.info(`šŸ“” API Target: ${apiTarget}`);
62
+ logger.info(`šŸ–„ļø Server ID: ${serverId}`);
63
+ logger.info(`ā±ļø Poll Interval: ${interval}ms`);
64
+
65
+ // Setup graceful shutdown handlers
66
+ this.#setupShutdownHandlers();
67
+
68
+ // Start polling loop
69
+ await this.#startPolling(serverId, interval);
70
+ } catch (error) {
71
+ logger.error('āŒ Poll Worker failed to start:', error.message);
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Start the polling loop
78
+ * @private
79
+ */
80
+ async #startPolling(serverId, interval) {
81
+ console.log('\nāœ… Poll Worker is running. Press Ctrl+C to stop.\n');
82
+ logger.success('Poll Worker is running');
83
+
84
+ while (!this.isShuttingDown) {
85
+ try {
86
+ // Check for next job
87
+ const job = await this.pipelineApi.getNextJob(serverId);
88
+
89
+ if (job && job.id) {
90
+ console.log(`šŸ“„ Got job: ${job.type} for RFC ${job.rfc}`);
91
+ logger.info(`šŸ“„ Got job: ${job.id} - ${job.type} for RFC ${job.rfc}`);
92
+ await this.#processJob(job);
93
+ } else {
94
+ // Only log to file, not console (too noisy)
95
+ logger.debug('šŸ“­ No jobs available, waiting...');
96
+ }
97
+ } catch (error) {
98
+ console.error(`āŒ Polling error: ${error.message}`);
99
+ logger.error(`āŒ Polling error: ${error.message}`);
100
+ }
101
+
102
+ // Wait for next poll (unless shutting down)
103
+ if (!this.isShuttingDown) {
104
+ await this.#sleep(interval);
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Process a job
111
+ * @private
112
+ */
113
+ async #processJob(job) {
114
+ this.currentJob = job;
115
+
116
+ try {
117
+ logger.info(`šŸ”„ Processing ${job.type} job ${job.id}`);
118
+
119
+ // Create progress callback that reports to API
120
+ const onProgress = async (percent, message) => {
121
+ await this.pipelineApi.updateProgress(
122
+ job.id,
123
+ percent,
124
+ message,
125
+ null,
126
+ job.type,
127
+ );
128
+ };
129
+
130
+ // Configure API targets for this job
131
+ this.#configureApiTargets(job);
132
+
133
+ // Execute the appropriate command
134
+ let result;
135
+ switch (job.type) {
136
+ case 'scan':
137
+ result = await this.#processScanJob(job, onProgress);
138
+ break;
139
+ case 'identify':
140
+ result = await this.#processIdentifyJob(job, onProgress);
141
+ break;
142
+ case 'propagate':
143
+ result = await this.#processPropagateJob(job, onProgress);
144
+ break;
145
+ case 'push':
146
+ result = await this.#processPushJob(job, onProgress);
147
+ break;
148
+ case 'full':
149
+ result = await this.#processFullPipeline(job, onProgress);
150
+ break;
151
+ default:
152
+ throw new Error(`Unknown job type: ${job.type}`);
153
+ }
154
+
155
+ // Mark job as completed
156
+ await this.pipelineApi.completeJob(job.id, result);
157
+ logger.success(`āœ… Job ${job.id} completed successfully`);
158
+ } catch (error) {
159
+ logger.error(`āŒ Job ${job.id} failed: ${error.message}`);
160
+ await this.pipelineApi.failJob(job.id, error.message);
161
+ } finally {
162
+ this.currentJob = null;
163
+ // Reset API configuration
164
+ this.#resetApiConfig();
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Configure API targets for cross-tenant operations
170
+ * @private
171
+ */
172
+ #configureApiTargets(job) {
173
+ if (job.sourceApi && job.targetApi) {
174
+ appConfig.setCrossTenantTargets(job.sourceApi, job.targetApi);
175
+ } else {
176
+ appConfig.setApiTarget(job.sourceApi || 'agencia');
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Reset API configuration after job
182
+ * @private
183
+ */
184
+ #resetApiConfig() {
185
+ // Clear cross-tenant mode
186
+ appConfig.api.sourceTarget = null;
187
+ appConfig.api.targetTarget = null;
188
+ }
189
+
190
+ /**
191
+ * Override scan configuration from job data
192
+ * @private
193
+ */
194
+ #overrideScanConfig(job) {
195
+ if (job.scanConfig) {
196
+ if (job.scanConfig.companySlug) {
197
+ process.env.ARELA_COMPANY_SLUG = job.scanConfig.companySlug;
198
+ }
199
+ if (job.scanConfig.serverId) {
200
+ process.env.ARELA_SERVER_ID = job.scanConfig.serverId;
201
+ }
202
+ if (job.scanConfig.basePath) {
203
+ process.env.UPLOAD_BASE_PATH = job.scanConfig.basePath;
204
+ process.env.ARELA_BASE_PATH_LABEL = job.scanConfig.basePath;
205
+ }
206
+ if (job.scanConfig.directoryLevel !== undefined) {
207
+ process.env.SCAN_DIRECTORY_LEVEL = String(
208
+ job.scanConfig.directoryLevel,
209
+ );
210
+ }
211
+ }
212
+
213
+ // Override scan directories if provided
214
+ if (job.scanDirectories && job.scanDirectories.length > 0) {
215
+ process.env.UPLOAD_SOURCES = job.scanDirectories.join('|');
216
+ }
217
+
218
+ // Override file extensions if provided
219
+ if (job.fileExtensions && job.fileExtensions.length > 0) {
220
+ process.env.UPLOAD_FILE_EXTENSIONS = job.fileExtensions.join(',');
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Process a scan job
226
+ * @private
227
+ */
228
+ async #processScanJob(job, onProgress) {
229
+ const { ScanCommand } = await import('./ScanCommand.js');
230
+ const scanCommand = new ScanCommand();
231
+
232
+ this.#overrideScanConfig(job);
233
+
234
+ const options = {
235
+ api: job.sourceApi || 'agencia',
236
+ countFirst: false,
237
+ stream: true,
238
+ onProgress,
239
+ };
240
+
241
+ return scanCommand.execute(options);
242
+ }
243
+
244
+ /**
245
+ * Process an identify job
246
+ * @private
247
+ */
248
+ async #processIdentifyJob(job, onProgress) {
249
+ const { IdentifyCommand } = await import('./IdentifyCommand.js');
250
+ const identifyCommand = new IdentifyCommand();
251
+
252
+ this.#overrideScanConfig(job);
253
+
254
+ const options = {
255
+ api: job.sourceApi || 'agencia',
256
+ batchSize: 100,
257
+ showStats: false,
258
+ onProgress,
259
+ };
260
+
261
+ return identifyCommand.execute(options);
262
+ }
263
+
264
+ /**
265
+ * Process a propagate job
266
+ * @private
267
+ */
268
+ async #processPropagateJob(job, onProgress) {
269
+ const { PropagateCommand } = await import('./PropagateCommand.js');
270
+
271
+ this.#overrideScanConfig(job);
272
+
273
+ const options = {
274
+ api: job.sourceApi || 'agencia',
275
+ batchSize: 50,
276
+ showStats: false,
277
+ onProgress,
278
+ };
279
+
280
+ const propagateCommand = new PropagateCommand(options);
281
+ return propagateCommand.execute();
282
+ }
283
+
284
+ /**
285
+ * Process a push job
286
+ * @private
287
+ */
288
+ async #processPushJob(job, onProgress) {
289
+ const { PushCommand } = await import('./PushCommand.js');
290
+ const pushCommand = new PushCommand();
291
+
292
+ this.#overrideScanConfig(job);
293
+
294
+ const options = {
295
+ scanApi: job.sourceApi || 'agencia',
296
+ pushApi: job.targetApi || 'cliente',
297
+ batchSize: 100,
298
+ uploadBatchSize: 10,
299
+ folderStructure: job.folderStructure || 'pedimento',
300
+ autoOrganize: true,
301
+ showStats: false,
302
+ onProgress,
303
+ };
304
+
305
+ return pushCommand.execute(options);
306
+ }
307
+
308
+ /**
309
+ * Process full pipeline (scan → identify → propagate → push)
310
+ * @private
311
+ */
312
+ async #processFullPipeline(job, onProgress) {
313
+ const results = {};
314
+
315
+ // Step 1: Scan (0-25%)
316
+ logger.info('šŸ“ Step 1/4: Scanning files...');
317
+ const wrappedProgress1 = (pct, msg) =>
318
+ onProgress(pct * 0.25, `[Scan] ${msg}`);
319
+ results.scan = await this.#processScanJob(job, wrappedProgress1);
320
+
321
+ // Step 2: Identify (25-50%)
322
+ logger.info('šŸ” Step 2/4: Identifying documents...');
323
+ const wrappedProgress2 = (pct, msg) =>
324
+ onProgress(25 + pct * 0.25, `[Identify] ${msg}`);
325
+ results.identify = await this.#processIdentifyJob(job, wrappedProgress2);
326
+
327
+ // Step 3: Propagate (50-75%)
328
+ logger.info('šŸ”„ Step 3/4: Propagating paths...');
329
+ const wrappedProgress3 = (pct, msg) =>
330
+ onProgress(50 + pct * 0.25, `[Propagate] ${msg}`);
331
+ results.propagate = await this.#processPropagateJob(job, wrappedProgress3);
332
+
333
+ // Step 4: Push (75-100%)
334
+ logger.info('šŸ“¤ Step 4/4: Pushing to storage...');
335
+ const wrappedProgress4 = (pct, msg) =>
336
+ onProgress(75 + pct * 0.25, `[Push] ${msg}`);
337
+ results.push = await this.#processPushJob(job, wrappedProgress4);
338
+
339
+ return results;
340
+ }
341
+
342
+ /**
343
+ * Sleep for specified milliseconds
344
+ * @private
345
+ */
346
+ #sleep(ms) {
347
+ return new Promise((resolve) => {
348
+ this.pollTimer = setTimeout(resolve, ms);
349
+ });
350
+ }
351
+
352
+ /**
353
+ * Setup graceful shutdown handlers
354
+ * @private
355
+ */
356
+ #setupShutdownHandlers() {
357
+ const shutdown = async (signal) => {
358
+ if (this.isShuttingDown) return;
359
+ this.isShuttingDown = true;
360
+
361
+ logger.info(`\nšŸ‘‹ Received ${signal}. Shutting down poll worker...`);
362
+
363
+ // Clear poll timer
364
+ if (this.pollTimer) {
365
+ clearTimeout(this.pollTimer);
366
+ }
367
+
368
+ // Wait for current job to finish if any
369
+ if (this.currentJob) {
370
+ logger.info(
371
+ ` ā³ Waiting for current job to finish: ${this.currentJob.id}`,
372
+ );
373
+ // Give it a moment to complete
374
+ await this.#sleep(2000);
375
+ }
376
+
377
+ // Cleanup
378
+ if (this.pipelineApi) {
379
+ this.pipelineApi.destroy();
380
+ }
381
+
382
+ logger.info('āœ… Poll worker stopped gracefully');
383
+ process.exit(0);
384
+ };
385
+
386
+ process.on('SIGINT', () => shutdown('SIGINT'));
387
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
388
+ }
389
+ }
390
+
391
+ export default new PollWorkerCommand();
@@ -16,6 +16,7 @@ export class PropagateCommand {
16
16
  batchSize: parseInt(options.batchSize) || 50, // Process 50 pedimentos at a time
17
17
  showStats: options.showStats || false,
18
18
  api: options.api || 'default',
19
+ onProgress: options.onProgress || null, // Progress callback for worker mode
19
20
  };
20
21
 
21
22
  this.scanApiService = null;
@@ -75,8 +76,19 @@ export class PropagateCommand {
75
76
  directoriesProcessed: 0,
76
77
  };
77
78
 
78
- for (const table of tables) {
79
+ // Report initial progress
80
+ this.#reportProgress(
81
+ 0,
82
+ `Starting propagation on ${tables.length} tables`,
83
+ );
84
+
85
+ for (let i = 0; i < tables.length; i++) {
86
+ const table = tables[i];
79
87
  console.log(`\nšŸ”„ Processing table: ${table.tableName}\n`);
88
+ this.#reportProgress(
89
+ Math.round((i / tables.length) * 100),
90
+ `Processing table ${i + 1}/${tables.length}: ${table.tableName}`,
91
+ );
80
92
  this.tableName = table.tableName;
81
93
 
82
94
  // Process this table
@@ -95,6 +107,12 @@ export class PropagateCommand {
95
107
  ? (totalStats.filesUpdated / parseFloat(duration)).toFixed(1)
96
108
  : 0;
97
109
 
110
+ // Report completion
111
+ this.#reportProgress(
112
+ 100,
113
+ `Propagation completed: ${totalStats.filesUpdated} files updated`,
114
+ );
115
+
98
116
  console.log('\\nāœ… Propagation Complete!\\n');
99
117
  console.log(`šŸ“Š Total Results:`);
100
118
  console.log(` Tables Processed: ${tables.length}`);
@@ -473,6 +491,25 @@ export class PropagateCommand {
473
491
  const used = process.memoryUsage();
474
492
  return `${Math.round(used.heapUsed / 1024 / 1024)}MB`;
475
493
  }
494
+
495
+ /**
496
+ * Report progress to callback if available (for worker mode)
497
+ * @private
498
+ * @param {number} percent - Progress percentage (0-100)
499
+ * @param {string} message - Progress message
500
+ */
501
+ #reportProgress(percent, message) {
502
+ if (
503
+ this.options.onProgress &&
504
+ typeof this.options.onProgress === 'function'
505
+ ) {
506
+ try {
507
+ this.options.onProgress(percent, message);
508
+ } catch (error) {
509
+ logger.debug(`Progress callback error: ${error.message}`);
510
+ }
511
+ }
512
+ }
476
513
  }
477
514
 
478
515
  export default PropagateCommand;
@@ -17,13 +17,16 @@ export class PushCommand {
17
17
  constructor() {
18
18
  // ScanApiService will be initialized in execute() with proper API target
19
19
  this.scanApiService = null;
20
+ this.onProgress = null; // Progress callback for worker mode
20
21
  }
21
22
 
22
23
  /**
23
24
  * Execute the push command
24
25
  * @param {Object} options - Command options
26
+ * @param {Function} options.onProgress - Optional callback for progress updates (percent, message)
25
27
  */
26
28
  async execute(options) {
29
+ this.onProgress = options.onProgress || null;
27
30
  try {
28
31
  console.log('\nšŸš€ Starting arela push command\n');
29
32
 
@@ -127,8 +130,16 @@ export class PushCommand {
127
130
  startTime: Date.now(),
128
131
  };
129
132
 
130
- for (const table of tables) {
133
+ // Report initial progress
134
+ this.#reportProgress(0, `Starting push on ${tables.length} tables`);
135
+
136
+ for (let i = 0; i < tables.length; i++) {
137
+ const table = tables[i];
131
138
  console.log(`\\nšŸš€ Processing table: ${table.tableName}\\n`);
139
+ this.#reportProgress(
140
+ Math.round((i / tables.length) * 100),
141
+ `Processing table ${i + 1}/${tables.length}: ${table.tableName}`,
142
+ );
132
143
 
133
144
  // Get initial statistics for this table
134
145
  const initialStats = await this.scanApiService.getPushStats(
@@ -175,6 +186,12 @@ export class PushCommand {
175
186
  ? (totalResults.processed / parseFloat(duration)).toFixed(0)
176
187
  : 0;
177
188
 
189
+ // Report completion
190
+ this.#reportProgress(
191
+ 100,
192
+ `Push completed: ${totalResults.uploaded} files uploaded`,
193
+ );
194
+
178
195
  console.log('\\nāœ… Push Complete!\\n');
179
196
  console.log('šŸ“Š Total Results:');
180
197
  console.log(` Tables Processed: ${tables.length}`);
@@ -702,6 +719,22 @@ export class PushCommand {
702
719
  };
703
720
  }
704
721
  }
722
+
723
+ /**
724
+ * Report progress to callback if available (for worker mode)
725
+ * @private
726
+ * @param {number} percent - Progress percentage (0-100)
727
+ * @param {string} message - Progress message
728
+ */
729
+ #reportProgress(percent, message) {
730
+ if (this.onProgress && typeof this.onProgress === 'function') {
731
+ try {
732
+ this.onProgress(percent, message);
733
+ } catch (error) {
734
+ logger.debug(`Progress callback error: ${error.message}`);
735
+ }
736
+ }
737
+ }
705
738
  }
706
739
 
707
740
  export default PushCommand;
@@ -28,9 +28,11 @@ export class ScanCommand {
28
28
  * @param {boolean} options.countFirst - Count files first for percentage-based progress
29
29
  * @param {string} options.api - API target: 'default', 'agencia', or 'cliente'
30
30
  * @param {boolean} options.stream - Use streaming file discovery (default: true, use --no-stream to disable)
31
+ * @param {Function} options.onProgress - Optional callback for progress updates (percent, message)
31
32
  */
32
33
  async execute(options = {}) {
33
34
  const startTime = Date.now();
35
+ this.onProgress = options.onProgress || null;
34
36
 
35
37
  try {
36
38
  // Validate scan configuration
@@ -115,8 +117,20 @@ export class ScanCommand {
115
117
  totalSize: 0,
116
118
  };
117
119
 
118
- for (const reg of registrations) {
120
+ // Report initial progress
121
+ this.#reportProgress(
122
+ 0,
123
+ `Starting scan of ${registrations.length} directories`,
124
+ );
125
+
126
+ for (let i = 0; i < registrations.length; i++) {
127
+ const reg = registrations[i];
119
128
  logger.info(`\nšŸ“‚ Scanning: ${reg.directory.label || 'root'}`);
129
+ this.#reportProgress(
130
+ Math.round((i / registrations.length) * 100),
131
+ `Scanning directory ${i + 1}/${registrations.length}: ${reg.directory.label || 'root'}`,
132
+ );
133
+
120
134
  const stats = await this.#streamScanDirectory(
121
135
  reg.directory.path,
122
136
  scanConfig,
@@ -141,6 +155,12 @@ export class ScanCommand {
141
155
  const duration = ((Date.now() - startTime) / 1000).toFixed(2);
142
156
  const filesPerSec = (totalStats.filesScanned / duration).toFixed(2);
143
157
 
158
+ // Report completion
159
+ this.#reportProgress(
160
+ 100,
161
+ `Scan completed: ${totalStats.filesScanned} files in ${duration}s`,
162
+ );
163
+
144
164
  logger.success('\nāœ… Scan completed successfully!');
145
165
  logger.info(`\nšŸ“Š Scan Statistics:`);
146
166
  logger.info(` Directories scanned: ${registrations.length}`);
@@ -649,6 +669,22 @@ export class ScanCommand {
649
669
 
650
670
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
651
671
  }
672
+
673
+ /**
674
+ * Report progress to callback if available (for worker mode)
675
+ * @private
676
+ * @param {number} percent - Progress percentage (0-100)
677
+ * @param {string} message - Progress message
678
+ */
679
+ #reportProgress(percent, message) {
680
+ if (this.onProgress && typeof this.onProgress === 'function') {
681
+ try {
682
+ this.onProgress(percent, message);
683
+ } catch (error) {
684
+ logger.debug(`Progress callback error: ${error.message}`);
685
+ }
686
+ }
687
+ }
652
688
  }
653
689
 
654
690
  export default new ScanCommand();