@magentrix-corp/magentrix-cli 1.1.4 → 1.2.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.
@@ -2,6 +2,7 @@ import { walkFiles } from "../utils/cacher.js";
2
2
  import { ensureValidCredentials } from "../utils/cli/helpers/ensureCredentials.js";
3
3
  import Config from "../utils/config.js";
4
4
  import { withSpinner } from "../utils/spinner.js";
5
+ import { ProgressTracker } from "../utils/progress.js";
5
6
  import fs from "fs";
6
7
  import path from "path";
7
8
  import chalk from "chalk";
@@ -22,6 +23,17 @@ import { toApiPath, toApiFolderPath } from "../utils/assetPaths.js";
22
23
 
23
24
  const config = new Config();
24
25
 
26
+ /* ==================== CONFIGURATION ==================== */
27
+
28
+ /**
29
+ * Set to true to process all operations sequentially (one at a time).
30
+ * Set to false to process operations in parallel with intelligent grouping.
31
+ *
32
+ * Sequential mode is slower but avoids any potential race conditions or
33
+ * server-side rate limiting issues.
34
+ */
35
+ const USE_SEQUENTIAL_PROCESSING = true;
36
+
25
37
  /* ==================== UTILITY FUNCTIONS ==================== */
26
38
 
27
39
  /**
@@ -239,13 +251,15 @@ const handleDeleteFolderAction = async (instanceUrl, apiKey, action) => {
239
251
  const cachedResults = hits?.[0]?.value || {};
240
252
 
241
253
  for (const [recordId, cachedEntry] of Object.entries(cachedResults)) {
242
- const entryPath = cachedEntry.filePath || cachedEntry.lastKnownPath;
254
+ // Check all possible path fields
255
+ const entryPath = cachedEntry.lastKnownActualPath || cachedEntry.filePath || cachedEntry.lastKnownPath;
243
256
  if (entryPath && typeof entryPath === 'string') {
244
- const normalizedEntryPath = path.normalize(entryPath);
245
- const normalizedFolderPath = path.normalize(action.folderPath);
257
+ const normalizedEntryPath = path.normalize(path.resolve(entryPath)).toLowerCase();
258
+ const normalizedFolderPath = path.normalize(path.resolve(action.folderPath)).toLowerCase();
246
259
 
247
260
  // Check if this entry is inside the deleted folder
248
- if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep)) {
261
+ if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep) ||
262
+ normalizedEntryPath === normalizedFolderPath) {
249
263
  removeFromBase(recordId);
250
264
  }
251
265
  }
@@ -318,7 +332,7 @@ const updateCacheAfterSuccess = async (action, operationResult) => {
318
332
  case "delete_folder": {
319
333
  // Skip if already cleaned from cache during 404 handling
320
334
  if (!operationResult?.cleanedFromCache) {
321
- // Remove the folder itself from base
335
+ // Remove the folder itself from base using recordId (which is the folderPath)
322
336
  removeFromBase(action.folderPath);
323
337
 
324
338
  // Also remove all files and subfolders inside this folder from base
@@ -326,13 +340,15 @@ const updateCacheAfterSuccess = async (action, operationResult) => {
326
340
  const cachedResults = hits?.[0]?.value || {};
327
341
 
328
342
  for (const [recordId, cachedEntry] of Object.entries(cachedResults)) {
329
- const entryPath = cachedEntry.filePath || cachedEntry.lastKnownPath;
343
+ // Check all possible path fields
344
+ const entryPath = cachedEntry.lastKnownActualPath || cachedEntry.filePath || cachedEntry.lastKnownPath;
330
345
  if (entryPath && typeof entryPath === 'string') {
331
- const normalizedEntryPath = path.normalize(entryPath);
332
- const normalizedFolderPath = path.normalize(action.folderPath);
346
+ const normalizedEntryPath = path.normalize(path.resolve(entryPath)).toLowerCase();
347
+ const normalizedFolderPath = path.normalize(path.resolve(action.folderPath)).toLowerCase();
333
348
 
334
349
  // Check if this entry is inside the deleted folder
335
- if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep)) {
350
+ if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep) ||
351
+ normalizedEntryPath === normalizedFolderPath) {
336
352
  removeFromBase(recordId);
337
353
  }
338
354
  }
@@ -349,54 +365,250 @@ const updateCacheAfterSuccess = async (action, operationResult) => {
349
365
  /* ==================== NETWORK REQUEST HANDLER ==================== */
350
366
 
351
367
  /**
352
- * Executes all actions in the queue in parallel and handles results/errors.
368
+ * Groups actions by resource ID to detect conflicts and sequence operations.
369
+ * @returns {Object} { byResource: Map<recordId, actions[]>, assets: actions[] }
353
370
  */
354
- const performNetworkRequest = async (actionQueue) => {
355
- const { instanceUrl, token } = await ensureValidCredentials();
371
+ const groupActionsByResource = (actionQueue) => {
372
+ const byResource = new Map(); // recordId -> actions[]
373
+ const assets = []; // Asset operations (can run in parallel)
356
374
 
357
- const results = await Promise.allSettled(
358
- actionQueue.map(async (action, index) => {
359
- try {
360
- let result;
361
- switch (action.action) {
362
- case "create":
363
- result = await handleCreateAction(instanceUrl, token.value, action);
364
- break;
365
- case "update":
366
- result = await handleUpdateAction(instanceUrl, token.value, action);
367
- break;
368
- case "delete":
369
- result = await handleDeleteAction(instanceUrl, token.value, action);
370
- break;
371
- case "create_static_asset":
372
- result = await handleCreateStaticAssetAction(instanceUrl, token.value, action);
373
- break;
374
- case "delete_static_asset":
375
- result = await handleDeleteStaticAssetAction(instanceUrl, token.value, action);
376
- break;
377
- case "create_folder":
378
- result = await handleCreateFolderAction(instanceUrl, token.value, action);
379
- break;
380
- case "delete_folder":
381
- result = await handleDeleteFolderAction(instanceUrl, token.value, action);
382
- break;
383
- default:
384
- throw new Error(`Unknown action: ${action.action}`);
385
- }
386
- return { index, action, result, success: true };
387
- } catch (error) {
388
- return { index, action, error: error.message, success: false };
375
+ for (let i = 0; i < actionQueue.length; i++) {
376
+ const action = { ...actionQueue[i], originalIndex: i };
377
+
378
+ // Asset operations don't need sequencing
379
+ if (['create_static_asset', 'delete_static_asset', 'create_folder', 'delete_folder'].includes(action.action)) {
380
+ assets.push(action);
381
+ continue;
382
+ }
383
+
384
+ // Code entity operations - group by recordId
385
+ const resourceId = action.recordId;
386
+ if (!resourceId) {
387
+ // Create actions without recordId yet - treat as unique resource
388
+ assets.push(action);
389
+ continue;
390
+ }
391
+
392
+ if (!byResource.has(resourceId)) {
393
+ byResource.set(resourceId, []);
394
+ }
395
+ byResource.get(resourceId).push(action);
396
+ }
397
+
398
+ return { byResource, assets };
399
+ };
400
+
401
+ /**
402
+ * Resolves conflicts for a single resource and returns actions to execute in sequence.
403
+ * Rules:
404
+ * - If DELETE exists, drop all other actions (delete wins)
405
+ * - If multiple UPDATEs exist, keep them in order (will execute sequentially)
406
+ */
407
+ const resolveResourceConflicts = (actions) => {
408
+ // Check if there's a delete action
409
+ const hasDelete = actions.some(a => a.action === 'delete');
410
+
411
+ if (hasDelete) {
412
+ // Delete wins - drop all other actions and only keep the delete
413
+ return actions.filter(a => a.action === 'delete');
414
+ }
415
+
416
+ // No delete - return all actions (they'll execute sequentially)
417
+ return actions;
418
+ };
419
+
420
+ /**
421
+ * Executes actions for a single resource sequentially.
422
+ */
423
+ const executeResourceActions = async (instanceUrl, token, actions) => {
424
+ const results = [];
425
+
426
+ for (const action of actions) {
427
+ try {
428
+ let result;
429
+ switch (action.action) {
430
+ case "create":
431
+ result = await handleCreateAction(instanceUrl, token, action);
432
+ break;
433
+ case "update":
434
+ result = await handleUpdateAction(instanceUrl, token, action);
435
+ break;
436
+ case "delete":
437
+ result = await handleDeleteAction(instanceUrl, token, action);
438
+ break;
439
+ default:
440
+ throw new Error(`Unknown action: ${action.action}`);
389
441
  }
390
- })
442
+ results.push({ index: action.originalIndex, action, result, success: true });
443
+ } catch (error) {
444
+ results.push({ index: action.originalIndex, action, error: error.message, success: false });
445
+ }
446
+ }
447
+
448
+ return results;
449
+ };
450
+
451
+ /**
452
+ * Executes a single action and returns the result.
453
+ */
454
+ const executeAction = async (instanceUrl, token, action) => {
455
+ let result;
456
+ switch (action.action) {
457
+ case "create":
458
+ result = await handleCreateAction(instanceUrl, token, action);
459
+ break;
460
+ case "update":
461
+ result = await handleUpdateAction(instanceUrl, token, action);
462
+ break;
463
+ case "delete":
464
+ result = await handleDeleteAction(instanceUrl, token, action);
465
+ break;
466
+ case "create_static_asset":
467
+ result = await handleCreateStaticAssetAction(instanceUrl, token, action);
468
+ break;
469
+ case "delete_static_asset":
470
+ result = await handleDeleteStaticAssetAction(instanceUrl, token, action);
471
+ break;
472
+ case "create_folder":
473
+ result = await handleCreateFolderAction(instanceUrl, token, action);
474
+ break;
475
+ case "delete_folder":
476
+ result = await handleDeleteFolderAction(instanceUrl, token, action);
477
+ break;
478
+ default:
479
+ throw new Error(`Unknown action: ${action.action}`);
480
+ }
481
+ return result;
482
+ };
483
+
484
+ /**
485
+ * Executes all actions sequentially (one at a time) with progress messages.
486
+ */
487
+ const performNetworkRequestSequential = async (actionQueue) => {
488
+ const { instanceUrl, token } = await ensureValidCredentials();
489
+
490
+ console.log(chalk.blue(`\n🔄 Sequential processing mode (${actionQueue.length} operations)\n`));
491
+
492
+ const results = [];
493
+ let successCount = 0;
494
+ let errorCount = 0;
495
+
496
+ for (let i = 0; i < actionQueue.length; i++) {
497
+ const action = { ...actionQueue[i], originalIndex: i };
498
+ const displayName = getActionDisplayName(action);
499
+
500
+ // Show progress message
501
+ console.log(chalk.gray(`[${i + 1}/${actionQueue.length}] Processing ${action.action.toUpperCase()} ${displayName}...`));
502
+
503
+ try {
504
+ const result = await executeAction(instanceUrl, token.value, action);
505
+ results.push({ index: i, action, result, success: true });
506
+
507
+ successCount++;
508
+ console.log(
509
+ chalk.green(`✓ [${i + 1}]`) +
510
+ ` ${chalk.yellow(action.action.toUpperCase())} ${chalk.cyan(displayName)} ` +
511
+ (result?.recordId ? chalk.magenta(result.recordId) : "")
512
+ );
513
+ await updateCacheAfterSuccess(action, result);
514
+ } catch (error) {
515
+ results.push({ index: i, action, error: error.message, success: false });
516
+
517
+ errorCount++;
518
+ console.log();
519
+ console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
520
+ console.log(chalk.redBright('─'.repeat(48)));
521
+ console.log(chalk.red.bold(`[${i + 1}] ${action.action.toUpperCase()} ${displayName} (${action.filePath || action.folderPath || action.folder}):`));
522
+ console.log(formatMultilineError(error.message));
523
+ console.log(chalk.redBright('─'.repeat(48)));
524
+ }
525
+
526
+ console.log(); // Add spacing between operations
527
+ }
528
+
529
+ // Summary
530
+ console.log(chalk.blue("--- Publish Summary ---"));
531
+ console.log(chalk.green(`✓ Successful: ${successCount}`));
532
+ if (errorCount > 0) {
533
+ console.log(chalk.red(`✗ Failed: ${errorCount}`));
534
+ } else {
535
+ console.log(chalk.green("All operations completed successfully! 🎉"));
536
+ }
537
+ };
538
+
539
+ /**
540
+ * Executes all actions in the queue with proper sequencing and conflict resolution (parallel mode).
541
+ */
542
+ const performNetworkRequestParallel = async (actionQueue) => {
543
+ const { instanceUrl, token } = await ensureValidCredentials();
544
+
545
+ // Group actions by resource
546
+ const { byResource, assets } = groupActionsByResource(actionQueue);
547
+
548
+ // Resolve conflicts for each resource
549
+ const sequencedCodeActions = [];
550
+ const droppedActions = [];
551
+
552
+ for (const [resourceId, actions] of byResource.entries()) {
553
+ const resolved = resolveResourceConflicts(actions);
554
+ sequencedCodeActions.push(resolved);
555
+
556
+ // Track dropped actions
557
+ const droppedCount = actions.length - resolved.length;
558
+ if (droppedCount > 0) {
559
+ const dropped = actions.filter(a => !resolved.includes(a));
560
+ droppedActions.push(...dropped);
561
+ }
562
+ }
563
+
564
+ // Log dropped actions
565
+ if (droppedActions.length > 0) {
566
+ console.log(chalk.yellow(`\n⚠️ Dropped ${droppedActions.length} redundant operation(s) due to delete:`));
567
+ droppedActions.forEach(action => {
568
+ console.log(chalk.gray(` • ${action.action.toUpperCase()} on ${getActionDisplayName(action)} (superseded by DELETE)`));
569
+ });
570
+ }
571
+
572
+ // Execute asset operations in parallel (they don't conflict)
573
+ const assetPromises = assets.map(async (action) => {
574
+ try {
575
+ const result = await executeAction(instanceUrl, token.value, action);
576
+ return { index: action.originalIndex, action, result, success: true };
577
+ } catch (error) {
578
+ return { index: action.originalIndex, action, error: error.message, success: false };
579
+ }
580
+ });
581
+
582
+ // Execute code entity operations sequentially per resource (but resources in parallel)
583
+ const codePromises = sequencedCodeActions.map(actions =>
584
+ executeResourceActions(instanceUrl, token.value, actions)
391
585
  );
392
586
 
587
+ // Wait for all operations
588
+ const assetResults = await Promise.allSettled(assetPromises);
589
+ const codeResults = await Promise.allSettled(codePromises);
590
+
591
+ // Flatten code results
592
+ const allCodeResults = codeResults
593
+ .filter(r => r.status === 'fulfilled')
594
+ .flatMap(r => r.value);
595
+
596
+ const allAssetResults = assetResults
597
+ .map(r => r.status === 'fulfilled' ? r.value : { status: 'rejected', reason: r.reason });
598
+
599
+ // Combine all results
600
+ const allResults = [...allCodeResults, ...allAssetResults];
601
+
602
+ // Sort by original index to maintain order
603
+ allResults.sort((a, b) => (a.index || 0) - (b.index || 0));
604
+
393
605
  // Process and display results
394
606
  let successCount = 0;
395
607
  let errorCount = 0;
396
608
 
397
- for (const result of results) {
398
- if (result.status === "fulfilled") {
399
- const { index, action, success, error, result: operationResult } = result.value;
609
+ for (const result of allResults) {
610
+ if (result.success !== undefined) {
611
+ const { index, action, success, error, result: operationResult } = result;
400
612
 
401
613
  if (success) {
402
614
  successCount++;
@@ -416,7 +628,7 @@ const performNetworkRequest = async (actionQueue) => {
416
628
  console.log(formatMultilineError(error));
417
629
  console.log(chalk.redBright('─'.repeat(48)));
418
630
  }
419
- } else {
631
+ } else if (result.status === 'rejected') {
420
632
  errorCount++;
421
633
  console.log();
422
634
  console.log(chalk.bgRed.bold.white(' ✖ Unexpected Error '));
@@ -429,6 +641,9 @@ const performNetworkRequest = async (actionQueue) => {
429
641
  // Summary
430
642
  console.log(chalk.blue("\n--- Publish Summary ---"));
431
643
  console.log(chalk.green(`✓ Successful: ${successCount}`));
644
+ if (droppedActions.length > 0) {
645
+ console.log(chalk.yellow(`⊝ Dropped: ${droppedActions.length} (redundant)`));
646
+ }
432
647
  if (errorCount > 0) {
433
648
  console.log(chalk.red(`✗ Failed: ${errorCount}`));
434
649
  } else {
@@ -436,6 +651,17 @@ const performNetworkRequest = async (actionQueue) => {
436
651
  }
437
652
  };
438
653
 
654
+ /**
655
+ * Executes all actions in the queue (dispatcher function).
656
+ */
657
+ const performNetworkRequest = async (actionQueue) => {
658
+ if (USE_SEQUENTIAL_PROCESSING) {
659
+ return await performNetworkRequestSequential(actionQueue);
660
+ } else {
661
+ return await performNetworkRequestParallel(actionQueue);
662
+ }
663
+ };
664
+
439
665
  /* ==================== MAIN PUBLISH LOGIC ==================== */
440
666
 
441
667
  /**
@@ -447,34 +673,69 @@ const performNetworkRequest = async (actionQueue) => {
447
673
  export const runPublish = async (options = {}) => {
448
674
  const { silent = false } = options;
449
675
 
676
+ // Create progress tracker
677
+ const progress = silent ? null : new ProgressTracker('Publish to Magentrix');
678
+ if (progress) {
679
+ progress.addStep('auth', 'Authenticating...');
680
+ progress.addStep('load', 'Loading cached data...');
681
+ progress.addStep('scan', 'Scanning local files...');
682
+ progress.addStep('compare-assets', 'Comparing assets...', { hasProgress: true });
683
+ progress.addStep('compare-code', 'Comparing code entities...', { hasProgress: true });
684
+ progress.addStep('prepare', 'Preparing action queue...');
685
+ progress.start();
686
+ // Start first step immediately so UI shows up
687
+ progress.startStep('auth');
688
+ }
689
+
450
690
  // Step 1: Authenticate
451
691
  await ensureValidCredentials().catch((err) => {
452
- if (!silent) {
692
+ if (progress) {
693
+ progress.abort(err.message);
694
+ } else if (!silent) {
453
695
  console.error(chalk.red.bold("Authentication failed:"), chalk.white(err.message));
454
696
  }
455
697
  throw err;
456
698
  });
699
+ if (progress) progress.completeStep('auth', '✓ Authenticated');
457
700
 
458
701
  // Step 2: Load cached file state
702
+ if (progress) progress.startStep('load');
703
+
704
+ const loadStart = Date.now();
459
705
  const hits = await config.searchObject({}, { filename: "base.json", global: false });
460
706
  const cachedResults = hits?.[0]?.value || {};
707
+ const loadTime = Date.now() - loadStart;
461
708
 
462
709
  if (!Object.keys(cachedResults).length) {
463
- if (!silent) {
710
+ if (progress) {
711
+ progress.abort('No file cache found');
712
+ } else if (!silent) {
464
713
  console.log(chalk.red.bold("No file cache found!"));
465
714
  console.log(`Run ${chalk.cyan("magentrix pull")} to initialize your workspace.`);
466
715
  }
467
716
  throw new Error("No file cache found");
468
717
  }
469
718
 
719
+ const mapStart = Date.now();
470
720
  const cachedFiles = Object.values(cachedResults).map((c) => ({
471
721
  ...c,
472
722
  tag: c.recordId,
473
723
  filePath: c.filePath || c.lastKnownPath,
474
724
  }));
725
+ const mapTime = Date.now() - mapStart;
726
+
727
+ if (progress) {
728
+ progress.completeStep('load', `✓ Loaded ${cachedFiles.length} entries (${loadTime}ms load, ${mapTime}ms map)`);
729
+ }
475
730
 
476
731
  // Step 3: Scan local workspace (excluding Assets folder)
732
+ if (progress) progress.startStep('scan');
733
+
734
+ const walkStart = Date.now();
477
735
  const localPaths = await walkFiles(EXPORT_ROOT, { ignore: [path.join(EXPORT_ROOT, 'Assets')] });
736
+ const walkTime = Date.now() - walkStart;
737
+
738
+ const tagStart = Date.now();
478
739
  const localFiles = await Promise.all(
479
740
  localPaths.map(async (p) => {
480
741
  try {
@@ -485,19 +746,59 @@ export const runPublish = async (options = {}) => {
485
746
  }
486
747
  })
487
748
  );
749
+ const tagTime = Date.now() - tagStart;
750
+
751
+ if (progress) {
752
+ progress.completeStep('scan', `✓ Found ${localPaths.length} files (${walkTime}ms walk, ${tagTime}ms tags)`);
753
+ }
488
754
 
489
755
  // Step 4: Create lookup maps
756
+ if (progress) progress.startStep('compare-assets');
757
+
758
+ const mapBuildStart = Date.now();
490
759
  const cacheById = Object.fromEntries(cachedFiles.map((c) => [c.tag, c]));
491
760
  const localById = Object.fromEntries(localFiles.filter((f) => f.tag).map((f) => [f.tag, f]));
492
761
  const newLocalNoId = localFiles.filter((f) => !f.tag);
493
762
  const allIds = new Set([...Object.keys(cacheById), ...Object.keys(localById)]);
763
+ const mapBuildTime = Date.now() - mapBuildStart;
494
764
 
495
765
  const actionQueue = [];
496
766
 
497
- // Step 5: Handle static asset files
767
+ // Step 5: Handle static asset files - Build fast lookup map first (O(n) instead of O(n²))
768
+ const assetWalkStart = Date.now();
498
769
  const assetPaths = await walkFiles(path.join(EXPORT_ROOT, 'Assets'));
499
- for (const assetPath of assetPaths) {
500
- if (cachedFiles.find(cr => cr.filePath?.toLowerCase() === assetPath.toLowerCase())) {
770
+ const assetWalkTime = Date.now() - assetWalkStart;
771
+
772
+ // Build a Set of normalized cached asset paths for O(1) lookup
773
+ const setStart = Date.now();
774
+ const cachedAssetPaths = new Set();
775
+ cachedFiles
776
+ .filter(cf => cf.type === 'File' || cf.type === 'Folder')
777
+ .forEach(cf => {
778
+ if (cf.lastKnownActualPath) {
779
+ cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownActualPath)).toLowerCase());
780
+ }
781
+ if (cf.filePath) {
782
+ cachedAssetPaths.add(path.normalize(path.resolve(cf.filePath)).toLowerCase());
783
+ }
784
+ if (cf.lastKnownPath) {
785
+ cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownPath)).toLowerCase());
786
+ }
787
+ });
788
+ const setTime = Date.now() - setStart;
789
+
790
+ // Now compare assets with O(1) lookup
791
+ for (let i = 0; i < assetPaths.length; i++) {
792
+ const assetPath = assetPaths[i];
793
+ const normalizedAssetPath = path.normalize(path.resolve(assetPath)).toLowerCase();
794
+
795
+ // Update progress every 100 files
796
+ if (progress && i % 100 === 0) {
797
+ progress.updateProgress('compare-assets', i, assetPaths.length, `Checking ${i}/${assetPaths.length} assets`);
798
+ }
799
+
800
+ // O(1) lookup instead of O(n) find()
801
+ if (cachedAssetPaths.has(normalizedAssetPath)) {
501
802
  continue;
502
803
  }
503
804
 
@@ -508,17 +809,43 @@ export const runPublish = async (options = {}) => {
508
809
  });
509
810
  }
510
811
 
511
- // Step 6: Handle folder creation and deletion
812
+ if (progress) {
813
+ progress.completeStep('compare-assets', `✓ Compared ${assetPaths.length} assets (walk:${assetWalkTime}ms, set:${setTime}ms, map:${mapBuildTime}ms)`);
814
+ }
815
+
816
+ // Step 6: Handle folder creation and deletion - Also optimized with Set
512
817
  const assetsDir = path.join(EXPORT_ROOT, 'Assets');
513
818
  if (fs.existsSync(assetsDir)) {
514
819
  const localFolders = walkFolders(assetsDir);
515
820
  const cachedFolders = cachedFiles
516
- .filter(c => c.type === 'Folder' && (c.filePath || c.lastKnownPath))
517
- .map(c => c.filePath || c.lastKnownPath);
821
+ .filter(c => c.type === 'Folder' && (c.filePath || c.lastKnownPath || c.lastKnownActualPath));
822
+
823
+ // Build Set of cached folder paths for O(1) lookup
824
+ const cachedFolderPaths = new Set();
825
+ cachedFolders.forEach(cf => {
826
+ if (cf.lastKnownActualPath) {
827
+ cachedFolderPaths.add(path.normalize(path.resolve(cf.lastKnownActualPath)).toLowerCase());
828
+ }
829
+ if (cf.lastKnownPath) {
830
+ cachedFolderPaths.add(path.normalize(path.resolve(cf.lastKnownPath)).toLowerCase());
831
+ }
832
+ if (cf.filePath) {
833
+ cachedFolderPaths.add(path.normalize(path.resolve(cf.filePath)).toLowerCase());
834
+ }
835
+ });
836
+
837
+ // Build Set of local folder paths for O(1) lookup
838
+ const localFolderPaths = new Set(
839
+ localFolders.map(lf => path.normalize(path.resolve(lf)).toLowerCase())
840
+ );
518
841
 
519
- // New folders
842
+ // New folders - O(1) lookup
520
843
  for (const folderPath of localFolders) {
521
- if (!folderPath || cachedFolders.find(cf => cf?.toLowerCase() === folderPath.toLowerCase())) {
844
+ if (!folderPath) continue;
845
+
846
+ const normalizedFolderPath = path.normalize(path.resolve(folderPath)).toLowerCase();
847
+
848
+ if (cachedFolderPaths.has(normalizedFolderPath)) {
522
849
  continue;
523
850
  }
524
851
 
@@ -533,27 +860,41 @@ export const runPublish = async (options = {}) => {
533
860
  });
534
861
  }
535
862
 
536
- // Deleted folders
863
+ // Deleted folders - O(1) lookup
537
864
  for (const cachedFolder of cachedFolders) {
538
- if (!cachedFolder || typeof cachedFolder !== 'string') continue;
539
- if (localFolders.find(lf => lf?.toLowerCase() === cachedFolder.toLowerCase())) {
865
+ const cachedPath = cachedFolder.lastKnownActualPath || cachedFolder.lastKnownPath || cachedFolder.filePath;
866
+ if (!cachedPath || typeof cachedPath !== 'string') continue;
867
+
868
+ const normalizedCachedPath = path.normalize(path.resolve(cachedPath)).toLowerCase();
869
+
870
+ if (localFolderPaths.has(normalizedCachedPath)) {
540
871
  continue;
541
872
  }
542
873
 
543
- const parentDir = path.dirname(cachedFolder);
544
- if (!parentDir || parentDir === '.' || parentDir === cachedFolder) continue;
874
+ const parentDir = path.dirname(cachedPath);
875
+ if (!parentDir || parentDir === '.' || parentDir === cachedPath) continue;
545
876
 
546
877
  actionQueue.push({
547
878
  action: "delete_folder",
548
- folderPath: cachedFolder,
879
+ folderPath: cachedPath,
549
880
  parentPath: toApiFolderPath(parentDir),
550
- folderName: path.basename(cachedFolder)
881
+ folderName: path.basename(cachedPath)
551
882
  });
552
883
  }
553
884
  }
554
885
 
555
886
  // Step 7: Process code entities (ActiveClass/ActivePage) and static assets
556
- for (const id of allIds) {
887
+ if (progress) progress.startStep('compare-code');
888
+
889
+ const allIdsArray = Array.from(allIds);
890
+ for (let idx = 0; idx < allIdsArray.length; idx++) {
891
+ const id = allIdsArray[idx];
892
+
893
+ // Update progress every 50 items
894
+ if (progress && idx % 50 === 0) {
895
+ progress.updateProgress('compare-code', idx, allIdsArray.length, `Checking ${idx}/${allIdsArray.length} entities`);
896
+ }
897
+
557
898
  try {
558
899
  const cacheFile = cacheById[id];
559
900
  const curFile = localById[id];
@@ -563,24 +904,26 @@ export const runPublish = async (options = {}) => {
563
904
 
564
905
  // Handle static asset files
565
906
  if (cacheFile?.type === 'File') {
566
- const localAssetExists = fs.existsSync(cacheFile.filePath);
907
+ // Use lastKnownActualPath which has the correct path (e.g., "src/Assets/...")
908
+ const actualPath = cacheFile.lastKnownActualPath || cacheFile.filePath;
909
+ const localAssetExists = fs.existsSync(actualPath);
567
910
 
568
911
  if (!localAssetExists) {
569
912
  actionQueue.push({
570
913
  action: 'delete_static_asset',
571
- folder: toApiPath(cacheFile.filePath),
572
- names: [path.basename(cacheFile.filePath)],
573
- filePath: cacheFile.filePath // Store actual file path for filtering
914
+ folder: toApiPath(actualPath),
915
+ names: [path.basename(actualPath)],
916
+ filePath: actualPath // Store actual file path for filtering
574
917
  });
575
918
  continue;
576
919
  }
577
920
 
578
- const contentHash = sha256(fs.readFileSync(cacheFile.filePath, 'utf-8'));
921
+ const contentHash = sha256(fs.readFileSync(actualPath, 'utf-8'));
579
922
  if (contentHash !== cacheFile.contentHash) {
580
923
  actionQueue.push({
581
924
  action: "create_static_asset",
582
- folder: toApiPath(cacheFile.filePath),
583
- filePath: cacheFile.filePath
925
+ folder: toApiPath(actualPath),
926
+ filePath: actualPath
584
927
  });
585
928
  }
586
929
  continue;
@@ -644,15 +987,25 @@ export const runPublish = async (options = {}) => {
644
987
  }
645
988
  }
646
989
  } catch (err) {
647
- console.error(chalk.yellow(`Warning: Error processing file with ID ${id}:`), err.message);
990
+ if (!silent) {
991
+ console.error(chalk.yellow(`Warning: Error processing file with ID ${id}:`), err.message);
992
+ }
648
993
  }
649
994
  }
650
995
 
996
+ if (progress) {
997
+ progress.completeStep('compare-code', `✓ Compared ${allIdsArray.length} code entities`);
998
+ }
999
+
651
1000
  // Step 8: Handle brand-new, tag-less files
1001
+ if (progress) progress.startStep('prepare');
1002
+
652
1003
  for (const f of newLocalNoId) {
653
1004
  const safe = readFileSafe(f.path);
654
1005
  if (!safe) {
655
- console.log(chalk.yellow(`Skipping unreadable file: ${f.path}`));
1006
+ if (!silent) {
1007
+ console.log(chalk.yellow(`Skipping unreadable file: ${f.path}`));
1008
+ }
656
1009
  continue;
657
1010
  }
658
1011
 
@@ -709,6 +1062,11 @@ export const runPublish = async (options = {}) => {
709
1062
  return true;
710
1063
  });
711
1064
 
1065
+ if (progress) {
1066
+ progress.completeStep('prepare', `✓ Prepared ${filteredActionQueue.length} actions`);
1067
+ progress.finish();
1068
+ }
1069
+
712
1070
  // Step 10: Display and execute action queue
713
1071
  if (!silent) {
714
1072
  console.log(chalk.blue("\n--- Publish Action Queue ---"));