@magentrix-corp/magentrix-cli 1.1.5 → 1.2.1

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/actions/pull.js CHANGED
@@ -3,14 +3,16 @@ import Config from "../utils/config.js";
3
3
  import { meqlQuery } from "../utils/magentrix/api/meqlQuery.js";
4
4
  import fs from "fs";
5
5
  import { withSpinner } from "../utils/spinner.js";
6
+ import { ProgressTracker } from "../utils/progress.js";
7
+ import { createLogger, Logger } from "../utils/logger.js";
6
8
  import { EXPORT_ROOT, TYPE_DIR_MAP } from "../vars/global.js";
7
9
  import { mapRecordToFile, writeRecords } from "../utils/cli/writeRecords.js";
8
- import { updateBase, removeFromBase } from "../utils/updateFileBase.js";
10
+ import { updateBase, removeFromBase, removeFromBaseBulk } from "../utils/updateFileBase.js";
9
11
  import { compareAllFilesAndLogStatus, promptConflictResolution, showCurrentConflicts } from "../utils/cli/helpers/compare.js";
10
12
  import path from "path";
11
13
  import { compareLocalAndRemote } from "../utils/compare.js";
12
14
  import chalk from 'chalk';
13
- import { getFileTag } from "../utils/filetag.js";
15
+ import { getFileTag, setFileTag } from "../utils/filetag.js";
14
16
  import { downloadAssetsZip, listAssets } from "../utils/magentrix/api/assets.js";
15
17
  import { downloadAssets, walkAssets } from "../utils/downloadAssets.js";
16
18
  import { v4 as uuidv4 } from 'uuid';
@@ -35,236 +37,529 @@ const config = new Config();
35
37
  * @returns {Promise<void>}
36
38
  */
37
39
  export const pull = async () => {
38
- // Step 1: Authenticate and retrieve instance URL and token
39
- const { instanceUrl, token } = await withSpinner('Authenticating...', async () => {
40
- return await ensureValidCredentials();
41
- });
42
-
43
40
  // Clear the terminal
44
41
  process.stdout.write('\x1Bc');
45
42
 
46
- // Step 2: Check if instance URL has changed (credential switch detected)
47
- const lastInstanceUrl = config.read('lastInstanceUrl', { global: false, filename: 'config.json' });
48
- const instanceChanged = lastInstanceUrl && lastInstanceUrl !== instanceUrl;
49
-
50
- if (instanceChanged) {
51
- console.log(chalk.yellow.bold(`\n⚠️ INSTANCE CHANGE DETECTED`));
52
- console.log(chalk.yellow(`Previous instance: ${chalk.cyan(lastInstanceUrl)}`));
53
- console.log(chalk.yellow(`New instance: ${chalk.cyan(instanceUrl)}`));
54
- console.log();
55
- console.log(chalk.red.bold(`⚠️ WARNING: This will DELETE your existing ${chalk.white(EXPORT_ROOT + '/')} directory!`));
56
- console.log(chalk.gray(`This is necessary to prevent mixing files from different instances.`));
57
- console.log();
58
-
59
- const confirm = readlineSync.question(
60
- chalk.yellow(`Type ${chalk.white.bold('yes')} to continue and delete ${EXPORT_ROOT}/, or ${chalk.white.bold('no')} to cancel: `)
61
- );
43
+ // Clean up old log files (keep last 10)
44
+ Logger.cleanupOldLogs(10);
45
+
46
+ // Create logger
47
+ const logger = createLogger('pull');
48
+ await logger.initLogFile(); // Initialize with user preference check
49
+ logger.info('Starting pull operation');
50
+
51
+ // Create progress tracker
52
+ const progress = new ProgressTracker('Pull from Magentrix');
53
+ progress.addStep('auth', 'Authenticating...');
54
+ progress.addStep('check', 'Checking instance...');
55
+ progress.addStep('load', 'Loading cached data...');
56
+ progress.addStep('download-code', 'Downloading code entities...', { hasProgress: true });
57
+ progress.addStep('download-assets', 'Downloading static assets...', { hasProgress: true });
58
+ progress.addStep('process', 'Processing files...', { hasProgress: true });
59
+ progress.addStep('conflicts', 'Checking for conflicts...');
60
+ progress.addStep('write', 'Writing files...', { hasProgress: true });
61
+ progress.start();
62
+
63
+ try {
64
+ // Step 1: Authenticate and retrieve instance URL and token
65
+ progress.startStep('auth');
66
+ const { instanceUrl, token } = await ensureValidCredentials();
67
+ progress.completeStep('auth', '✓ Authenticated');
68
+
69
+ // Step 2: Check if instance URL has changed (credential switch detected)
70
+ progress.startStep('check');
71
+ const lastInstanceUrl = config.read('lastInstanceUrl', { global: false, filename: 'config.json' });
72
+ const instanceChanged = lastInstanceUrl && lastInstanceUrl !== instanceUrl;
73
+
74
+ if (instanceChanged) {
75
+ progress.stopSpinner();
76
+ progress.render();
77
+ console.log(chalk.yellow.bold(`\n⚠️ INSTANCE CHANGE DETECTED`));
78
+ console.log(chalk.yellow(`Previous instance: ${chalk.cyan(lastInstanceUrl)}`));
79
+ console.log(chalk.yellow(`New instance: ${chalk.cyan(instanceUrl)}`));
80
+ console.log();
81
+ console.log(chalk.red.bold(`⚠️ WARNING: This will DELETE your existing ${chalk.white(EXPORT_ROOT + '/')} directory!`));
82
+ console.log(chalk.gray(`This is necessary to prevent mixing files from different instances.`));
83
+ console.log();
84
+
85
+ const confirm = readlineSync.question(
86
+ chalk.yellow(`Type ${chalk.white.bold('yes')} to continue and delete ${EXPORT_ROOT}/, or ${chalk.white.bold('no')} to cancel: `)
87
+ );
62
88
 
63
- if (confirm.trim().toLowerCase() !== 'yes') {
64
- console.log(chalk.red('\n❌ Pull cancelled. No files were deleted.'));
65
- console.log(chalk.gray(`Tip: To pull from ${chalk.cyan(lastInstanceUrl)}, switch back to those credentials.`));
66
- process.exit(0);
67
- }
89
+ if (confirm.trim().toLowerCase() !== 'yes') {
90
+ console.log(chalk.red('\n❌ Pull cancelled. No files were deleted.'));
91
+ console.log(chalk.gray(`Tip: To pull from ${chalk.cyan(lastInstanceUrl)}, switch back to those credentials.`));
92
+ process.exit(0);
93
+ }
68
94
 
69
- console.log(chalk.yellow(`\n🗑️ Removing existing ${EXPORT_ROOT}/ directory...`));
70
- if (fs.existsSync(EXPORT_ROOT)) {
71
- fs.rmSync(EXPORT_ROOT, { recursive: true, force: true });
95
+ console.log(chalk.yellow(`\n🗑️ Removing existing ${EXPORT_ROOT}/ directory...`));
96
+ if (fs.existsSync(EXPORT_ROOT)) {
97
+ fs.rmSync(EXPORT_ROOT, { recursive: true, force: true });
98
+ }
99
+ // Clear the base.json cache as well since it's from a different instance
100
+ fs.writeFileSync('.magentrix/base.json', JSON.stringify({}));
101
+ config.save('cachedFiles', {}, { filename: 'fileCache.json' });
102
+ config.save('trackedFileTags', {}, { filename: 'fileIdIndex.json' });
103
+ console.log(chalk.green(`✓ Removed ${EXPORT_ROOT}/ directory\n`));
104
+
105
+ progress.start(); // Restart progress tracker
106
+ progress.startStep('check');
72
107
  }
73
- // Clear the base.json cache as well since it's from a different instance
74
- fs.writeFileSync('.magentrix/base.json', JSON.stringify({}));
75
- config.save('cachedFiles', {}, { filename: 'fileCache.json' });
76
- config.save('trackedFileTags', {}, { filename: 'fileIdIndex.json' });
77
- console.log(chalk.green(`✓ Removed ${EXPORT_ROOT}/ directory\n`));
78
- }
79
108
 
80
- // Save the current instance URL for future comparisons
81
- config.save('lastInstanceUrl', instanceUrl, { global: false, filename: 'config.json' });
82
-
83
- // Step 3: Prepare queries for both ActiveClass and ActivePage
84
- const queries = [
85
- {
86
- name: "ActiveClass",
87
- query: "SELECT Id,Body,Name,CreatedOn,Description,ModifiedOn,Type FROM ActiveClass",
88
- contentField: "Body",
89
- },
90
- {
91
- name: "ActivePage",
92
- query: "SELECT Id,Content,Name,CreatedOn,Description,ModifiedOn,Type FROM ActivePage",
93
- contentField: "Content",
94
- }
95
- ];
96
-
97
- // Step 3: Load existing base.json to detect deletions
98
- const hits = await config.searchObject({}, { filename: "base.json", global: false });
99
- const cachedResults = hits?.[0]?.value || {};
100
- const cachedAssets = Object.values(cachedResults).filter(c =>
101
- c.type === 'File' || c.type === 'Folder'
102
- );
103
-
104
- // Step 4: Download records in parallel with spinner
105
- const [activeClassResult, activePageResult, assets] = await withSpinner("Downloading files...", async () => {
106
- const meqlResults = await Promise.all(
107
- queries.map(q => meqlQuery(instanceUrl, token.value, q.query))
109
+ // Save the current instance URL for future comparisons
110
+ config.save('lastInstanceUrl', instanceUrl, { global: false, filename: 'config.json' });
111
+ progress.completeStep('check', instanceChanged ? '✓ Instance reset' : '✓ Instance verified');
112
+
113
+ // Step 3: Prepare queries for both ActiveClass and ActivePage
114
+ const queries = [
115
+ {
116
+ name: "ActiveClass",
117
+ query: "SELECT Id,Body,Name,CreatedOn,Description,ModifiedOn,Type FROM ActiveClass",
118
+ contentField: "Body",
119
+ },
120
+ {
121
+ name: "ActivePage",
122
+ query: "SELECT Id,Content,Name,CreatedOn,Description,ModifiedOn,Type FROM ActivePage",
123
+ contentField: "Content",
124
+ }
125
+ ];
126
+
127
+ // Step 3: Load existing base.json to detect deletions
128
+ progress.startStep('load');
129
+ const hits = await config.searchObject({}, { filename: "base.json", global: false });
130
+ const cachedResults = hits?.[0]?.value || {};
131
+ const cachedAssets = Object.values(cachedResults).filter(c =>
132
+ c.type === 'File' || c.type === 'Folder'
108
133
  );
134
+ progress.completeStep('load', `✓ Loaded ${Object.keys(cachedResults).length} cached entries`);
109
135
 
110
- const assetTree = await downloadAssets(instanceUrl, token.value);
136
+ // Step 4a: Download code entities (ActiveClass and ActivePage)
137
+ progress.startStep('download-code');
111
138
 
112
- return [
113
- ...meqlResults,
114
- assetTree
115
- ]
116
- });
139
+ let activeClassResult, activePageResult;
140
+ const codeDownloadErrors = [];
117
141
 
118
- // Collect all server asset paths
119
- const serverAssetPaths = new Set();
120
- const collectServerPaths = (records) => {
121
- for (const record of records) {
122
- const fullPath = path.join(EXPORT_ROOT, record?.Path);
123
- serverAssetPaths.add(path.normalize(fullPath));
142
+ try {
143
+ logger.info('Starting code entity downloads');
144
+ const meqlResults = await Promise.all(
145
+ queries.map(q => meqlQuery(instanceUrl, token.value, q.query))
146
+ );
124
147
 
125
- if (record?.Type === 'Folder' && record?.Children?.length > 0) {
126
- collectServerPaths(record.Children);
127
- }
148
+ [activeClassResult, activePageResult] = meqlResults;
149
+ const totalCodeEntities = activeClassResult.Records.length + activePageResult.Records.length;
150
+
151
+ logger.info(`Downloaded ${totalCodeEntities} code entities`, {
152
+ activeClass: activeClassResult.Records.length,
153
+ activePage: activePageResult.Records.length
154
+ });
155
+
156
+ progress.updateProgress('download-code', totalCodeEntities, totalCodeEntities, `Downloaded ${totalCodeEntities} code entities`);
157
+ progress.completeStep('download-code', `✓ Downloaded ${totalCodeEntities} code entities`);
158
+ } catch (error) {
159
+ logger.error('Error downloading code entities', error);
160
+ codeDownloadErrors.push({ stage: 'code-download', error: error.message });
161
+ progress.failStep('download-code', error.message);
162
+
163
+ // Show error preview
164
+ progress.stopSpinner();
165
+ console.log('');
166
+ console.log(chalk.red.bold('❌ Code Download Error:'));
167
+ console.log(chalk.red(` ${error.message.substring(0, 200)}`));
168
+ console.log(chalk.cyan(`\n 📄 Full details in log file`));
169
+ console.log('');
170
+
171
+ // Initialize empty data structures
172
+ if (!activeClassResult) activeClassResult = { Records: [] };
173
+ if (!activePageResult) activePageResult = { Records: [] };
174
+
175
+ // Restart progress for next step
176
+ progress.start();
177
+ progress.startStep('download-assets');
128
178
  }
129
- };
130
- collectServerPaths(assets.tree);
131
179
 
132
- // Step 5: Detect and delete assets that were on server before but are now gone
133
- const assetsToDelete = [];
134
- for (const cached of cachedAssets) {
135
- const cachedPath = path.normalize(cached.filePath || cached.lastKnownPath);
136
-
137
- // If this asset was in base.json but not returned from server, it was deleted
138
- if (!serverAssetPaths.has(cachedPath)) {
139
- assetsToDelete.push(cachedPath);
180
+ // Step 4b: Download static assets
181
+ if (!codeDownloadErrors.length) {
182
+ progress.startStep('download-assets');
140
183
  }
141
- }
142
184
 
143
- // Delete local files/folders that were deleted on server
144
- for (const assetPath of assetsToDelete) {
185
+ let assets;
186
+ const assetDownloadErrors = [];
187
+
145
188
  try {
146
- if (fs.existsSync(assetPath)) {
147
- const stats = fs.statSync(assetPath);
148
- if (stats.isDirectory()) {
149
- fs.rmSync(assetPath, { recursive: true, force: true });
150
- console.log(chalk.gray(` 🗑️ Removed deleted folder: ${path.relative(process.cwd(), assetPath)}`));
151
- } else {
152
- fs.unlinkSync(assetPath);
153
- console.log(chalk.gray(` 🗑️ Removed deleted file: ${path.relative(process.cwd(), assetPath)}`));
189
+ logger.info('Starting static asset downloads');
190
+ assets = await downloadAssets(instanceUrl, token.value, null, (current, total, message) => {
191
+ progress.updateProgress('download-assets', current, total, message);
192
+ }, logger);
193
+
194
+ logger.info(`Downloaded ${assets.tree.length} asset folders`);
195
+ progress.completeStep('download-assets', `✓ Downloaded ${assets.tree.length} asset folders`);
196
+ } catch (error) {
197
+ logger.error('Error downloading static assets', error);
198
+ assetDownloadErrors.push({ stage: 'asset-download', error: error.message });
199
+ progress.failStep('download-assets', error.message);
200
+
201
+ // Show error preview
202
+ progress.stopSpinner();
203
+ console.log('');
204
+ console.log(chalk.red.bold('❌ Asset Download Error:'));
205
+ console.log(chalk.red(` ${error.message.substring(0, 200)}`));
206
+ console.log(chalk.cyan(`\n 📄 Full details in log file`));
207
+ console.log('');
208
+
209
+ // Initialize empty data structure
210
+ if (!assets) assets = { tree: [] };
211
+
212
+ // Restart progress for next step
213
+ progress.start();
214
+ }
215
+
216
+ // Check if we have any data to continue with
217
+ if (!activeClassResult?.Records?.length && !activePageResult?.Records?.length && !assets?.tree?.length) {
218
+ throw new Error('Download failed completely. No data available to continue.');
219
+ }
220
+
221
+ // Step 5: Process files and detect changes
222
+ progress.startStep('process');
223
+ const { allRecords, issues, deletionLogs, processingErrors } = await (async () => {
224
+ let processedCount = 0;
225
+ const totalToProcess = cachedAssets.length + (activeClassResult.Records.length + activePageResult.Records.length);
226
+ const processingErrors = [];
227
+
228
+ // Collect all server asset paths
229
+ const serverAssetPaths = new Set();
230
+ const collectServerPaths = (records) => {
231
+ try {
232
+ for (const record of records) {
233
+ const fullPath = path.join(EXPORT_ROOT, record?.Path);
234
+ serverAssetPaths.add(path.normalize(fullPath));
235
+
236
+ if (record?.Type === 'Folder' && record?.Children?.length > 0) {
237
+ collectServerPaths(record.Children);
238
+ }
239
+ }
240
+ } catch (err) {
241
+ processingErrors.push({ stage: 'collect-paths', error: err.message });
242
+ logger.error('Error collecting server paths', err);
243
+ }
244
+ };
245
+ collectServerPaths(assets.tree);
246
+ progress.updateProgress('process', ++processedCount, totalToProcess, 'Collecting server paths');
247
+
248
+ // Detect and delete assets that were on server before but are now gone
249
+ const assetsToDelete = [];
250
+ for (const cached of cachedAssets) {
251
+ const cachedPath = path.normalize(cached.lastKnownActualPath || cached.filePath || cached.lastKnownPath);
252
+
253
+ // If this asset was in base.json but not returned from server, it was deleted
254
+ if (!serverAssetPaths.has(cachedPath)) {
255
+ assetsToDelete.push(cachedPath);
256
+ }
257
+
258
+ processedCount++;
259
+ if (processedCount % 50 === 0) {
260
+ progress.updateProgress('process', processedCount, totalToProcess, `Checking assets...`);
261
+ }
262
+ }
263
+ progress.updateProgress('process', processedCount, totalToProcess, `Found ${assetsToDelete.length} assets to delete`);
264
+
265
+ // Count total assets (for progress tracking)
266
+ // Note: We don't update base.json for assets here because:
267
+ // 1. Assets are already downloaded and written to disk
268
+ // 2. Their base will be updated during the "Writing files" stage if modified
269
+ // 3. Updating base.json thousands of times here is extremely slow
270
+ logger.info('Counting assets in tree');
271
+ let assetCount = 0;
272
+ const countAssets = (records) => {
273
+ for (const record of records) {
274
+ assetCount++;
275
+ if (record?.Type === 'Folder' && record?.Children?.length > 0) {
276
+ countAssets(record.Children);
277
+ }
278
+ }
279
+ }
280
+
281
+ countAssets(assets.tree);
282
+ logger.info(`Counted ${assetCount} total assets`);
283
+ processedCount += assetCount;
284
+ progress.updateProgress('process', processedCount, totalToProcess, `Processed ${assetCount} assets`);
285
+
286
+ // Handle code entity (ActiveClass, ActivePage) deletions
287
+ const activeClassRecords = (activeClassResult.Records || []).map(record => {
288
+ record.Content = record.Body;
289
+ delete record.Body;
290
+ return record;
291
+ });
292
+
293
+ const activePageRecords = (activePageResult.Records || []);
294
+ const allRecords = [...activeClassRecords, ...activePageRecords].map(mapRecordToFile);
295
+
296
+ // Get all server record IDs
297
+ const serverRecordIds = new Set([
298
+ ...activeClassRecords.map(r => r.Id),
299
+ ...activePageRecords.map(r => r.Id)
300
+ ]);
301
+
302
+ // Find code entities in base.json that are no longer on server
303
+ const cachedCodeEntities = Object.values(cachedResults).filter(c =>
304
+ c.type !== 'File' && c.type !== 'Folder' && c.recordId
305
+ );
306
+
307
+ const codeEntitiesToDelete = [];
308
+ for (const cached of cachedCodeEntities) {
309
+ // If this code entity was in base.json but not returned from server, it was deleted
310
+ if (!serverRecordIds.has(cached.recordId)) {
311
+ codeEntitiesToDelete.push(cached);
154
312
  }
155
313
  }
156
- // Remove from base.json
157
- removeFromBase(assetPath);
158
- } catch (err) {
159
- console.warn(chalk.yellow(` ⚠️ Could not delete ${assetPath}: ${err.message}`));
160
- }
161
- }
162
314
 
163
- // Step 6: Update assets base
164
- const processAssets = (records) => {
165
- for (const record of records) {
166
- if (record?.Type === 'Folder') {
167
- // Cache the folder itself
168
- updateBase(
169
- path.join(EXPORT_ROOT, record?.Path),
170
- {
171
- ...record,
172
- Id: path.join(EXPORT_ROOT, record?.Path)
315
+ // Delete local files/folders that were deleted on server
316
+ logger.info(`Starting deletion of ${assetsToDelete.length} assets`);
317
+ const deletionLogs = [];
318
+ for (const assetPath of assetsToDelete) {
319
+ try {
320
+ if (fs.existsSync(assetPath)) {
321
+ const stats = fs.statSync(assetPath);
322
+ if (stats.isDirectory()) {
323
+ fs.rmSync(assetPath, { recursive: true, force: true });
324
+ deletionLogs.push({ type: 'folder', path: assetPath });
325
+ logger.info('Deleted folder', { path: assetPath });
326
+ } else {
327
+ fs.unlinkSync(assetPath);
328
+ deletionLogs.push({ type: 'file', path: assetPath });
329
+ logger.info('Deleted file', { path: assetPath });
330
+ }
173
331
  }
174
- );
175
- // Process children if any
176
- if (record?.Children?.length > 0) {
177
- processAssets(record.Children);
332
+ } catch (err) {
333
+ deletionLogs.push({ type: 'error', path: assetPath, error: err.message });
334
+ logger.error(`Failed to delete asset: ${assetPath}`, err);
178
335
  }
179
- continue;
180
336
  }
181
337
 
182
- updateBase(
183
- path.join(EXPORT_ROOT, record?.Path),
184
- {
185
- ...record,
186
- Id: path.join(EXPORT_ROOT, record?.Path)
338
+ // Bulk remove from base.json
339
+ const assetPathsToRemove = deletionLogs
340
+ .filter(l => l.type === 'file' || l.type === 'folder')
341
+ .map(l => l.path);
342
+
343
+ if (assetPathsToRemove.length > 0) {
344
+ removeFromBaseBulk(assetPathsToRemove);
345
+ }
346
+
347
+ // Delete local code entity files that were deleted on server
348
+ logger.info(`Starting deletion of ${codeEntitiesToDelete.length} code entities`);
349
+ for (const entity of codeEntitiesToDelete) {
350
+ const entityPath = entity.filePath || entity.lastKnownPath;
351
+ try {
352
+ if (entityPath && fs.existsSync(entityPath)) {
353
+ fs.unlinkSync(entityPath);
354
+ deletionLogs.push({ type: 'entity', path: entityPath, entityType: entity.type });
355
+ logger.info('Deleted code entity', { path: entityPath, type: entity.type });
356
+ }
357
+ } catch (err) {
358
+ deletionLogs.push({ type: 'error', path: entityPath, error: err.message });
359
+ logger.error(`Failed to delete code entity: ${entityPath}`, err);
187
360
  }
188
- );
189
- }
190
- }
361
+ }
191
362
 
192
- processAssets(assets.tree);
193
-
194
- // Step 7: Handle code entity (ActiveClass, ActivePage) deletions
195
- const activeClassRecords = (activeClassResult.Records || []).map(record => {
196
- record.Content = record.Body;
197
- delete record.Body;
198
- return record;
199
- });
200
-
201
- const activePageRecords = (activePageResult.Records || []);
202
- const allRecords = [...activeClassRecords, ...activePageRecords].map(mapRecordToFile);
203
-
204
- // Get all server record IDs
205
- const serverRecordIds = new Set([
206
- ...activeClassRecords.map(r => r.Id),
207
- ...activePageRecords.map(r => r.Id)
208
- ]);
209
-
210
- // Find code entities in base.json that are no longer on server
211
- const cachedCodeEntities = Object.values(cachedResults).filter(c =>
212
- c.type !== 'File' && c.type !== 'Folder' && c.recordId
213
- );
214
-
215
- const codeEntitiesToDelete = [];
216
- for (const cached of cachedCodeEntities) {
217
- // If this code entity was in base.json but not returned from server, it was deleted
218
- if (!serverRecordIds.has(cached.recordId)) {
219
- codeEntitiesToDelete.push(cached);
220
- }
221
- }
363
+ // Bulk remove code entities from base.json
364
+ const entityIdsToRemove = deletionLogs
365
+ .filter(l => l.type === 'entity')
366
+ .map(l => {
367
+ // Find the entity object that corresponds to this path to get the ID
368
+ const entity = codeEntitiesToDelete.find(e =>
369
+ (e.filePath === l.path || e.lastKnownPath === l.path)
370
+ );
371
+ return entity ? entity.recordId : null;
372
+ })
373
+ .filter(id => id !== null);
374
+
375
+ if (entityIdsToRemove.length > 0) {
376
+ removeFromBaseBulk(entityIdsToRemove);
377
+ }
378
+ logger.info(`Completed deletions - ${deletionLogs.length} items deleted`);
379
+ progress.updateProgress('process', processedCount, totalToProcess, `Deleted ${deletionLogs.length} items`);
380
+
381
+ // Compare files and detect conflicts
382
+ logger.info(`Starting file comparison for ${allRecords.length} records`);
383
+ // --- Fix for Bug 1: Rename Tracking ---
384
+ // Build a map of RecordId -> LocalPath from cached results
385
+ const localPathMap = new Map();
386
+ Object.values(cachedResults).forEach(entry => {
387
+ if (entry.recordId && (entry.filePath || entry.lastKnownPath)) {
388
+ localPathMap.set(entry.recordId, entry.filePath || entry.lastKnownPath);
389
+ }
390
+ });
391
+
392
+ // Check for renames before comparison
393
+ for (const record of allRecords) {
394
+ if (localPathMap.has(record.Id)) {
395
+ const oldPath = localPathMap.get(record.Id);
396
+ const newPath = path.join(EXPORT_ROOT, record.relativePath);
397
+
398
+ // Normalize paths for comparison
399
+ let normalizedOld = path.normalize(path.resolve(oldPath));
400
+ const normalizedNew = path.normalize(path.resolve(newPath));
401
+
402
+ // Fix for path resolution: base.json might store paths relative to CWD or EXPORT_ROOT
403
+ // If direct resolution fails, try prepending EXPORT_ROOT
404
+ if (!fs.existsSync(normalizedOld)) {
405
+ const withRoot = path.normalize(path.resolve(EXPORT_ROOT, oldPath));
406
+ if (fs.existsSync(withRoot)) {
407
+ normalizedOld = withRoot;
408
+ }
409
+ }
222
410
 
223
- // Delete local code entity files that were deleted on server
224
- for (const entity of codeEntitiesToDelete) {
225
- const entityPath = entity.filePath || entity.lastKnownPath;
226
- try {
227
- if (entityPath && fs.existsSync(entityPath)) {
228
- fs.unlinkSync(entityPath);
229
- console.log(chalk.gray(` 🗑️ Removed deleted ${entity.type}: ${path.relative(process.cwd(), entityPath)}`));
411
+ if (normalizedOld !== normalizedNew && fs.existsSync(normalizedOld)) {
412
+ try {
413
+ logger.info(`Detected rename: ${path.relative(EXPORT_ROOT, oldPath)} -> ${record.relativePath}`);
414
+
415
+ // Ensure target directory exists
416
+ fs.mkdirSync(path.dirname(normalizedNew), { recursive: true });
417
+
418
+ // Rename file
419
+ fs.renameSync(normalizedOld, normalizedNew);
420
+
421
+ // Update fileIdIndex.json to point to the new path
422
+ // This must be done BEFORE writeRecords runs, otherwise findFileByTag
423
+ // will return the old path and writeRecords will recreate the old file
424
+ await setFileTag(normalizedNew, record.Id);
425
+
426
+ // Update base.json immediately to reflect new path
427
+ // We use the existing record data but update the path
428
+ updateBase(record.relativePath, record, normalizedNew);
429
+
430
+ // Remove old path from deletion logs if it was marked for deletion
431
+ // (It might have been marked if the old filename wasn't in the server response)
432
+ const deletionIndex = deletionLogs.findIndex(l => path.normalize(l.path) === normalizedOld);
433
+ if (deletionIndex !== -1) {
434
+ deletionLogs.splice(deletionIndex, 1);
435
+ logger.info(`Cancelled deletion of renamed file: ${path.relative(EXPORT_ROOT, oldPath)}`);
436
+ }
437
+
438
+ console.log(chalk.cyan(` ↻ Renamed: ${path.relative(EXPORT_ROOT, oldPath)} -> ${record.relativePath}`));
439
+ } catch (err) {
440
+ logger.error(`Failed to rename file: ${oldPath} -> ${newPath}`, err);
441
+ processingErrors.push({
442
+ stage: 'rename',
443
+ file: record.relativePath,
444
+ error: `Rename failed: ${err.message}`
445
+ });
446
+ }
447
+ }
448
+ }
230
449
  }
231
- // Remove from base.json
232
- removeFromBase(entity.recordId);
233
- } catch (err) {
234
- console.warn(chalk.yellow(` ⚠️ Could not delete ${entityPath}: ${err.message}`));
235
- }
236
- }
450
+ // --- End Fix for Bug 1 ---
451
+
452
+ const issues = [];
453
+ for (let i = 0; i < allRecords.length; i++) {
454
+ const record = allRecords[i];
455
+ try {
456
+ if (record?.error) {
457
+ logger.warning('Record has error', { record: record.Name || record.relativePath });
458
+ processingErrors.push({
459
+ stage: 'record-error',
460
+ file: record.Name || record.relativePath,
461
+ error: 'Record contains error flag'
462
+ });
463
+ continue;
464
+ }
237
465
 
238
- const issues = [];
239
- for (const record of allRecords) {
240
- if (record?.error) {
241
- continue;
242
- }
466
+ // Log every 50th file to track progress
467
+ if (i % 50 === 0) {
468
+ logger.info(`Comparing file ${i + 1}/${allRecords.length}: ${record.relativePath}`);
469
+ }
243
470
 
244
- const status = compareLocalAndRemote(
245
- path.join(EXPORT_ROOT, record.relativePath),
246
- { ...record, content: record.Content }
247
- );
471
+ const status = compareLocalAndRemote(
472
+ path.join(EXPORT_ROOT, record.relativePath),
473
+ { ...record, content: record.Content }
474
+ );
248
475
 
249
- // Missing files will just be written
250
- if (!['in_sync', 'missing'].includes(status.status)) {
251
- issues.push({ relativePath: record.relativePath, status: status.status });
252
- }
253
- }
476
+ // Missing files will just be written
477
+ if (!['in_sync', 'missing'].includes(status.status)) {
478
+ issues.push({ relativePath: record.relativePath, status: status.status });
479
+ logger.info('Conflict detected', { file: record.relativePath, status: status.status });
480
+ }
481
+ } catch (err) {
482
+ processingErrors.push({
483
+ stage: 'compare-files',
484
+ file: record?.relativePath || record?.Name,
485
+ error: err.message
486
+ });
487
+ logger.error(`Error comparing file ${record?.relativePath}`, err);
488
+ }
254
489
 
255
- let resolutionMethod = 'skip';
490
+ processedCount++;
491
+ if (processedCount % 10 === 0 || i === allRecords.length - 1) {
492
+ progress.updateProgress('process', processedCount, totalToProcess, `Comparing files...`);
493
+ }
494
+ }
495
+ logger.info(`Completed file comparison - found ${issues.length} conflicts`);
256
496
 
257
- if (issues.length > 0) {
258
- resolutionMethod = await promptConflictResolution(issues);
259
- }
497
+ return { allRecords, issues, deletionLogs, processingErrors };
498
+ })();
499
+
500
+ // Show completion message with error count if any
501
+ if (processingErrors.length > 0) {
502
+ progress.completeStep('process', `⚠ Processed ${allRecords.length} records with ${processingErrors.length} errors`);
503
+ } else {
504
+ progress.completeStep('process', `✓ Processed ${allRecords.length} records, ${deletionLogs.length} deletions`);
505
+ }
506
+
507
+ // Note: Error preview will be shown in final summary, not here to avoid UI glitches
508
+ // Pause progress display for deletion logs and conflict resolution
509
+ progress.stopSpinner();
510
+
511
+ // Display deletion logs
512
+ for (const log of deletionLogs) {
513
+ if (log.type === 'folder') {
514
+ console.log(chalk.gray(` 🗑️ Removed deleted folder: ${path.relative(process.cwd(), log.path)}`));
515
+ } else if (log.type === 'file') {
516
+ console.log(chalk.gray(` 🗑️ Removed deleted file: ${path.relative(process.cwd(), log.path)}`));
517
+ } else if (log.type === 'entity') {
518
+ console.log(chalk.gray(` 🗑️ Removed deleted ${log.entityType}: ${path.relative(process.cwd(), log.path)}`));
519
+ } else if (log.type === 'error') {
520
+ console.warn(chalk.yellow(` ⚠️ Could not delete ${log.path}: ${log.error}`));
521
+ }
522
+ }
260
523
 
261
- // Step 5: Write all ActiveClass and ActivePage records
262
- await writeRecords(allRecords, resolutionMethod);
524
+ let resolutionMethod = 'skip';
263
525
 
264
- // Step 7: Success message
265
- console.log(`\n✅ Successfully pulled:`);
266
- console.log(` • ${activeClassResult.Records.length} ActiveClass records`);
267
- console.log(` • ${activePageResult.Records.length} ActivePage records`);
268
- console.log(`📁 Saved to: ./${EXPORT_ROOT}/ (organized into Controllers, Triggers, Classes, Pages)`);
269
- // console.log(`🔍 Tip: Run 'magentrix status' to see local vs server differences.`);
526
+ // Check for conflicts
527
+ progress.startStep('conflicts');
528
+ if (issues.length > 0) {
529
+ progress.stopSpinner();
530
+ resolutionMethod = await promptConflictResolution(issues);
531
+ progress.startSpinner();
532
+ }
533
+ progress.completeStep('conflicts', issues.length > 0 ? `✓ Resolved ${issues.length} conflicts` : '✓ No conflicts');
534
+
535
+ // Step 6: Write all ActiveClass and ActivePage records
536
+ progress.startStep('write');
537
+ await writeRecords(allRecords, resolutionMethod, progress, logger);
538
+ progress.completeStep('write', `✓ Wrote ${allRecords.length} files`);
539
+
540
+ // Step 7: Finish progress tracker
541
+ logger.info('Pull completed successfully');
542
+ logger.close();
543
+ progress.finish('Pull completed successfully!');
544
+
545
+ // Summary
546
+ console.log(chalk.bold(`Summary:`));
547
+ console.log(` • ${activeClassResult.Records.length} ActiveClass records`);
548
+ console.log(` • ${activePageResult.Records.length} ActivePage records`);
549
+ console.log(` • ${deletionLogs.length} deletions`);
550
+ console.log(`📁 Saved to: ./${EXPORT_ROOT}/`);
551
+
552
+ // Display log summary
553
+ logger.displaySummary();
554
+ } catch (error) {
555
+ logger.error('Pull operation failed', error);
556
+ logger.close();
557
+ progress.abort(error.message);
558
+
559
+ // Display log summary even on failure
560
+ logger.displaySummary();
561
+
562
+ console.error(chalk.red('\nPull failed. Please check the error above and the log file for details.'));
563
+ throw error;
564
+ }
270
565
  };