@magentrix-corp/magentrix-cli 1.3.16 → 1.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +25 -25
  2. package/README.md +1166 -1166
  3. package/actions/autopublish.old.js +293 -293
  4. package/actions/config.js +182 -182
  5. package/actions/create.js +466 -466
  6. package/actions/help.js +164 -164
  7. package/actions/iris/buildStage.js +874 -874
  8. package/actions/iris/delete.js +256 -256
  9. package/actions/iris/dev.js +391 -391
  10. package/actions/iris/index.js +6 -6
  11. package/actions/iris/link.js +375 -375
  12. package/actions/iris/recover.js +268 -268
  13. package/actions/main.js +80 -80
  14. package/actions/publish.js +1420 -1420
  15. package/actions/pull.js +684 -684
  16. package/actions/setup.js +148 -148
  17. package/actions/status.js +17 -17
  18. package/actions/update.js +248 -248
  19. package/bin/magentrix.js +393 -393
  20. package/package.json +55 -55
  21. package/utils/assetPaths.js +158 -158
  22. package/utils/autopublishLock.js +77 -77
  23. package/utils/cacher.js +206 -206
  24. package/utils/cli/checkInstanceUrl.js +76 -74
  25. package/utils/cli/helpers/compare.js +282 -282
  26. package/utils/cli/helpers/ensureApiKey.js +63 -63
  27. package/utils/cli/helpers/ensureCredentials.js +68 -68
  28. package/utils/cli/helpers/ensureInstanceUrl.js +75 -75
  29. package/utils/cli/writeRecords.js +262 -262
  30. package/utils/compare.js +135 -135
  31. package/utils/compress.js +17 -17
  32. package/utils/config.js +527 -527
  33. package/utils/debug.js +144 -144
  34. package/utils/diagnostics/testPublishLogic.js +96 -96
  35. package/utils/diff.js +49 -49
  36. package/utils/downloadAssets.js +291 -291
  37. package/utils/filetag.js +115 -115
  38. package/utils/hash.js +14 -14
  39. package/utils/iris/backup.js +411 -411
  40. package/utils/iris/builder.js +541 -541
  41. package/utils/iris/config-reader.js +664 -664
  42. package/utils/iris/deleteHelper.js +150 -150
  43. package/utils/iris/errors.js +537 -537
  44. package/utils/iris/linker.js +601 -601
  45. package/utils/iris/lock.js +360 -360
  46. package/utils/iris/validation.js +360 -360
  47. package/utils/iris/validator.js +281 -281
  48. package/utils/iris/zipper.js +248 -248
  49. package/utils/logger.js +291 -291
  50. package/utils/magentrix/api/assets.js +220 -220
  51. package/utils/magentrix/api/auth.js +107 -107
  52. package/utils/magentrix/api/createEntity.js +61 -61
  53. package/utils/magentrix/api/deleteEntity.js +55 -55
  54. package/utils/magentrix/api/iris.js +251 -251
  55. package/utils/magentrix/api/meqlQuery.js +36 -36
  56. package/utils/magentrix/api/retrieveEntity.js +86 -86
  57. package/utils/magentrix/api/updateEntity.js +66 -66
  58. package/utils/magentrix/fetch.js +168 -168
  59. package/utils/merge.js +22 -22
  60. package/utils/permissionError.js +70 -70
  61. package/utils/preferences.js +40 -40
  62. package/utils/progress.js +469 -469
  63. package/utils/spinner.js +43 -43
  64. package/utils/template.js +52 -52
  65. package/utils/updateFileBase.js +121 -121
  66. package/utils/workspaces.js +108 -108
  67. package/vars/config.js +11 -11
  68. package/vars/global.js +50 -50
package/actions/pull.js CHANGED
@@ -1,684 +1,684 @@
1
- import { ensureValidCredentials } from "../utils/cli/helpers/ensureCredentials.js";
2
- import debug from '../utils/debug.js';
3
- import Config from "../utils/config.js";
4
- import { meqlQuery } from "../utils/magentrix/api/meqlQuery.js";
5
- import fs from "fs";
6
- import { ProgressTracker } from "../utils/progress.js";
7
- import { createLogger, Logger } from "../utils/logger.js";
8
- import { EXPORT_ROOT, IRIS_APPS_DIR } from "../vars/global.js";
9
- import { mapRecordToFile, writeRecords } from "../utils/cli/writeRecords.js";
10
- import { updateBase, removeFromBaseBulk } from "../utils/updateFileBase.js";
11
- import { promptConflictResolution } from "../utils/cli/helpers/compare.js";
12
- import path from "path";
13
- import { compareLocalAndRemote } from "../utils/compare.js";
14
- import chalk from 'chalk';
15
- import { setFileTag } from "../utils/filetag.js";
16
- import { downloadAssets } from "../utils/downloadAssets.js";
17
- import { listApps, downloadApp } from "../utils/magentrix/api/iris.js";
18
- import { extractIrisZip, hashIrisAppFolder } from "../utils/iris/zipper.js";
19
- import readlineSync from 'readline-sync';
20
-
21
- const config = new Config();
22
-
23
- /**
24
- * Pulls all ActiveClass and ActivePage records from Magentrix,
25
- * saving them to categorized local directories with appropriate extensions.
26
- *
27
- * Output Structure:
28
- * /test/
29
- * Controllers/
30
- * Triggers/
31
- * Classes/
32
- * Pages/
33
- * <Name>.<ext>
34
- *
35
- * @async
36
- * @function pull
37
- * @returns {Promise<void>}
38
- */
39
- export const pull = async () => {
40
- // Clear the terminal
41
- process.stdout.write('\x1Bc');
42
-
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('download-iris', 'Downloading Iris apps...', { hasProgress: true });
59
- progress.addStep('process', 'Processing files...', { hasProgress: true });
60
- progress.addStep('conflicts', 'Checking for conflicts...');
61
- progress.addStep('write', 'Writing files...', { hasProgress: true });
62
- progress.start();
63
-
64
- try {
65
- // Step 1: Authenticate and retrieve instance URL and token
66
- progress.startStep('auth');
67
- debug.log('PULL', 'Step 1: Authenticating...');
68
- const { instanceUrl, token } = await ensureValidCredentials();
69
- debug.log('PULL', `Authenticated with instance: ${instanceUrl}`);
70
- progress.completeStep('auth', '✓ Authenticated');
71
-
72
- // Step 2: Check if instance URL has changed (credential switch detected)
73
- progress.startStep('check');
74
- const lastInstanceUrl = config.read('lastInstanceUrl', { global: false, filename: 'config.json' });
75
- const instanceChanged = lastInstanceUrl && lastInstanceUrl !== instanceUrl;
76
-
77
- if (instanceChanged) {
78
- progress.stopSpinner();
79
- progress.render();
80
- console.log(chalk.yellow.bold(`\n⚠️ INSTANCE CHANGE DETECTED`));
81
- console.log(chalk.yellow(`Previous instance: ${chalk.cyan(lastInstanceUrl)}`));
82
- console.log(chalk.yellow(`New instance: ${chalk.cyan(instanceUrl)}`));
83
- console.log();
84
- console.log(chalk.red.bold(`⚠️ WARNING: This will DELETE your existing ${chalk.white(EXPORT_ROOT + '/')} directory!`));
85
- console.log(chalk.gray(`This is necessary to prevent mixing files from different instances.`));
86
- console.log();
87
-
88
- const confirm = readlineSync.question(
89
- chalk.yellow(`Type ${chalk.white.bold('yes')} to continue and delete ${EXPORT_ROOT}/, or ${chalk.white.bold('no')} to cancel: `)
90
- );
91
-
92
- if (confirm.trim().toLowerCase() !== 'yes') {
93
- console.log(chalk.red('\n❌ Pull cancelled. No files were deleted.'));
94
- console.log(chalk.gray(`Tip: To pull from ${chalk.cyan(lastInstanceUrl)}, switch back to those credentials.`));
95
- process.exit(0);
96
- }
97
-
98
- console.log(chalk.yellow(`\n🗑️ Removing existing ${EXPORT_ROOT}/ directory...`));
99
- if (fs.existsSync(EXPORT_ROOT)) {
100
- fs.rmSync(EXPORT_ROOT, { recursive: true, force: true });
101
- }
102
- // Clear the base.json cache as well since it's from a different instance
103
- fs.writeFileSync('.magentrix/base.json', JSON.stringify({}));
104
- config.save('cachedFiles', {}, { filename: 'fileCache.json' });
105
- config.save('trackedFileTags', {}, { filename: 'fileIdIndex.json' });
106
- console.log(chalk.green(`✓ Removed ${EXPORT_ROOT}/ directory\n`));
107
-
108
- progress.start(); // Restart progress tracker
109
- progress.startStep('check');
110
- }
111
-
112
- // Save the current instance URL for future comparisons
113
- config.save('lastInstanceUrl', instanceUrl, { global: false, filename: 'config.json' });
114
- progress.completeStep('check', instanceChanged ? '✓ Instance reset' : '✓ Instance verified');
115
-
116
- // Step 3: Prepare queries for both ActiveClass and ActivePage
117
- const queries = [
118
- {
119
- name: "ActiveClass",
120
- query: "SELECT Id,Body,Name,CreatedOn,Description,ModifiedOn,Type FROM ActiveClass",
121
- contentField: "Body",
122
- },
123
- {
124
- name: "ActivePage",
125
- query: "SELECT Id,Content,Name,CreatedOn,Description,ModifiedOn,Type FROM ActivePage",
126
- contentField: "Content",
127
- }
128
- ];
129
-
130
- // Step 3: Load existing base.json to detect deletions
131
- progress.startStep('load');
132
- const hits = await config.searchObject({}, { filename: "base.json", global: false });
133
- const cachedResults = hits?.[0]?.value || {};
134
- // Include the base.json key (_baseKey) alongside each entry so we can
135
- // use the correct key for removeFromBaseBulk later. The keys may differ
136
- // from lastKnownActualPath/filePath due to path format inconsistencies.
137
- const cachedAssets = Object.entries(cachedResults)
138
- .filter(([, c]) => c.type === 'File' || c.type === 'Folder')
139
- .map(([key, c]) => ({ ...c, _baseKey: key }));
140
- progress.completeStep('load', `✓ Loaded ${Object.keys(cachedResults).length} cached entries`);
141
-
142
- // Step 4a: Download code entities (ActiveClass and ActivePage)
143
- progress.startStep('download-code');
144
- debug.log('PULL', 'Step 4a: Downloading code entities...');
145
- debug.log('PULL', `Queries: ${queries.map(q => q.query).join(' | ')}`);
146
-
147
- let activeClassResult, activePageResult;
148
- const codeDownloadErrors = [];
149
-
150
- try {
151
- logger.info('Starting code entity downloads');
152
- const meqlResults = await Promise.all(
153
- queries.map(q => meqlQuery(instanceUrl, token.value, q.query))
154
- );
155
-
156
- [activeClassResult, activePageResult] = meqlResults;
157
- const totalCodeEntities = activeClassResult.Records.length + activePageResult.Records.length;
158
-
159
- logger.info(`Downloaded ${totalCodeEntities} code entities`, {
160
- activeClass: activeClassResult.Records.length,
161
- activePage: activePageResult.Records.length
162
- });
163
-
164
- progress.updateProgress('download-code', totalCodeEntities, totalCodeEntities, `Downloaded ${totalCodeEntities} code entities`);
165
- progress.completeStep('download-code', `✓ Downloaded ${totalCodeEntities} code entities`);
166
- } catch (error) {
167
- logger.error('Error downloading code entities', error);
168
- codeDownloadErrors.push({ stage: 'code-download', error: error.message });
169
- progress.failStep('download-code', error.message);
170
-
171
- // Show error preview
172
- progress.stopSpinner();
173
- console.log('');
174
- console.log(chalk.red.bold('❌ Code Download Error:'));
175
- console.log(chalk.red(` ${error.message.substring(0, 200)}`));
176
- console.log(chalk.cyan(`\n 📄 Full details in log file`));
177
- console.log('');
178
-
179
- // Initialize empty data structures
180
- if (!activeClassResult) activeClassResult = { Records: [] };
181
- if (!activePageResult) activePageResult = { Records: [] };
182
-
183
- // Restart progress for next step
184
- progress.start();
185
- progress.startStep('download-assets');
186
- }
187
-
188
- // Step 4b: Download static assets
189
- if (!codeDownloadErrors.length) {
190
- progress.startStep('download-assets');
191
- }
192
-
193
- let assets;
194
- const assetDownloadErrors = [];
195
-
196
- debug.log('PULL', 'Step 4b: Downloading static assets...');
197
- try {
198
- logger.info('Starting static asset downloads');
199
- assets = await downloadAssets(instanceUrl, token.value, null, (current, total, message) => {
200
- progress.updateProgress('download-assets', current, total, message);
201
- }, logger);
202
-
203
- logger.info(`Downloaded ${assets.tree.length} asset folders`);
204
- progress.completeStep('download-assets', `✓ Downloaded ${assets.tree.length} asset folders`);
205
- } catch (error) {
206
- logger.error('Error downloading static assets', error);
207
- assetDownloadErrors.push({ stage: 'asset-download', error: error.message });
208
- progress.failStep('download-assets', error.message);
209
-
210
- // Show error preview
211
- progress.stopSpinner();
212
- console.log('');
213
- console.log(chalk.red.bold('❌ Asset Download Error:'));
214
- console.log(chalk.red(` ${error.message.substring(0, 200)}`));
215
- console.log(chalk.cyan(`\n 📄 Full details in log file`));
216
- console.log('');
217
-
218
- // Initialize empty data structure
219
- if (!assets) assets = { tree: [] };
220
-
221
- // Restart progress for next step
222
- progress.start();
223
- }
224
-
225
- // Step 4c: Download Iris apps
226
- progress.startStep('download-iris');
227
- let irisApps = [];
228
- const irisDownloadErrors = [];
229
-
230
- try {
231
- logger.info('Fetching Iris apps list');
232
- const irisListResult = await listApps(instanceUrl, token.value);
233
-
234
- if (irisListResult.success && irisListResult.apps && irisListResult.apps.length > 0) {
235
- irisApps = irisListResult.apps;
236
- const irisAppsDir = path.resolve(EXPORT_ROOT, IRIS_APPS_DIR);
237
-
238
- // Ensure iris-apps directory exists
239
- if (!fs.existsSync(irisAppsDir)) {
240
- fs.mkdirSync(irisAppsDir, { recursive: true });
241
- }
242
-
243
- logger.info(`Found ${irisApps.length} Iris apps to download`);
244
-
245
- for (let i = 0; i < irisApps.length; i++) {
246
- const app = irisApps[i];
247
- progress.updateProgress('download-iris', i + 1, irisApps.length, `Downloading ${app.folderName}...`);
248
-
249
- try {
250
- // Download the app as a buffer
251
- const { buffer } = await downloadApp(instanceUrl, token.value, app.folderName);
252
-
253
- // Extract to iris-apps directory
254
- const appDir = path.join(irisAppsDir, app.folderName);
255
-
256
- // Remove existing app directory if it exists
257
- if (fs.existsSync(appDir)) {
258
- fs.rmSync(appDir, { recursive: true, force: true });
259
- }
260
-
261
- // Extract the zip (it contains a folder with the app name)
262
- await extractIrisZip(buffer, irisAppsDir);
263
-
264
- // Calculate content hash for change detection
265
- const contentHash = hashIrisAppFolder(appDir);
266
-
267
- // Update base.json for this Iris app
268
- updateBase(appDir, {
269
- Id: `iris-app:${app.folderName}`,
270
- Type: 'IrisApp',
271
- folderName: app.folderName,
272
- uploadedOn: app.uploadedOn,
273
- modifiedOn: app.modifiedOn,
274
- size: app.size,
275
- contentHash // Store hash for change detection
276
- }, appDir);
277
-
278
- logger.info(`Downloaded Iris app: ${app.folderName}`);
279
- } catch (appError) {
280
- // Detect permission errors and provide helpful hint
281
- const isPermissionError = appError.code === 'EACCES' || appError.code === 'EPERM' ||
282
- appError.message?.includes('permission denied') || appError.message?.includes('EACCES');
283
-
284
- // Create a more informative error message
285
- const errorDetail = appError.message || String(appError);
286
- const errorMsg = `Failed to download Iris app ${app.folderName}: ${errorDetail}`;
287
-
288
- if (isPermissionError) {
289
- const hint = `Try: sudo chown -R $(whoami):staff "${irisAppsDir}"`;
290
- logger.error(errorMsg, appError, null, hint);
291
- irisDownloadErrors.push({ app: app.folderName, error: appError.message, isPermissionError: true, hint });
292
- progress.addIssue('error', errorMsg, hint);
293
- } else {
294
- logger.error(errorMsg, appError);
295
- irisDownloadErrors.push({ app: app.folderName, error: appError.message });
296
- progress.addIssue('error', errorMsg);
297
- }
298
- }
299
- }
300
-
301
- progress.completeStep('download-iris', `✓ Downloaded ${irisApps.length} Iris apps`);
302
- } else {
303
- progress.completeStep('download-iris', '✓ No Iris apps found');
304
- }
305
- } catch (error) {
306
- logger.error('Error fetching Iris apps', error);
307
- irisDownloadErrors.push({ stage: 'iris-list', error: error.message });
308
- progress.completeStep('download-iris', '⚠ Iris apps skipped (API may not be available)');
309
- }
310
-
311
- // Check if we have any data to continue with
312
- if (!activeClassResult?.Records?.length && !activePageResult?.Records?.length && !assets?.tree?.length) {
313
- throw new Error('Download failed completely. No data available to continue.');
314
- }
315
-
316
- // Step 5: Process files and detect changes
317
- progress.startStep('process');
318
- const { allRecords, issues, deletionLogs, processingErrors } = await (async () => {
319
- let processedCount = 0;
320
- const totalToProcess = cachedAssets.length + (activeClassResult.Records.length + activePageResult.Records.length);
321
- const processingErrors = [];
322
-
323
- // Collect all server asset paths
324
- const serverAssetPaths = new Set();
325
- const collectServerPaths = (records) => {
326
- try {
327
- for (const record of records) {
328
- const fullPath = path.join(EXPORT_ROOT, record?.Path);
329
- serverAssetPaths.add(path.normalize(fullPath));
330
-
331
- if (record?.Type === 'Folder' && record?.Children?.length > 0) {
332
- collectServerPaths(record.Children);
333
- }
334
- }
335
- } catch (err) {
336
- processingErrors.push({ stage: 'collect-paths', error: err.message });
337
- logger.error('Error collecting server paths', err);
338
- }
339
- };
340
- collectServerPaths(assets.tree);
341
- progress.updateProgress('process', ++processedCount, totalToProcess, 'Collecting server paths');
342
-
343
- // Detect and delete assets that were on server before but are now gone
344
- const assetsToDelete = [];
345
- for (const cached of cachedAssets) {
346
- const cachedPath = path.normalize(cached.lastKnownActualPath || cached.filePath || cached.lastKnownPath);
347
-
348
- // If this asset was in base.json but not returned from server, it was deleted
349
- if (!serverAssetPaths.has(cachedPath)) {
350
- assetsToDelete.push({ path: cachedPath, baseKey: cached._baseKey });
351
- }
352
-
353
- processedCount++;
354
- if (processedCount % 50 === 0) {
355
- progress.updateProgress('process', processedCount, totalToProcess, `Checking assets...`);
356
- }
357
- }
358
- progress.updateProgress('process', processedCount, totalToProcess, `Found ${assetsToDelete.length} assets to delete`);
359
-
360
- // Count total assets (for progress tracking)
361
- // Note: We don't update base.json for assets here because:
362
- // 1. Assets are already downloaded and written to disk
363
- // 2. Their base will be updated during the "Writing files" stage if modified
364
- // 3. Updating base.json thousands of times here is extremely slow
365
- logger.info('Counting assets in tree');
366
- let assetCount = 0;
367
- const countAssets = (records) => {
368
- for (const record of records) {
369
- assetCount++;
370
- if (record?.Type === 'Folder' && record?.Children?.length > 0) {
371
- countAssets(record.Children);
372
- }
373
- }
374
- }
375
-
376
- countAssets(assets.tree);
377
- logger.info(`Counted ${assetCount} total assets`);
378
- processedCount += assetCount;
379
- progress.updateProgress('process', processedCount, totalToProcess, `Processed ${assetCount} assets`);
380
-
381
- // Handle code entity (ActiveClass, ActivePage) deletions
382
- const activeClassRecords = (activeClassResult.Records || []).map(record => {
383
- record.Content = record.Body;
384
- delete record.Body;
385
- return record;
386
- });
387
-
388
- const activePageRecords = (activePageResult.Records || []);
389
- const allRecords = [...activeClassRecords, ...activePageRecords].map(mapRecordToFile);
390
-
391
- // Get all server record IDs
392
- const serverRecordIds = new Set([
393
- ...activeClassRecords.map(r => r.Id),
394
- ...activePageRecords.map(r => r.Id)
395
- ]);
396
-
397
- // Find code entities in base.json that are no longer on server
398
- // Exclude Files, Folders, and IrisApps - only ActiveClass and ActivePage
399
- const cachedCodeEntities = Object.values(cachedResults).filter(c =>
400
- c.type !== 'File' && c.type !== 'Folder' && c.type !== 'IrisApp' && c.recordId
401
- );
402
-
403
- const codeEntitiesToDelete = [];
404
- for (const cached of cachedCodeEntities) {
405
- // If this code entity was in base.json but not returned from server, it was deleted
406
- if (!serverRecordIds.has(cached.recordId)) {
407
- codeEntitiesToDelete.push(cached);
408
- }
409
- }
410
-
411
- // Delete local files/folders that were deleted on server
412
- logger.info(`Starting deletion of ${assetsToDelete.length} assets`);
413
- const deletionLogs = [];
414
- // Track base.json keys for entries that need removal
415
- const assetBaseKeysToRemove = [];
416
- for (const asset of assetsToDelete) {
417
- try {
418
- if (fs.existsSync(asset.path)) {
419
- const stats = fs.statSync(asset.path);
420
- if (stats.isDirectory()) {
421
- fs.rmSync(asset.path, { recursive: true, force: true });
422
- deletionLogs.push({ type: 'folder', path: asset.path });
423
- logger.info('Deleted folder', { path: asset.path });
424
- } else {
425
- fs.unlinkSync(asset.path);
426
- deletionLogs.push({ type: 'file', path: asset.path });
427
- logger.info('Deleted file', { path: asset.path });
428
- }
429
- }
430
- // Always remove from base.json regardless of whether the local file
431
- // existed — the server confirmed deletion, so the cache entry is stale.
432
- assetBaseKeysToRemove.push(asset.baseKey);
433
- } catch (err) {
434
- deletionLogs.push({ type: 'error', path: asset.path, error: err.message });
435
- logger.error(`Failed to delete asset: ${asset.path}`, err);
436
- }
437
- }
438
-
439
- // Bulk remove from base.json using the original base.json keys
440
- // (not the normalized paths, which may have a different format)
441
- if (assetBaseKeysToRemove.length > 0) {
442
- removeFromBaseBulk(assetBaseKeysToRemove);
443
- }
444
-
445
- // Delete local code entity files that were deleted on server
446
- logger.info(`Starting deletion of ${codeEntitiesToDelete.length} code entities`);
447
- for (const entity of codeEntitiesToDelete) {
448
- const entityPath = entity.filePath || entity.lastKnownPath;
449
- try {
450
- if (entityPath && fs.existsSync(entityPath)) {
451
- fs.unlinkSync(entityPath);
452
- deletionLogs.push({ type: 'entity', path: entityPath, entityType: entity.type });
453
- logger.info('Deleted code entity', { path: entityPath, type: entity.type });
454
- }
455
- } catch (err) {
456
- // Detect permission errors
457
- const isPermissionError = err.code === 'EACCES' || err.code === 'EPERM';
458
- deletionLogs.push({ type: 'error', path: entityPath, error: err.message, isPermissionError });
459
-
460
- if (isPermissionError) {
461
- const hint = `Try: sudo chown -R $(whoami):staff "${path.dirname(entityPath)}"`;
462
- logger.error(`Failed to delete code entity: ${entityPath}`, err, null, hint);
463
- progress.addIssue('error', `Failed to delete code entity: ${entityPath}`, hint);
464
- } else {
465
- logger.error(`Failed to delete code entity: ${entityPath}`, err);
466
- progress.addIssue('error', `Failed to delete code entity: ${entityPath}`);
467
- }
468
- }
469
- }
470
-
471
- // Bulk remove code entities from base.json
472
- const entityIdsToRemove = deletionLogs
473
- .filter(l => l.type === 'entity')
474
- .map(l => {
475
- // Find the entity object that corresponds to this path to get the ID
476
- const entity = codeEntitiesToDelete.find(e =>
477
- (e.filePath === l.path || e.lastKnownPath === l.path)
478
- );
479
- return entity ? entity.recordId : null;
480
- })
481
- .filter(id => id !== null);
482
-
483
- if (entityIdsToRemove.length > 0) {
484
- removeFromBaseBulk(entityIdsToRemove);
485
- }
486
- logger.info(`Completed deletions - ${deletionLogs.length} items deleted`);
487
- progress.updateProgress('process', processedCount, totalToProcess, `Deleted ${deletionLogs.length} items`);
488
-
489
- // Compare files and detect conflicts
490
- logger.info(`Starting file comparison for ${allRecords.length} records`);
491
- // --- Fix for Bug 1: Rename Tracking ---
492
- // Build a map of RecordId -> LocalPath from cached results
493
- const localPathMap = new Map();
494
- Object.values(cachedResults).forEach(entry => {
495
- if (entry.recordId && (entry.filePath || entry.lastKnownPath)) {
496
- localPathMap.set(entry.recordId, entry.filePath || entry.lastKnownPath);
497
- }
498
- });
499
-
500
- // Check for renames before comparison
501
- for (const record of allRecords) {
502
- if (localPathMap.has(record.Id)) {
503
- const oldPath = localPathMap.get(record.Id);
504
- const newPath = path.join(EXPORT_ROOT, record.relativePath);
505
-
506
- // Normalize paths for comparison
507
- let normalizedOld = path.normalize(path.resolve(oldPath));
508
- const normalizedNew = path.normalize(path.resolve(newPath));
509
-
510
- // Fix for path resolution: base.json might store paths relative to CWD or EXPORT_ROOT
511
- // If direct resolution fails, try prepending EXPORT_ROOT
512
- if (!fs.existsSync(normalizedOld)) {
513
- const withRoot = path.normalize(path.resolve(EXPORT_ROOT, oldPath));
514
- if (fs.existsSync(withRoot)) {
515
- normalizedOld = withRoot;
516
- }
517
- }
518
-
519
- if (normalizedOld !== normalizedNew && fs.existsSync(normalizedOld)) {
520
- try {
521
- logger.info(`Detected rename: ${path.relative(EXPORT_ROOT, oldPath)} -> ${record.relativePath}`);
522
-
523
- // Ensure target directory exists
524
- fs.mkdirSync(path.dirname(normalizedNew), { recursive: true });
525
-
526
- // Rename file
527
- fs.renameSync(normalizedOld, normalizedNew);
528
-
529
- // Update fileIdIndex.json to point to the new path
530
- // This must be done BEFORE writeRecords runs, otherwise findFileByTag
531
- // will return the old path and writeRecords will recreate the old file
532
- await setFileTag(normalizedNew, record.Id);
533
-
534
- // Update base.json immediately to reflect new path
535
- // We use the existing record data but update the path
536
- updateBase(record.relativePath, record, normalizedNew);
537
-
538
- // Remove old path from deletion logs if it was marked for deletion
539
- // (It might have been marked if the old filename wasn't in the server response)
540
- const deletionIndex = deletionLogs.findIndex(l => path.normalize(l.path) === normalizedOld);
541
- if (deletionIndex !== -1) {
542
- deletionLogs.splice(deletionIndex, 1);
543
- logger.info(`Cancelled deletion of renamed file: ${path.relative(EXPORT_ROOT, oldPath)}`);
544
- }
545
-
546
- console.log(chalk.cyan(` ↻ Renamed: ${path.relative(EXPORT_ROOT, oldPath)} -> ${record.relativePath}`));
547
- } catch (err) {
548
- logger.error(`Failed to rename file: ${oldPath} -> ${newPath}`, err);
549
- processingErrors.push({
550
- stage: 'rename',
551
- file: record.relativePath,
552
- error: `Rename failed: ${err.message}`
553
- });
554
- }
555
- }
556
- }
557
- }
558
- // --- End Fix for Bug 1 ---
559
-
560
- const issues = [];
561
- for (let i = 0; i < allRecords.length; i++) {
562
- const record = allRecords[i];
563
- try {
564
- if (record?.error) {
565
- logger.warning('Record has error', { record: record.Name || record.relativePath });
566
- processingErrors.push({
567
- stage: 'record-error',
568
- file: record.Name || record.relativePath,
569
- error: 'Record contains error flag'
570
- });
571
- continue;
572
- }
573
-
574
- // Log every 50th file to track progress
575
- if (i % 50 === 0) {
576
- logger.info(`Comparing file ${i + 1}/${allRecords.length}: ${record.relativePath}`);
577
- }
578
-
579
- const status = compareLocalAndRemote(
580
- path.join(EXPORT_ROOT, record.relativePath),
581
- { ...record, content: record.Content }
582
- );
583
-
584
- // Missing files will just be written
585
- if (!['in_sync', 'missing'].includes(status.status)) {
586
- issues.push({ relativePath: record.relativePath, status: status.status });
587
- logger.info('Conflict detected', { file: record.relativePath, status: status.status });
588
- }
589
- } catch (err) {
590
- processingErrors.push({
591
- stage: 'compare-files',
592
- file: record?.relativePath || record?.Name,
593
- error: err.message
594
- });
595
- logger.error(`Error comparing file ${record?.relativePath}`, err);
596
- }
597
-
598
- processedCount++;
599
- if (processedCount % 10 === 0 || i === allRecords.length - 1) {
600
- progress.updateProgress('process', processedCount, totalToProcess, `Comparing files...`);
601
- }
602
- }
603
- logger.info(`Completed file comparison - found ${issues.length} conflicts`);
604
-
605
- return { allRecords, issues, deletionLogs, processingErrors };
606
- })();
607
-
608
- // Show completion message with error count if any
609
- if (processingErrors.length > 0) {
610
- progress.completeStep('process', `⚠ Processed ${allRecords.length} records with ${processingErrors.length} errors`);
611
- } else {
612
- progress.completeStep('process', `✓ Processed ${allRecords.length} records, ${deletionLogs.length} deletions`);
613
- }
614
-
615
- // Note: Error preview will be shown in final summary, not here to avoid UI glitches
616
- // Pause progress display for deletion logs and conflict resolution
617
- progress.stopSpinner();
618
-
619
- // Display deletion logs
620
- for (const log of deletionLogs) {
621
- if (log.type === 'folder') {
622
- console.log(chalk.gray(` 🗑️ Removed deleted folder: ${path.relative(process.cwd(), log.path)}`));
623
- } else if (log.type === 'file') {
624
- console.log(chalk.gray(` 🗑️ Removed deleted file: ${path.relative(process.cwd(), log.path)}`));
625
- } else if (log.type === 'entity') {
626
- console.log(chalk.gray(` 🗑️ Removed deleted ${log.entityType}: ${path.relative(process.cwd(), log.path)}`));
627
- } else if (log.type === 'error') {
628
- console.warn(chalk.yellow(` ⚠️ Could not delete ${log.path}: ${log.error}`));
629
- }
630
- }
631
-
632
- let resolutionMethod = 'skip';
633
-
634
- // Check for conflicts
635
- progress.startStep('conflicts');
636
- if (issues.length > 0) {
637
- progress.stopSpinner();
638
- resolutionMethod = await promptConflictResolution(issues);
639
- progress.startSpinner();
640
- }
641
- progress.completeStep('conflicts', issues.length > 0 ? `✓ Resolved ${issues.length} conflicts` : '✓ No conflicts');
642
-
643
- // Step 6: Write all ActiveClass and ActivePage records
644
- progress.startStep('write');
645
- await writeRecords(allRecords, resolutionMethod, progress, logger);
646
- progress.completeStep('write', `✓ Wrote ${allRecords.length} files`);
647
-
648
- // Step 7: Finish progress tracker
649
- logger.info('Pull completed successfully');
650
- logger.close();
651
- debug.log('PULL', 'Pull completed successfully');
652
- debug.close();
653
- progress.finish('Pull completed successfully!');
654
-
655
- // Clear any incomplete-pull marker (set by vue-run-build when a pull is
656
- // started but cancelled/fails). A successful pull means we're fully synced.
657
- config.removeKey('pullIncomplete', { filename: 'config.json' });
658
-
659
- // Summary
660
- console.log(chalk.bold(`Summary:`));
661
- console.log(` • ${activeClassResult.Records.length} ActiveClass records`);
662
- console.log(` • ${activePageResult.Records.length} ActivePage records`);
663
- if (irisApps.length > 0) {
664
- console.log(` • ${irisApps.length} Iris apps`);
665
- }
666
- console.log(` • ${deletionLogs.length} deletions`);
667
- console.log(`📁 Saved to: ./${EXPORT_ROOT}/`);
668
-
669
- // Display log summary
670
- logger.displaySummary();
671
- } catch (error) {
672
- logger.error('Pull operation failed', error);
673
- logger.close();
674
- debug.log('PULL', `Pull failed: ${error.message}`, error.stack);
675
- debug.close();
676
- progress.abort(error.message);
677
-
678
- // Display log summary even on failure
679
- logger.displaySummary();
680
-
681
- console.error(chalk.red('\nPull failed. Please check the error above and the log file for details.'));
682
- throw error;
683
- }
684
- };
1
+ import { ensureValidCredentials } from "../utils/cli/helpers/ensureCredentials.js";
2
+ import debug from '../utils/debug.js';
3
+ import Config from "../utils/config.js";
4
+ import { meqlQuery } from "../utils/magentrix/api/meqlQuery.js";
5
+ import fs from "fs";
6
+ import { ProgressTracker } from "../utils/progress.js";
7
+ import { createLogger, Logger } from "../utils/logger.js";
8
+ import { EXPORT_ROOT, IRIS_APPS_DIR } from "../vars/global.js";
9
+ import { mapRecordToFile, writeRecords } from "../utils/cli/writeRecords.js";
10
+ import { updateBase, removeFromBaseBulk } from "../utils/updateFileBase.js";
11
+ import { promptConflictResolution } from "../utils/cli/helpers/compare.js";
12
+ import path from "path";
13
+ import { compareLocalAndRemote } from "../utils/compare.js";
14
+ import chalk from 'chalk';
15
+ import { setFileTag } from "../utils/filetag.js";
16
+ import { downloadAssets } from "../utils/downloadAssets.js";
17
+ import { listApps, downloadApp } from "../utils/magentrix/api/iris.js";
18
+ import { extractIrisZip, hashIrisAppFolder } from "../utils/iris/zipper.js";
19
+ import readlineSync from 'readline-sync';
20
+
21
+ const config = new Config();
22
+
23
+ /**
24
+ * Pulls all ActiveClass and ActivePage records from Magentrix,
25
+ * saving them to categorized local directories with appropriate extensions.
26
+ *
27
+ * Output Structure:
28
+ * /test/
29
+ * Controllers/
30
+ * Triggers/
31
+ * Classes/
32
+ * Pages/
33
+ * <Name>.<ext>
34
+ *
35
+ * @async
36
+ * @function pull
37
+ * @returns {Promise<void>}
38
+ */
39
+ export const pull = async () => {
40
+ // Clear the terminal
41
+ process.stdout.write('\x1Bc');
42
+
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('download-iris', 'Downloading Iris apps...', { hasProgress: true });
59
+ progress.addStep('process', 'Processing files...', { hasProgress: true });
60
+ progress.addStep('conflicts', 'Checking for conflicts...');
61
+ progress.addStep('write', 'Writing files...', { hasProgress: true });
62
+ progress.start();
63
+
64
+ try {
65
+ // Step 1: Authenticate and retrieve instance URL and token
66
+ progress.startStep('auth');
67
+ debug.log('PULL', 'Step 1: Authenticating...');
68
+ const { instanceUrl, token } = await ensureValidCredentials();
69
+ debug.log('PULL', `Authenticated with instance: ${instanceUrl}`);
70
+ progress.completeStep('auth', '✓ Authenticated');
71
+
72
+ // Step 2: Check if instance URL has changed (credential switch detected)
73
+ progress.startStep('check');
74
+ const lastInstanceUrl = config.read('lastInstanceUrl', { global: false, filename: 'config.json' });
75
+ const instanceChanged = lastInstanceUrl && lastInstanceUrl !== instanceUrl;
76
+
77
+ if (instanceChanged) {
78
+ progress.stopSpinner();
79
+ progress.render();
80
+ console.log(chalk.yellow.bold(`\n⚠️ INSTANCE CHANGE DETECTED`));
81
+ console.log(chalk.yellow(`Previous instance: ${chalk.cyan(lastInstanceUrl)}`));
82
+ console.log(chalk.yellow(`New instance: ${chalk.cyan(instanceUrl)}`));
83
+ console.log();
84
+ console.log(chalk.red.bold(`⚠️ WARNING: This will DELETE your existing ${chalk.white(EXPORT_ROOT + '/')} directory!`));
85
+ console.log(chalk.gray(`This is necessary to prevent mixing files from different instances.`));
86
+ console.log();
87
+
88
+ const confirm = readlineSync.question(
89
+ chalk.yellow(`Type ${chalk.white.bold('yes')} to continue and delete ${EXPORT_ROOT}/, or ${chalk.white.bold('no')} to cancel: `)
90
+ );
91
+
92
+ if (confirm.trim().toLowerCase() !== 'yes') {
93
+ console.log(chalk.red('\n❌ Pull cancelled. No files were deleted.'));
94
+ console.log(chalk.gray(`Tip: To pull from ${chalk.cyan(lastInstanceUrl)}, switch back to those credentials.`));
95
+ process.exit(0);
96
+ }
97
+
98
+ console.log(chalk.yellow(`\n🗑️ Removing existing ${EXPORT_ROOT}/ directory...`));
99
+ if (fs.existsSync(EXPORT_ROOT)) {
100
+ fs.rmSync(EXPORT_ROOT, { recursive: true, force: true });
101
+ }
102
+ // Clear the base.json cache as well since it's from a different instance
103
+ fs.writeFileSync('.magentrix/base.json', JSON.stringify({}));
104
+ config.save('cachedFiles', {}, { filename: 'fileCache.json' });
105
+ config.save('trackedFileTags', {}, { filename: 'fileIdIndex.json' });
106
+ console.log(chalk.green(`✓ Removed ${EXPORT_ROOT}/ directory\n`));
107
+
108
+ progress.start(); // Restart progress tracker
109
+ progress.startStep('check');
110
+ }
111
+
112
+ // Save the current instance URL for future comparisons
113
+ config.save('lastInstanceUrl', instanceUrl, { global: false, filename: 'config.json' });
114
+ progress.completeStep('check', instanceChanged ? '✓ Instance reset' : '✓ Instance verified');
115
+
116
+ // Step 3: Prepare queries for both ActiveClass and ActivePage
117
+ const queries = [
118
+ {
119
+ name: "ActiveClass",
120
+ query: "SELECT Id,Body,Name,CreatedOn,Description,ModifiedOn,Type FROM ActiveClass",
121
+ contentField: "Body",
122
+ },
123
+ {
124
+ name: "ActivePage",
125
+ query: "SELECT Id,Content,Name,CreatedOn,Description,ModifiedOn,Type FROM ActivePage",
126
+ contentField: "Content",
127
+ }
128
+ ];
129
+
130
+ // Step 3: Load existing base.json to detect deletions
131
+ progress.startStep('load');
132
+ const hits = await config.searchObject({}, { filename: "base.json", global: false });
133
+ const cachedResults = hits?.[0]?.value || {};
134
+ // Include the base.json key (_baseKey) alongside each entry so we can
135
+ // use the correct key for removeFromBaseBulk later. The keys may differ
136
+ // from lastKnownActualPath/filePath due to path format inconsistencies.
137
+ const cachedAssets = Object.entries(cachedResults)
138
+ .filter(([, c]) => c.type === 'File' || c.type === 'Folder')
139
+ .map(([key, c]) => ({ ...c, _baseKey: key }));
140
+ progress.completeStep('load', `✓ Loaded ${Object.keys(cachedResults).length} cached entries`);
141
+
142
+ // Step 4a: Download code entities (ActiveClass and ActivePage)
143
+ progress.startStep('download-code');
144
+ debug.log('PULL', 'Step 4a: Downloading code entities...');
145
+ debug.log('PULL', `Queries: ${queries.map(q => q.query).join(' | ')}`);
146
+
147
+ let activeClassResult, activePageResult;
148
+ const codeDownloadErrors = [];
149
+
150
+ try {
151
+ logger.info('Starting code entity downloads');
152
+ const meqlResults = await Promise.all(
153
+ queries.map(q => meqlQuery(instanceUrl, token.value, q.query))
154
+ );
155
+
156
+ [activeClassResult, activePageResult] = meqlResults;
157
+ const totalCodeEntities = activeClassResult.Records.length + activePageResult.Records.length;
158
+
159
+ logger.info(`Downloaded ${totalCodeEntities} code entities`, {
160
+ activeClass: activeClassResult.Records.length,
161
+ activePage: activePageResult.Records.length
162
+ });
163
+
164
+ progress.updateProgress('download-code', totalCodeEntities, totalCodeEntities, `Downloaded ${totalCodeEntities} code entities`);
165
+ progress.completeStep('download-code', `✓ Downloaded ${totalCodeEntities} code entities`);
166
+ } catch (error) {
167
+ logger.error('Error downloading code entities', error);
168
+ codeDownloadErrors.push({ stage: 'code-download', error: error.message });
169
+ progress.failStep('download-code', error.message);
170
+
171
+ // Show error preview
172
+ progress.stopSpinner();
173
+ console.log('');
174
+ console.log(chalk.red.bold('❌ Code Download Error:'));
175
+ console.log(chalk.red(` ${error.message.substring(0, 200)}`));
176
+ console.log(chalk.cyan(`\n 📄 Full details in log file`));
177
+ console.log('');
178
+
179
+ // Initialize empty data structures
180
+ if (!activeClassResult) activeClassResult = { Records: [] };
181
+ if (!activePageResult) activePageResult = { Records: [] };
182
+
183
+ // Restart progress for next step
184
+ progress.start();
185
+ progress.startStep('download-assets');
186
+ }
187
+
188
+ // Step 4b: Download static assets
189
+ if (!codeDownloadErrors.length) {
190
+ progress.startStep('download-assets');
191
+ }
192
+
193
+ let assets;
194
+ const assetDownloadErrors = [];
195
+
196
+ debug.log('PULL', 'Step 4b: Downloading static assets...');
197
+ try {
198
+ logger.info('Starting static asset downloads');
199
+ assets = await downloadAssets(instanceUrl, token.value, null, (current, total, message) => {
200
+ progress.updateProgress('download-assets', current, total, message);
201
+ }, logger);
202
+
203
+ logger.info(`Downloaded ${assets.tree.length} asset folders`);
204
+ progress.completeStep('download-assets', `✓ Downloaded ${assets.tree.length} asset folders`);
205
+ } catch (error) {
206
+ logger.error('Error downloading static assets', error);
207
+ assetDownloadErrors.push({ stage: 'asset-download', error: error.message });
208
+ progress.failStep('download-assets', error.message);
209
+
210
+ // Show error preview
211
+ progress.stopSpinner();
212
+ console.log('');
213
+ console.log(chalk.red.bold('❌ Asset Download Error:'));
214
+ console.log(chalk.red(` ${error.message.substring(0, 200)}`));
215
+ console.log(chalk.cyan(`\n 📄 Full details in log file`));
216
+ console.log('');
217
+
218
+ // Initialize empty data structure
219
+ if (!assets) assets = { tree: [] };
220
+
221
+ // Restart progress for next step
222
+ progress.start();
223
+ }
224
+
225
+ // Step 4c: Download Iris apps
226
+ progress.startStep('download-iris');
227
+ let irisApps = [];
228
+ const irisDownloadErrors = [];
229
+
230
+ try {
231
+ logger.info('Fetching Iris apps list');
232
+ const irisListResult = await listApps(instanceUrl, token.value);
233
+
234
+ if (irisListResult.success && irisListResult.apps && irisListResult.apps.length > 0) {
235
+ irisApps = irisListResult.apps;
236
+ const irisAppsDir = path.resolve(EXPORT_ROOT, IRIS_APPS_DIR);
237
+
238
+ // Ensure iris-apps directory exists
239
+ if (!fs.existsSync(irisAppsDir)) {
240
+ fs.mkdirSync(irisAppsDir, { recursive: true });
241
+ }
242
+
243
+ logger.info(`Found ${irisApps.length} Iris apps to download`);
244
+
245
+ for (let i = 0; i < irisApps.length; i++) {
246
+ const app = irisApps[i];
247
+ progress.updateProgress('download-iris', i + 1, irisApps.length, `Downloading ${app.folderName}...`);
248
+
249
+ try {
250
+ // Download the app as a buffer
251
+ const { buffer } = await downloadApp(instanceUrl, token.value, app.folderName);
252
+
253
+ // Extract to iris-apps directory
254
+ const appDir = path.join(irisAppsDir, app.folderName);
255
+
256
+ // Remove existing app directory if it exists
257
+ if (fs.existsSync(appDir)) {
258
+ fs.rmSync(appDir, { recursive: true, force: true });
259
+ }
260
+
261
+ // Extract the zip (it contains a folder with the app name)
262
+ await extractIrisZip(buffer, irisAppsDir);
263
+
264
+ // Calculate content hash for change detection
265
+ const contentHash = hashIrisAppFolder(appDir);
266
+
267
+ // Update base.json for this Iris app
268
+ updateBase(appDir, {
269
+ Id: `iris-app:${app.folderName}`,
270
+ Type: 'IrisApp',
271
+ folderName: app.folderName,
272
+ uploadedOn: app.uploadedOn,
273
+ modifiedOn: app.modifiedOn,
274
+ size: app.size,
275
+ contentHash // Store hash for change detection
276
+ }, appDir);
277
+
278
+ logger.info(`Downloaded Iris app: ${app.folderName}`);
279
+ } catch (appError) {
280
+ // Detect permission errors and provide helpful hint
281
+ const isPermissionError = appError.code === 'EACCES' || appError.code === 'EPERM' ||
282
+ appError.message?.includes('permission denied') || appError.message?.includes('EACCES');
283
+
284
+ // Create a more informative error message
285
+ const errorDetail = appError.message || String(appError);
286
+ const errorMsg = `Failed to download Iris app ${app.folderName}: ${errorDetail}`;
287
+
288
+ if (isPermissionError) {
289
+ const hint = `Try: sudo chown -R $(whoami):staff "${irisAppsDir}"`;
290
+ logger.error(errorMsg, appError, null, hint);
291
+ irisDownloadErrors.push({ app: app.folderName, error: appError.message, isPermissionError: true, hint });
292
+ progress.addIssue('error', errorMsg, hint);
293
+ } else {
294
+ logger.error(errorMsg, appError);
295
+ irisDownloadErrors.push({ app: app.folderName, error: appError.message });
296
+ progress.addIssue('error', errorMsg);
297
+ }
298
+ }
299
+ }
300
+
301
+ progress.completeStep('download-iris', `✓ Downloaded ${irisApps.length} Iris apps`);
302
+ } else {
303
+ progress.completeStep('download-iris', '✓ No Iris apps found');
304
+ }
305
+ } catch (error) {
306
+ logger.error('Error fetching Iris apps', error);
307
+ irisDownloadErrors.push({ stage: 'iris-list', error: error.message });
308
+ progress.completeStep('download-iris', '⚠ Iris apps skipped (API may not be available)');
309
+ }
310
+
311
+ // Check if we have any data to continue with
312
+ if (!activeClassResult?.Records?.length && !activePageResult?.Records?.length && !assets?.tree?.length) {
313
+ throw new Error('Download failed completely. No data available to continue.');
314
+ }
315
+
316
+ // Step 5: Process files and detect changes
317
+ progress.startStep('process');
318
+ const { allRecords, issues, deletionLogs, processingErrors } = await (async () => {
319
+ let processedCount = 0;
320
+ const totalToProcess = cachedAssets.length + (activeClassResult.Records.length + activePageResult.Records.length);
321
+ const processingErrors = [];
322
+
323
+ // Collect all server asset paths
324
+ const serverAssetPaths = new Set();
325
+ const collectServerPaths = (records) => {
326
+ try {
327
+ for (const record of records) {
328
+ const fullPath = path.join(EXPORT_ROOT, record?.Path);
329
+ serverAssetPaths.add(path.normalize(fullPath));
330
+
331
+ if (record?.Type === 'Folder' && record?.Children?.length > 0) {
332
+ collectServerPaths(record.Children);
333
+ }
334
+ }
335
+ } catch (err) {
336
+ processingErrors.push({ stage: 'collect-paths', error: err.message });
337
+ logger.error('Error collecting server paths', err);
338
+ }
339
+ };
340
+ collectServerPaths(assets.tree);
341
+ progress.updateProgress('process', ++processedCount, totalToProcess, 'Collecting server paths');
342
+
343
+ // Detect and delete assets that were on server before but are now gone
344
+ const assetsToDelete = [];
345
+ for (const cached of cachedAssets) {
346
+ const cachedPath = path.normalize(cached.lastKnownActualPath || cached.filePath || cached.lastKnownPath);
347
+
348
+ // If this asset was in base.json but not returned from server, it was deleted
349
+ if (!serverAssetPaths.has(cachedPath)) {
350
+ assetsToDelete.push({ path: cachedPath, baseKey: cached._baseKey });
351
+ }
352
+
353
+ processedCount++;
354
+ if (processedCount % 50 === 0) {
355
+ progress.updateProgress('process', processedCount, totalToProcess, `Checking assets...`);
356
+ }
357
+ }
358
+ progress.updateProgress('process', processedCount, totalToProcess, `Found ${assetsToDelete.length} assets to delete`);
359
+
360
+ // Count total assets (for progress tracking)
361
+ // Note: We don't update base.json for assets here because:
362
+ // 1. Assets are already downloaded and written to disk
363
+ // 2. Their base will be updated during the "Writing files" stage if modified
364
+ // 3. Updating base.json thousands of times here is extremely slow
365
+ logger.info('Counting assets in tree');
366
+ let assetCount = 0;
367
+ const countAssets = (records) => {
368
+ for (const record of records) {
369
+ assetCount++;
370
+ if (record?.Type === 'Folder' && record?.Children?.length > 0) {
371
+ countAssets(record.Children);
372
+ }
373
+ }
374
+ }
375
+
376
+ countAssets(assets.tree);
377
+ logger.info(`Counted ${assetCount} total assets`);
378
+ processedCount += assetCount;
379
+ progress.updateProgress('process', processedCount, totalToProcess, `Processed ${assetCount} assets`);
380
+
381
+ // Handle code entity (ActiveClass, ActivePage) deletions
382
+ const activeClassRecords = (activeClassResult.Records || []).map(record => {
383
+ record.Content = record.Body;
384
+ delete record.Body;
385
+ return record;
386
+ });
387
+
388
+ const activePageRecords = (activePageResult.Records || []);
389
+ const allRecords = [...activeClassRecords, ...activePageRecords].map(mapRecordToFile);
390
+
391
+ // Get all server record IDs
392
+ const serverRecordIds = new Set([
393
+ ...activeClassRecords.map(r => r.Id),
394
+ ...activePageRecords.map(r => r.Id)
395
+ ]);
396
+
397
+ // Find code entities in base.json that are no longer on server
398
+ // Exclude Files, Folders, and IrisApps - only ActiveClass and ActivePage
399
+ const cachedCodeEntities = Object.values(cachedResults).filter(c =>
400
+ c.type !== 'File' && c.type !== 'Folder' && c.type !== 'IrisApp' && c.recordId
401
+ );
402
+
403
+ const codeEntitiesToDelete = [];
404
+ for (const cached of cachedCodeEntities) {
405
+ // If this code entity was in base.json but not returned from server, it was deleted
406
+ if (!serverRecordIds.has(cached.recordId)) {
407
+ codeEntitiesToDelete.push(cached);
408
+ }
409
+ }
410
+
411
+ // Delete local files/folders that were deleted on server
412
+ logger.info(`Starting deletion of ${assetsToDelete.length} assets`);
413
+ const deletionLogs = [];
414
+ // Track base.json keys for entries that need removal
415
+ const assetBaseKeysToRemove = [];
416
+ for (const asset of assetsToDelete) {
417
+ try {
418
+ if (fs.existsSync(asset.path)) {
419
+ const stats = fs.statSync(asset.path);
420
+ if (stats.isDirectory()) {
421
+ fs.rmSync(asset.path, { recursive: true, force: true });
422
+ deletionLogs.push({ type: 'folder', path: asset.path });
423
+ logger.info('Deleted folder', { path: asset.path });
424
+ } else {
425
+ fs.unlinkSync(asset.path);
426
+ deletionLogs.push({ type: 'file', path: asset.path });
427
+ logger.info('Deleted file', { path: asset.path });
428
+ }
429
+ }
430
+ // Always remove from base.json regardless of whether the local file
431
+ // existed — the server confirmed deletion, so the cache entry is stale.
432
+ assetBaseKeysToRemove.push(asset.baseKey);
433
+ } catch (err) {
434
+ deletionLogs.push({ type: 'error', path: asset.path, error: err.message });
435
+ logger.error(`Failed to delete asset: ${asset.path}`, err);
436
+ }
437
+ }
438
+
439
+ // Bulk remove from base.json using the original base.json keys
440
+ // (not the normalized paths, which may have a different format)
441
+ if (assetBaseKeysToRemove.length > 0) {
442
+ removeFromBaseBulk(assetBaseKeysToRemove);
443
+ }
444
+
445
+ // Delete local code entity files that were deleted on server
446
+ logger.info(`Starting deletion of ${codeEntitiesToDelete.length} code entities`);
447
+ for (const entity of codeEntitiesToDelete) {
448
+ const entityPath = entity.filePath || entity.lastKnownPath;
449
+ try {
450
+ if (entityPath && fs.existsSync(entityPath)) {
451
+ fs.unlinkSync(entityPath);
452
+ deletionLogs.push({ type: 'entity', path: entityPath, entityType: entity.type });
453
+ logger.info('Deleted code entity', { path: entityPath, type: entity.type });
454
+ }
455
+ } catch (err) {
456
+ // Detect permission errors
457
+ const isPermissionError = err.code === 'EACCES' || err.code === 'EPERM';
458
+ deletionLogs.push({ type: 'error', path: entityPath, error: err.message, isPermissionError });
459
+
460
+ if (isPermissionError) {
461
+ const hint = `Try: sudo chown -R $(whoami):staff "${path.dirname(entityPath)}"`;
462
+ logger.error(`Failed to delete code entity: ${entityPath}`, err, null, hint);
463
+ progress.addIssue('error', `Failed to delete code entity: ${entityPath}`, hint);
464
+ } else {
465
+ logger.error(`Failed to delete code entity: ${entityPath}`, err);
466
+ progress.addIssue('error', `Failed to delete code entity: ${entityPath}`);
467
+ }
468
+ }
469
+ }
470
+
471
+ // Bulk remove code entities from base.json
472
+ const entityIdsToRemove = deletionLogs
473
+ .filter(l => l.type === 'entity')
474
+ .map(l => {
475
+ // Find the entity object that corresponds to this path to get the ID
476
+ const entity = codeEntitiesToDelete.find(e =>
477
+ (e.filePath === l.path || e.lastKnownPath === l.path)
478
+ );
479
+ return entity ? entity.recordId : null;
480
+ })
481
+ .filter(id => id !== null);
482
+
483
+ if (entityIdsToRemove.length > 0) {
484
+ removeFromBaseBulk(entityIdsToRemove);
485
+ }
486
+ logger.info(`Completed deletions - ${deletionLogs.length} items deleted`);
487
+ progress.updateProgress('process', processedCount, totalToProcess, `Deleted ${deletionLogs.length} items`);
488
+
489
+ // Compare files and detect conflicts
490
+ logger.info(`Starting file comparison for ${allRecords.length} records`);
491
+ // --- Fix for Bug 1: Rename Tracking ---
492
+ // Build a map of RecordId -> LocalPath from cached results
493
+ const localPathMap = new Map();
494
+ Object.values(cachedResults).forEach(entry => {
495
+ if (entry.recordId && (entry.filePath || entry.lastKnownPath)) {
496
+ localPathMap.set(entry.recordId, entry.filePath || entry.lastKnownPath);
497
+ }
498
+ });
499
+
500
+ // Check for renames before comparison
501
+ for (const record of allRecords) {
502
+ if (localPathMap.has(record.Id)) {
503
+ const oldPath = localPathMap.get(record.Id);
504
+ const newPath = path.join(EXPORT_ROOT, record.relativePath);
505
+
506
+ // Normalize paths for comparison
507
+ let normalizedOld = path.normalize(path.resolve(oldPath));
508
+ const normalizedNew = path.normalize(path.resolve(newPath));
509
+
510
+ // Fix for path resolution: base.json might store paths relative to CWD or EXPORT_ROOT
511
+ // If direct resolution fails, try prepending EXPORT_ROOT
512
+ if (!fs.existsSync(normalizedOld)) {
513
+ const withRoot = path.normalize(path.resolve(EXPORT_ROOT, oldPath));
514
+ if (fs.existsSync(withRoot)) {
515
+ normalizedOld = withRoot;
516
+ }
517
+ }
518
+
519
+ if (normalizedOld !== normalizedNew && fs.existsSync(normalizedOld)) {
520
+ try {
521
+ logger.info(`Detected rename: ${path.relative(EXPORT_ROOT, oldPath)} -> ${record.relativePath}`);
522
+
523
+ // Ensure target directory exists
524
+ fs.mkdirSync(path.dirname(normalizedNew), { recursive: true });
525
+
526
+ // Rename file
527
+ fs.renameSync(normalizedOld, normalizedNew);
528
+
529
+ // Update fileIdIndex.json to point to the new path
530
+ // This must be done BEFORE writeRecords runs, otherwise findFileByTag
531
+ // will return the old path and writeRecords will recreate the old file
532
+ await setFileTag(normalizedNew, record.Id);
533
+
534
+ // Update base.json immediately to reflect new path
535
+ // We use the existing record data but update the path
536
+ updateBase(record.relativePath, record, normalizedNew);
537
+
538
+ // Remove old path from deletion logs if it was marked for deletion
539
+ // (It might have been marked if the old filename wasn't in the server response)
540
+ const deletionIndex = deletionLogs.findIndex(l => path.normalize(l.path) === normalizedOld);
541
+ if (deletionIndex !== -1) {
542
+ deletionLogs.splice(deletionIndex, 1);
543
+ logger.info(`Cancelled deletion of renamed file: ${path.relative(EXPORT_ROOT, oldPath)}`);
544
+ }
545
+
546
+ console.log(chalk.cyan(` ↻ Renamed: ${path.relative(EXPORT_ROOT, oldPath)} -> ${record.relativePath}`));
547
+ } catch (err) {
548
+ logger.error(`Failed to rename file: ${oldPath} -> ${newPath}`, err);
549
+ processingErrors.push({
550
+ stage: 'rename',
551
+ file: record.relativePath,
552
+ error: `Rename failed: ${err.message}`
553
+ });
554
+ }
555
+ }
556
+ }
557
+ }
558
+ // --- End Fix for Bug 1 ---
559
+
560
+ const issues = [];
561
+ for (let i = 0; i < allRecords.length; i++) {
562
+ const record = allRecords[i];
563
+ try {
564
+ if (record?.error) {
565
+ logger.warning('Record has error', { record: record.Name || record.relativePath });
566
+ processingErrors.push({
567
+ stage: 'record-error',
568
+ file: record.Name || record.relativePath,
569
+ error: 'Record contains error flag'
570
+ });
571
+ continue;
572
+ }
573
+
574
+ // Log every 50th file to track progress
575
+ if (i % 50 === 0) {
576
+ logger.info(`Comparing file ${i + 1}/${allRecords.length}: ${record.relativePath}`);
577
+ }
578
+
579
+ const status = compareLocalAndRemote(
580
+ path.join(EXPORT_ROOT, record.relativePath),
581
+ { ...record, content: record.Content }
582
+ );
583
+
584
+ // Missing files will just be written
585
+ if (!['in_sync', 'missing'].includes(status.status)) {
586
+ issues.push({ relativePath: record.relativePath, status: status.status });
587
+ logger.info('Conflict detected', { file: record.relativePath, status: status.status });
588
+ }
589
+ } catch (err) {
590
+ processingErrors.push({
591
+ stage: 'compare-files',
592
+ file: record?.relativePath || record?.Name,
593
+ error: err.message
594
+ });
595
+ logger.error(`Error comparing file ${record?.relativePath}`, err);
596
+ }
597
+
598
+ processedCount++;
599
+ if (processedCount % 10 === 0 || i === allRecords.length - 1) {
600
+ progress.updateProgress('process', processedCount, totalToProcess, `Comparing files...`);
601
+ }
602
+ }
603
+ logger.info(`Completed file comparison - found ${issues.length} conflicts`);
604
+
605
+ return { allRecords, issues, deletionLogs, processingErrors };
606
+ })();
607
+
608
+ // Show completion message with error count if any
609
+ if (processingErrors.length > 0) {
610
+ progress.completeStep('process', `⚠ Processed ${allRecords.length} records with ${processingErrors.length} errors`);
611
+ } else {
612
+ progress.completeStep('process', `✓ Processed ${allRecords.length} records, ${deletionLogs.length} deletions`);
613
+ }
614
+
615
+ // Note: Error preview will be shown in final summary, not here to avoid UI glitches
616
+ // Pause progress display for deletion logs and conflict resolution
617
+ progress.stopSpinner();
618
+
619
+ // Display deletion logs
620
+ for (const log of deletionLogs) {
621
+ if (log.type === 'folder') {
622
+ console.log(chalk.gray(` 🗑️ Removed deleted folder: ${path.relative(process.cwd(), log.path)}`));
623
+ } else if (log.type === 'file') {
624
+ console.log(chalk.gray(` 🗑️ Removed deleted file: ${path.relative(process.cwd(), log.path)}`));
625
+ } else if (log.type === 'entity') {
626
+ console.log(chalk.gray(` 🗑️ Removed deleted ${log.entityType}: ${path.relative(process.cwd(), log.path)}`));
627
+ } else if (log.type === 'error') {
628
+ console.warn(chalk.yellow(` ⚠️ Could not delete ${log.path}: ${log.error}`));
629
+ }
630
+ }
631
+
632
+ let resolutionMethod = 'skip';
633
+
634
+ // Check for conflicts
635
+ progress.startStep('conflicts');
636
+ if (issues.length > 0) {
637
+ progress.stopSpinner();
638
+ resolutionMethod = await promptConflictResolution(issues);
639
+ progress.startSpinner();
640
+ }
641
+ progress.completeStep('conflicts', issues.length > 0 ? `✓ Resolved ${issues.length} conflicts` : '✓ No conflicts');
642
+
643
+ // Step 6: Write all ActiveClass and ActivePage records
644
+ progress.startStep('write');
645
+ await writeRecords(allRecords, resolutionMethod, progress, logger);
646
+ progress.completeStep('write', `✓ Wrote ${allRecords.length} files`);
647
+
648
+ // Step 7: Finish progress tracker
649
+ logger.info('Pull completed successfully');
650
+ logger.close();
651
+ debug.log('PULL', 'Pull completed successfully');
652
+ debug.close();
653
+ progress.finish('Pull completed successfully!');
654
+
655
+ // Clear any incomplete-pull marker (set by vue-run-build when a pull is
656
+ // started but cancelled/fails). A successful pull means we're fully synced.
657
+ config.removeKey('pullIncomplete', { filename: 'config.json' });
658
+
659
+ // Summary
660
+ console.log(chalk.bold(`Summary:`));
661
+ console.log(` • ${activeClassResult.Records.length} ActiveClass records`);
662
+ console.log(` • ${activePageResult.Records.length} ActivePage records`);
663
+ if (irisApps.length > 0) {
664
+ console.log(` • ${irisApps.length} Iris apps`);
665
+ }
666
+ console.log(` • ${deletionLogs.length} deletions`);
667
+ console.log(`📁 Saved to: ./${EXPORT_ROOT}/`);
668
+
669
+ // Display log summary
670
+ logger.displaySummary();
671
+ } catch (error) {
672
+ logger.error('Pull operation failed', error);
673
+ logger.close();
674
+ debug.log('PULL', `Pull failed: ${error.message}`, error.stack);
675
+ debug.close();
676
+ progress.abort(error.message);
677
+
678
+ // Display log summary even on failure
679
+ logger.displaySummary();
680
+
681
+ console.error(chalk.red('\nPull failed. Please check the error above and the log file for details.'));
682
+ throw error;
683
+ }
684
+ };