@magentrix-corp/magentrix-cli 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/actions/pull.js CHANGED
@@ -5,16 +5,18 @@ import fs from "fs";
5
5
  import { withSpinner } from "../utils/spinner.js";
6
6
  import { ProgressTracker } from "../utils/progress.js";
7
7
  import { createLogger, Logger } from "../utils/logger.js";
8
- import { EXPORT_ROOT, TYPE_DIR_MAP } from "../vars/global.js";
8
+ import { EXPORT_ROOT, TYPE_DIR_MAP, IRIS_APPS_DIR } from "../vars/global.js";
9
9
  import { mapRecordToFile, writeRecords } from "../utils/cli/writeRecords.js";
10
- import { updateBase, removeFromBase } from "../utils/updateFileBase.js";
10
+ import { updateBase, removeFromBase, removeFromBaseBulk } from "../utils/updateFileBase.js";
11
11
  import { compareAllFilesAndLogStatus, promptConflictResolution, showCurrentConflicts } from "../utils/cli/helpers/compare.js";
12
12
  import path from "path";
13
13
  import { compareLocalAndRemote } from "../utils/compare.js";
14
14
  import chalk from 'chalk';
15
- import { getFileTag } from "../utils/filetag.js";
15
+ import { getFileTag, setFileTag } from "../utils/filetag.js";
16
16
  import { downloadAssetsZip, listAssets } from "../utils/magentrix/api/assets.js";
17
17
  import { downloadAssets, walkAssets } from "../utils/downloadAssets.js";
18
+ import { listApps, downloadApp } from "../utils/magentrix/api/iris.js";
19
+ import { extractIrisZip, hashIrisAppFolder } from "../utils/iris/zipper.js";
18
20
  import { v4 as uuidv4 } from 'uuid';
19
21
  import readlineSync from 'readline-sync';
20
22
 
@@ -55,6 +57,7 @@ export const pull = async () => {
55
57
  progress.addStep('load', 'Loading cached data...');
56
58
  progress.addStep('download-code', 'Downloading code entities...', { hasProgress: true });
57
59
  progress.addStep('download-assets', 'Downloading static assets...', { hasProgress: true });
60
+ progress.addStep('download-iris', 'Downloading Iris apps...', { hasProgress: true });
58
61
  progress.addStep('process', 'Processing files...', { hasProgress: true });
59
62
  progress.addStep('conflicts', 'Checking for conflicts...');
60
63
  progress.addStep('write', 'Writing files...', { hasProgress: true });
@@ -72,380 +75,567 @@ export const pull = async () => {
72
75
  const instanceChanged = lastInstanceUrl && lastInstanceUrl !== instanceUrl;
73
76
 
74
77
  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
- );
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
+ }
88
97
 
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);
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');
93
110
  }
94
111
 
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 });
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
+ const cachedAssets = Object.values(cachedResults).filter(c =>
135
+ c.type === 'File' || c.type === 'Folder'
136
+ );
137
+ progress.completeStep('load', `✓ Loaded ${Object.keys(cachedResults).length} cached entries`);
138
+
139
+ // Step 4a: Download code entities (ActiveClass and ActivePage)
140
+ progress.startStep('download-code');
141
+
142
+ let activeClassResult, activePageResult;
143
+ const codeDownloadErrors = [];
144
+
145
+ try {
146
+ logger.info('Starting code entity downloads');
147
+ const meqlResults = await Promise.all(
148
+ queries.map(q => meqlQuery(instanceUrl, token.value, q.query))
149
+ );
150
+
151
+ [activeClassResult, activePageResult] = meqlResults;
152
+ const totalCodeEntities = activeClassResult.Records.length + activePageResult.Records.length;
153
+
154
+ logger.info(`Downloaded ${totalCodeEntities} code entities`, {
155
+ activeClass: activeClassResult.Records.length,
156
+ activePage: activePageResult.Records.length
157
+ });
158
+
159
+ progress.updateProgress('download-code', totalCodeEntities, totalCodeEntities, `Downloaded ${totalCodeEntities} code entities`);
160
+ progress.completeStep('download-code', `✓ Downloaded ${totalCodeEntities} code entities`);
161
+ } catch (error) {
162
+ logger.error('Error downloading code entities', error);
163
+ codeDownloadErrors.push({ stage: 'code-download', error: error.message });
164
+ progress.failStep('download-code', error.message);
165
+
166
+ // Show error preview
167
+ progress.stopSpinner();
168
+ console.log('');
169
+ console.log(chalk.red.bold('❌ Code Download Error:'));
170
+ console.log(chalk.red(` ${error.message.substring(0, 200)}`));
171
+ console.log(chalk.cyan(`\n 📄 Full details in log file`));
172
+ console.log('');
173
+
174
+ // Initialize empty data structures
175
+ if (!activeClassResult) activeClassResult = { Records: [] };
176
+ if (!activePageResult) activePageResult = { Records: [] };
177
+
178
+ // Restart progress for next step
179
+ progress.start();
180
+ progress.startStep('download-assets');
98
181
  }
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
182
 
105
- progress.start(); // Restart progress tracker
106
- progress.startStep('check');
107
- }
108
-
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",
183
+ // Step 4b: Download static assets
184
+ if (!codeDownloadErrors.length) {
185
+ progress.startStep('download-assets');
124
186
  }
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'
133
- );
134
- progress.completeStep('load', `✓ Loaded ${Object.keys(cachedResults).length} cached entries`);
135
-
136
- // Step 4a: Download code entities (ActiveClass and ActivePage)
137
- progress.startStep('download-code');
138
-
139
- let activeClassResult, activePageResult;
140
- const codeDownloadErrors = [];
141
-
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
- );
147
-
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
187
 
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');
178
- }
188
+ let assets;
189
+ const assetDownloadErrors = [];
190
+
191
+ try {
192
+ logger.info('Starting static asset downloads');
193
+ assets = await downloadAssets(instanceUrl, token.value, null, (current, total, message) => {
194
+ progress.updateProgress('download-assets', current, total, message);
195
+ }, logger);
196
+
197
+ logger.info(`Downloaded ${assets.tree.length} asset folders`);
198
+ progress.completeStep('download-assets', `✓ Downloaded ${assets.tree.length} asset folders`);
199
+ } catch (error) {
200
+ logger.error('Error downloading static assets', error);
201
+ assetDownloadErrors.push({ stage: 'asset-download', error: error.message });
202
+ progress.failStep('download-assets', error.message);
203
+
204
+ // Show error preview
205
+ progress.stopSpinner();
206
+ console.log('');
207
+ console.log(chalk.red.bold('❌ Asset Download Error:'));
208
+ console.log(chalk.red(` ${error.message.substring(0, 200)}`));
209
+ console.log(chalk.cyan(`\n 📄 Full details in log file`));
210
+ console.log('');
211
+
212
+ // Initialize empty data structure
213
+ if (!assets) assets = { tree: [] };
214
+
215
+ // Restart progress for next step
216
+ progress.start();
217
+ }
179
218
 
180
- // Step 4b: Download static assets
181
- if (!codeDownloadErrors.length) {
182
- progress.startStep('download-assets');
183
- }
219
+ // Step 4c: Download Iris apps
220
+ progress.startStep('download-iris');
221
+ let irisApps = [];
222
+ const irisDownloadErrors = [];
184
223
 
185
- let assets;
186
- const assetDownloadErrors = [];
224
+ try {
225
+ logger.info('Fetching Iris apps list');
226
+ const irisListResult = await listApps(instanceUrl, token.value);
187
227
 
188
- try {
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);
228
+ if (irisListResult.success && irisListResult.apps && irisListResult.apps.length > 0) {
229
+ irisApps = irisListResult.apps;
230
+ const irisAppsDir = path.resolve(EXPORT_ROOT, IRIS_APPS_DIR);
193
231
 
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);
232
+ // Ensure iris-apps directory exists
233
+ if (!fs.existsSync(irisAppsDir)) {
234
+ fs.mkdirSync(irisAppsDir, { recursive: true });
235
+ }
200
236
 
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('');
237
+ logger.info(`Found ${irisApps.length} Iris apps to download`);
238
+
239
+ for (let i = 0; i < irisApps.length; i++) {
240
+ const app = irisApps[i];
241
+ progress.updateProgress('download-iris', i + 1, irisApps.length, `Downloading ${app.folderName}...`);
242
+
243
+ try {
244
+ // Download the app as a buffer
245
+ const { buffer } = await downloadApp(instanceUrl, token.value, app.folderName);
246
+
247
+ // Extract to iris-apps directory
248
+ const appDir = path.join(irisAppsDir, app.folderName);
249
+
250
+ // Remove existing app directory if it exists
251
+ if (fs.existsSync(appDir)) {
252
+ fs.rmSync(appDir, { recursive: true, force: true });
253
+ }
254
+
255
+ // Extract the zip (it contains a folder with the app name)
256
+ await extractIrisZip(buffer, irisAppsDir);
257
+
258
+ // Calculate content hash for change detection
259
+ const contentHash = hashIrisAppFolder(appDir);
260
+
261
+ // Update base.json for this Iris app
262
+ updateBase(appDir, {
263
+ Id: `iris-app:${app.folderName}`,
264
+ Type: 'IrisApp',
265
+ folderName: app.folderName,
266
+ uploadedOn: app.uploadedOn,
267
+ modifiedOn: app.modifiedOn,
268
+ size: app.size,
269
+ contentHash // Store hash for change detection
270
+ }, appDir);
271
+
272
+ logger.info(`Downloaded Iris app: ${app.folderName}`);
273
+ } catch (appError) {
274
+ // Detect permission errors and provide helpful hint
275
+ const isPermissionError = appError.code === 'EACCES' || appError.code === 'EPERM' ||
276
+ appError.message?.includes('permission denied') || appError.message?.includes('EACCES');
277
+
278
+ // Create a more informative error message
279
+ const errorDetail = appError.message || String(appError);
280
+ const errorMsg = `Failed to download Iris app ${app.folderName}: ${errorDetail}`;
281
+
282
+ if (isPermissionError) {
283
+ const hint = `Try: sudo chown -R $(whoami):staff "${irisAppsDir}"`;
284
+ logger.error(errorMsg, appError, null, hint);
285
+ irisDownloadErrors.push({ app: app.folderName, error: appError.message, isPermissionError: true, hint });
286
+ progress.addIssue('error', errorMsg, hint);
287
+ } else {
288
+ logger.error(errorMsg, appError);
289
+ irisDownloadErrors.push({ app: app.folderName, error: appError.message });
290
+ progress.addIssue('error', errorMsg);
291
+ }
292
+ }
293
+ }
208
294
 
209
- // Initialize empty data structure
210
- if (!assets) assets = { tree: [] };
295
+ progress.completeStep('download-iris', `✓ Downloaded ${irisApps.length} Iris apps`);
296
+ } else {
297
+ progress.completeStep('download-iris', '✓ No Iris apps found');
298
+ }
299
+ } catch (error) {
300
+ logger.error('Error fetching Iris apps', error);
301
+ irisDownloadErrors.push({ stage: 'iris-list', error: error.message });
302
+ progress.completeStep('download-iris', '⚠ Iris apps skipped (API may not be available)');
303
+ }
211
304
 
212
- // Restart progress for next step
213
- progress.start();
214
- }
305
+ // Check if we have any data to continue with
306
+ if (!activeClassResult?.Records?.length && !activePageResult?.Records?.length && !assets?.tree?.length) {
307
+ throw new Error('Download failed completely. No data available to continue.');
308
+ }
215
309
 
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
- }
310
+ // Step 5: Process files and detect changes
311
+ progress.startStep('process');
312
+ const { allRecords, issues, deletionLogs, processingErrors } = await (async () => {
313
+ let processedCount = 0;
314
+ const totalToProcess = cachedAssets.length + (activeClassResult.Records.length + activePageResult.Records.length);
315
+ const processingErrors = [];
316
+
317
+ // Collect all server asset paths
318
+ const serverAssetPaths = new Set();
319
+ const collectServerPaths = (records) => {
320
+ try {
321
+ for (const record of records) {
322
+ const fullPath = path.join(EXPORT_ROOT, record?.Path);
323
+ serverAssetPaths.add(path.normalize(fullPath));
324
+
325
+ if (record?.Type === 'Folder' && record?.Children?.length > 0) {
326
+ collectServerPaths(record.Children);
327
+ }
328
+ }
329
+ } catch (err) {
330
+ processingErrors.push({ stage: 'collect-paths', error: err.message });
331
+ logger.error('Error collecting server paths', err);
332
+ }
333
+ };
334
+ collectServerPaths(assets.tree);
335
+ progress.updateProgress('process', ++processedCount, totalToProcess, 'Collecting server paths');
336
+
337
+ // Detect and delete assets that were on server before but are now gone
338
+ const assetsToDelete = [];
339
+ for (const cached of cachedAssets) {
340
+ const cachedPath = path.normalize(cached.lastKnownActualPath || cached.filePath || cached.lastKnownPath);
341
+
342
+ // If this asset was in base.json but not returned from server, it was deleted
343
+ if (!serverAssetPaths.has(cachedPath)) {
344
+ assetsToDelete.push(cachedPath);
345
+ }
220
346
 
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 {
347
+ processedCount++;
348
+ if (processedCount % 50 === 0) {
349
+ progress.updateProgress('process', processedCount, totalToProcess, `Checking assets...`);
350
+ }
351
+ }
352
+ progress.updateProgress('process', processedCount, totalToProcess, `Found ${assetsToDelete.length} assets to delete`);
353
+
354
+ // Count total assets (for progress tracking)
355
+ // Note: We don't update base.json for assets here because:
356
+ // 1. Assets are already downloaded and written to disk
357
+ // 2. Their base will be updated during the "Writing files" stage if modified
358
+ // 3. Updating base.json thousands of times here is extremely slow
359
+ logger.info('Counting assets in tree');
360
+ let assetCount = 0;
361
+ const countAssets = (records) => {
232
362
  for (const record of records) {
233
- const fullPath = path.join(EXPORT_ROOT, record?.Path);
234
- serverAssetPaths.add(path.normalize(fullPath));
235
-
363
+ assetCount++;
236
364
  if (record?.Type === 'Folder' && record?.Children?.length > 0) {
237
- collectServerPaths(record.Children);
365
+ countAssets(record.Children);
238
366
  }
239
367
  }
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
368
  }
257
369
 
258
- processedCount++;
259
- if (processedCount % 50 === 0) {
260
- progress.updateProgress('process', processedCount, totalToProcess, `Checking assets...`);
370
+ countAssets(assets.tree);
371
+ logger.info(`Counted ${assetCount} total assets`);
372
+ processedCount += assetCount;
373
+ progress.updateProgress('process', processedCount, totalToProcess, `Processed ${assetCount} assets`);
374
+
375
+ // Handle code entity (ActiveClass, ActivePage) deletions
376
+ const activeClassRecords = (activeClassResult.Records || []).map(record => {
377
+ record.Content = record.Body;
378
+ delete record.Body;
379
+ return record;
380
+ });
381
+
382
+ const activePageRecords = (activePageResult.Records || []);
383
+ const allRecords = [...activeClassRecords, ...activePageRecords].map(mapRecordToFile);
384
+
385
+ // Get all server record IDs
386
+ const serverRecordIds = new Set([
387
+ ...activeClassRecords.map(r => r.Id),
388
+ ...activePageRecords.map(r => r.Id)
389
+ ]);
390
+
391
+ // Find code entities in base.json that are no longer on server
392
+ // Exclude Files, Folders, and IrisApps - only ActiveClass and ActivePage
393
+ const cachedCodeEntities = Object.values(cachedResults).filter(c =>
394
+ c.type !== 'File' && c.type !== 'Folder' && c.type !== 'IrisApp' && c.recordId
395
+ );
396
+
397
+ const codeEntitiesToDelete = [];
398
+ for (const cached of cachedCodeEntities) {
399
+ // If this code entity was in base.json but not returned from server, it was deleted
400
+ if (!serverRecordIds.has(cached.recordId)) {
401
+ codeEntitiesToDelete.push(cached);
402
+ }
261
403
  }
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);
404
+
405
+ // Delete local files/folders that were deleted on server
406
+ logger.info(`Starting deletion of ${assetsToDelete.length} assets`);
407
+ const deletionLogs = [];
408
+ for (const assetPath of assetsToDelete) {
409
+ try {
410
+ if (fs.existsSync(assetPath)) {
411
+ const stats = fs.statSync(assetPath);
412
+ if (stats.isDirectory()) {
413
+ fs.rmSync(assetPath, { recursive: true, force: true });
414
+ deletionLogs.push({ type: 'folder', path: assetPath });
415
+ logger.info('Deleted folder', { path: assetPath });
416
+ } else {
417
+ fs.unlinkSync(assetPath);
418
+ deletionLogs.push({ type: 'file', path: assetPath });
419
+ logger.info('Deleted file', { path: assetPath });
420
+ }
421
+ }
422
+ } catch (err) {
423
+ deletionLogs.push({ type: 'error', path: assetPath, error: err.message });
424
+ logger.error(`Failed to delete asset: ${assetPath}`, err);
277
425
  }
278
426
  }
279
- }
280
427
 
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
- );
428
+ // Bulk remove from base.json
429
+ const assetPathsToRemove = deletionLogs
430
+ .filter(l => l.type === 'file' || l.type === 'folder')
431
+ .map(l => l.path);
306
432
 
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);
433
+ if (assetPathsToRemove.length > 0) {
434
+ removeFromBaseBulk(assetPathsToRemove);
312
435
  }
313
- }
314
436
 
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 });
437
+ // Delete local code entity files that were deleted on server
438
+ logger.info(`Starting deletion of ${codeEntitiesToDelete.length} code entities`);
439
+ for (const entity of codeEntitiesToDelete) {
440
+ const entityPath = entity.filePath || entity.lastKnownPath;
441
+ try {
442
+ if (entityPath && fs.existsSync(entityPath)) {
443
+ fs.unlinkSync(entityPath);
444
+ deletionLogs.push({ type: 'entity', path: entityPath, entityType: entity.type });
445
+ logger.info('Deleted code entity', { path: entityPath, type: entity.type });
446
+ }
447
+ } catch (err) {
448
+ // Detect permission errors
449
+ const isPermissionError = err.code === 'EACCES' || err.code === 'EPERM';
450
+ deletionLogs.push({ type: 'error', path: entityPath, error: err.message, isPermissionError });
451
+
452
+ if (isPermissionError) {
453
+ const hint = `Try: sudo chown -R $(whoami):staff "${path.dirname(entityPath)}"`;
454
+ logger.error(`Failed to delete code entity: ${entityPath}`, err, null, hint);
455
+ progress.addIssue('error', `Failed to delete code entity: ${entityPath}`, hint);
326
456
  } else {
327
- fs.unlinkSync(assetPath);
328
- deletionLogs.push({ type: 'file', path: assetPath });
329
- logger.info('Deleted file', { path: assetPath });
457
+ logger.error(`Failed to delete code entity: ${entityPath}`, err);
458
+ progress.addIssue('error', `Failed to delete code entity: ${entityPath}`);
330
459
  }
331
460
  }
332
- // Remove from base.json
333
- removeFromBase(assetPath);
334
- } catch (err) {
335
- deletionLogs.push({ type: 'error', path: assetPath, error: err.message });
336
- logger.error(`Failed to delete asset: ${assetPath}`, err);
337
461
  }
338
- }
339
462
 
340
- // Delete local code entity files that were deleted on server
341
- logger.info(`Starting deletion of ${codeEntitiesToDelete.length} code entities`);
342
- for (const entity of codeEntitiesToDelete) {
343
- const entityPath = entity.filePath || entity.lastKnownPath;
344
- try {
345
- if (entityPath && fs.existsSync(entityPath)) {
346
- fs.unlinkSync(entityPath);
347
- deletionLogs.push({ type: 'entity', path: entityPath, entityType: entity.type });
348
- logger.info('Deleted code entity', { path: entityPath, type: entity.type });
349
- }
350
- // Remove from base.json
351
- removeFromBase(entity.recordId);
352
- } catch (err) {
353
- deletionLogs.push({ type: 'error', path: entityPath, error: err.message });
354
- logger.error(`Failed to delete code entity: ${entityPath}`, err);
463
+ // Bulk remove code entities from base.json
464
+ const entityIdsToRemove = deletionLogs
465
+ .filter(l => l.type === 'entity')
466
+ .map(l => {
467
+ // Find the entity object that corresponds to this path to get the ID
468
+ const entity = codeEntitiesToDelete.find(e =>
469
+ (e.filePath === l.path || e.lastKnownPath === l.path)
470
+ );
471
+ return entity ? entity.recordId : null;
472
+ })
473
+ .filter(id => id !== null);
474
+
475
+ if (entityIdsToRemove.length > 0) {
476
+ removeFromBaseBulk(entityIdsToRemove);
355
477
  }
356
- }
357
- logger.info(`Completed deletions - ${deletionLogs.length} items deleted`);
358
- progress.updateProgress('process', processedCount, totalToProcess, `Deleted ${deletionLogs.length} items`);
359
-
360
- // Compare files and detect conflicts
361
- logger.info(`Starting file comparison for ${allRecords.length} records`);
362
- const issues = [];
363
- for (let i = 0; i < allRecords.length; i++) {
364
- const record = allRecords[i];
365
- try {
366
- if (record?.error) {
367
- logger.warning('Record has error', { record: record.Name || record.relativePath });
368
- processingErrors.push({
369
- stage: 'record-error',
370
- file: record.Name || record.relativePath,
371
- error: 'Record contains error flag'
372
- });
373
- continue;
478
+ logger.info(`Completed deletions - ${deletionLogs.length} items deleted`);
479
+ progress.updateProgress('process', processedCount, totalToProcess, `Deleted ${deletionLogs.length} items`);
480
+
481
+ // Compare files and detect conflicts
482
+ logger.info(`Starting file comparison for ${allRecords.length} records`);
483
+ // --- Fix for Bug 1: Rename Tracking ---
484
+ // Build a map of RecordId -> LocalPath from cached results
485
+ const localPathMap = new Map();
486
+ Object.values(cachedResults).forEach(entry => {
487
+ if (entry.recordId && (entry.filePath || entry.lastKnownPath)) {
488
+ localPathMap.set(entry.recordId, entry.filePath || entry.lastKnownPath);
374
489
  }
490
+ });
491
+
492
+ // Check for renames before comparison
493
+ for (const record of allRecords) {
494
+ if (localPathMap.has(record.Id)) {
495
+ const oldPath = localPathMap.get(record.Id);
496
+ const newPath = path.join(EXPORT_ROOT, record.relativePath);
497
+
498
+ // Normalize paths for comparison
499
+ let normalizedOld = path.normalize(path.resolve(oldPath));
500
+ const normalizedNew = path.normalize(path.resolve(newPath));
501
+
502
+ // Fix for path resolution: base.json might store paths relative to CWD or EXPORT_ROOT
503
+ // If direct resolution fails, try prepending EXPORT_ROOT
504
+ if (!fs.existsSync(normalizedOld)) {
505
+ const withRoot = path.normalize(path.resolve(EXPORT_ROOT, oldPath));
506
+ if (fs.existsSync(withRoot)) {
507
+ normalizedOld = withRoot;
508
+ }
509
+ }
375
510
 
376
- // Log every 50th file to track progress
377
- if (i % 50 === 0) {
378
- logger.info(`Comparing file ${i + 1}/${allRecords.length}: ${record.relativePath}`);
511
+ if (normalizedOld !== normalizedNew && fs.existsSync(normalizedOld)) {
512
+ try {
513
+ logger.info(`Detected rename: ${path.relative(EXPORT_ROOT, oldPath)} -> ${record.relativePath}`);
514
+
515
+ // Ensure target directory exists
516
+ fs.mkdirSync(path.dirname(normalizedNew), { recursive: true });
517
+
518
+ // Rename file
519
+ fs.renameSync(normalizedOld, normalizedNew);
520
+
521
+ // Update fileIdIndex.json to point to the new path
522
+ // This must be done BEFORE writeRecords runs, otherwise findFileByTag
523
+ // will return the old path and writeRecords will recreate the old file
524
+ await setFileTag(normalizedNew, record.Id);
525
+
526
+ // Update base.json immediately to reflect new path
527
+ // We use the existing record data but update the path
528
+ updateBase(record.relativePath, record, normalizedNew);
529
+
530
+ // Remove old path from deletion logs if it was marked for deletion
531
+ // (It might have been marked if the old filename wasn't in the server response)
532
+ const deletionIndex = deletionLogs.findIndex(l => path.normalize(l.path) === normalizedOld);
533
+ if (deletionIndex !== -1) {
534
+ deletionLogs.splice(deletionIndex, 1);
535
+ logger.info(`Cancelled deletion of renamed file: ${path.relative(EXPORT_ROOT, oldPath)}`);
536
+ }
537
+
538
+ console.log(chalk.cyan(` ↻ Renamed: ${path.relative(EXPORT_ROOT, oldPath)} -> ${record.relativePath}`));
539
+ } catch (err) {
540
+ logger.error(`Failed to rename file: ${oldPath} -> ${newPath}`, err);
541
+ processingErrors.push({
542
+ stage: 'rename',
543
+ file: record.relativePath,
544
+ error: `Rename failed: ${err.message}`
545
+ });
546
+ }
547
+ }
379
548
  }
549
+ }
550
+ // --- End Fix for Bug 1 ---
551
+
552
+ const issues = [];
553
+ for (let i = 0; i < allRecords.length; i++) {
554
+ const record = allRecords[i];
555
+ try {
556
+ if (record?.error) {
557
+ logger.warning('Record has error', { record: record.Name || record.relativePath });
558
+ processingErrors.push({
559
+ stage: 'record-error',
560
+ file: record.Name || record.relativePath,
561
+ error: 'Record contains error flag'
562
+ });
563
+ continue;
564
+ }
565
+
566
+ // Log every 50th file to track progress
567
+ if (i % 50 === 0) {
568
+ logger.info(`Comparing file ${i + 1}/${allRecords.length}: ${record.relativePath}`);
569
+ }
380
570
 
381
- const status = compareLocalAndRemote(
382
- path.join(EXPORT_ROOT, record.relativePath),
383
- { ...record, content: record.Content }
384
- );
571
+ const status = compareLocalAndRemote(
572
+ path.join(EXPORT_ROOT, record.relativePath),
573
+ { ...record, content: record.Content }
574
+ );
385
575
 
386
- // Missing files will just be written
387
- if (!['in_sync', 'missing'].includes(status.status)) {
388
- issues.push({ relativePath: record.relativePath, status: status.status });
389
- logger.info('Conflict detected', { file: record.relativePath, status: status.status });
576
+ // Missing files will just be written
577
+ if (!['in_sync', 'missing'].includes(status.status)) {
578
+ issues.push({ relativePath: record.relativePath, status: status.status });
579
+ logger.info('Conflict detected', { file: record.relativePath, status: status.status });
580
+ }
581
+ } catch (err) {
582
+ processingErrors.push({
583
+ stage: 'compare-files',
584
+ file: record?.relativePath || record?.Name,
585
+ error: err.message
586
+ });
587
+ logger.error(`Error comparing file ${record?.relativePath}`, err);
390
588
  }
391
- } catch (err) {
392
- processingErrors.push({
393
- stage: 'compare-files',
394
- file: record?.relativePath || record?.Name,
395
- error: err.message
396
- });
397
- logger.error(`Error comparing file ${record?.relativePath}`, err);
398
- }
399
589
 
400
- processedCount++;
401
- if (processedCount % 10 === 0 || i === allRecords.length - 1) {
402
- progress.updateProgress('process', processedCount, totalToProcess, `Comparing files...`);
590
+ processedCount++;
591
+ if (processedCount % 10 === 0 || i === allRecords.length - 1) {
592
+ progress.updateProgress('process', processedCount, totalToProcess, `Comparing files...`);
593
+ }
403
594
  }
404
- }
405
- logger.info(`Completed file comparison - found ${issues.length} conflicts`);
595
+ logger.info(`Completed file comparison - found ${issues.length} conflicts`);
406
596
 
407
- return { allRecords, issues, deletionLogs, processingErrors };
408
- })();
597
+ return { allRecords, issues, deletionLogs, processingErrors };
598
+ })();
409
599
 
410
- // Show completion message with error count if any
411
- if (processingErrors.length > 0) {
412
- progress.completeStep('process', `⚠ Processed ${allRecords.length} records with ${processingErrors.length} errors`);
413
- } else {
414
- progress.completeStep('process', `✓ Processed ${allRecords.length} records, ${deletionLogs.length} deletions`);
415
- }
600
+ // Show completion message with error count if any
601
+ if (processingErrors.length > 0) {
602
+ progress.completeStep('process', `⚠ Processed ${allRecords.length} records with ${processingErrors.length} errors`);
603
+ } else {
604
+ progress.completeStep('process', `✓ Processed ${allRecords.length} records, ${deletionLogs.length} deletions`);
605
+ }
606
+
607
+ // Note: Error preview will be shown in final summary, not here to avoid UI glitches
608
+ // Pause progress display for deletion logs and conflict resolution
609
+ progress.stopSpinner();
416
610
 
417
- // Note: Error preview will be shown in final summary, not here to avoid UI glitches
418
- // Pause progress display for deletion logs and conflict resolution
419
- progress.stopSpinner();
420
-
421
- // Display deletion logs
422
- for (const log of deletionLogs) {
423
- if (log.type === 'folder') {
424
- console.log(chalk.gray(` 🗑️ Removed deleted folder: ${path.relative(process.cwd(), log.path)}`));
425
- } else if (log.type === 'file') {
426
- console.log(chalk.gray(` 🗑️ Removed deleted file: ${path.relative(process.cwd(), log.path)}`));
427
- } else if (log.type === 'entity') {
428
- console.log(chalk.gray(` 🗑️ Removed deleted ${log.entityType}: ${path.relative(process.cwd(), log.path)}`));
429
- } else if (log.type === 'error') {
430
- console.warn(chalk.yellow(` ⚠️ Could not delete ${log.path}: ${log.error}`));
611
+ // Display deletion logs
612
+ for (const log of deletionLogs) {
613
+ if (log.type === 'folder') {
614
+ console.log(chalk.gray(` 🗑️ Removed deleted folder: ${path.relative(process.cwd(), log.path)}`));
615
+ } else if (log.type === 'file') {
616
+ console.log(chalk.gray(` 🗑️ Removed deleted file: ${path.relative(process.cwd(), log.path)}`));
617
+ } else if (log.type === 'entity') {
618
+ console.log(chalk.gray(` 🗑️ Removed deleted ${log.entityType}: ${path.relative(process.cwd(), log.path)}`));
619
+ } else if (log.type === 'error') {
620
+ console.warn(chalk.yellow(` ⚠️ Could not delete ${log.path}: ${log.error}`));
621
+ }
431
622
  }
432
- }
433
623
 
434
- let resolutionMethod = 'skip';
624
+ let resolutionMethod = 'skip';
435
625
 
436
- // Check for conflicts
437
- progress.startStep('conflicts');
438
- if (issues.length > 0) {
439
- progress.stopSpinner();
440
- resolutionMethod = await promptConflictResolution(issues);
441
- progress.startSpinner();
442
- }
443
- progress.completeStep('conflicts', issues.length > 0 ? `✓ Resolved ${issues.length} conflicts` : '✓ No conflicts');
626
+ // Check for conflicts
627
+ progress.startStep('conflicts');
628
+ if (issues.length > 0) {
629
+ progress.stopSpinner();
630
+ resolutionMethod = await promptConflictResolution(issues);
631
+ progress.startSpinner();
632
+ }
633
+ progress.completeStep('conflicts', issues.length > 0 ? `✓ Resolved ${issues.length} conflicts` : '✓ No conflicts');
444
634
 
445
- // Step 6: Write all ActiveClass and ActivePage records
446
- progress.startStep('write');
447
- await writeRecords(allRecords, resolutionMethod, progress, logger);
448
- progress.completeStep('write', `✓ Wrote ${allRecords.length} files`);
635
+ // Step 6: Write all ActiveClass and ActivePage records
636
+ progress.startStep('write');
637
+ await writeRecords(allRecords, resolutionMethod, progress, logger);
638
+ progress.completeStep('write', `✓ Wrote ${allRecords.length} files`);
449
639
 
450
640
  // Step 7: Finish progress tracker
451
641
  logger.info('Pull completed successfully');
@@ -456,6 +646,9 @@ export const pull = async () => {
456
646
  console.log(chalk.bold(`Summary:`));
457
647
  console.log(` • ${activeClassResult.Records.length} ActiveClass records`);
458
648
  console.log(` • ${activePageResult.Records.length} ActivePage records`);
649
+ if (irisApps.length > 0) {
650
+ console.log(` • ${irisApps.length} Iris apps`);
651
+ }
459
652
  console.log(` • ${deletionLogs.length} deletions`);
460
653
  console.log(`📁 Saved to: ./${EXPORT_ROOT}/`);
461
654