@magentrix-corp/magentrix-cli 1.3.15 → 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 -45
  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
@@ -1,1420 +1,1420 @@
1
- import { walkFiles } from "../utils/cacher.js";
2
- import { ensureValidCredentials } from "../utils/cli/helpers/ensureCredentials.js";
3
- import Config from "../utils/config.js";
4
- import { withSpinner } from "../utils/spinner.js";
5
- import { ProgressTracker } from "../utils/progress.js";
6
- import fs from "fs";
7
- import path from "path";
8
- import chalk from "chalk";
9
- import {
10
- ENTITY_FIELD_MAP,
11
- ENTITY_TYPE_MAP,
12
- EXPORT_ROOT,
13
- TYPE_DIR_MAP,
14
- IRIS_APPS_DIR,
15
- ALLOWED_SRC_DIRS,
16
- } from "../vars/global.js";
17
- import { getFileTag, setFileTag } from "../utils/filetag.js";
18
- import { sha256 } from "../utils/hash.js";
19
- import { createEntity } from "../utils/magentrix/api/createEntity.js";
20
- import { updateEntity } from "../utils/magentrix/api/updateEntity.js";
21
- import { deleteEntity } from "../utils/magentrix/api/deleteEntity.js";
22
- import { removeFromBase, updateBase } from "../utils/updateFileBase.js";
23
- import { deleteAsset, uploadAsset, createFolder } from "../utils/magentrix/api/assets.js";
24
- import { toApiPath, toApiFolderPath } from "../utils/assetPaths.js";
25
- import { publishApp } from "../utils/magentrix/api/iris.js";
26
- import { createIrisZip, hashIrisAppFolder } from "../utils/iris/zipper.js";
27
- import { validateIrisAppFolder } from "../utils/iris/validator.js";
28
- import { getLinkedProjects } from "../utils/iris/linker.js";
29
- import { deleteIrisAppFromServer } from "../utils/iris/deleteHelper.js";
30
-
31
- const config = new Config();
32
-
33
- /* ==================== CONFIGURATION ==================== */
34
-
35
- /**
36
- * Set to true to process all operations sequentially (one at a time).
37
- * Set to false to process operations in parallel with intelligent grouping.
38
- *
39
- * Sequential mode is slower but avoids any potential race conditions or
40
- * server-side rate limiting issues.
41
- */
42
- const USE_SEQUENTIAL_PROCESSING = true;
43
-
44
- /* ==================== UTILITY FUNCTIONS ==================== */
45
-
46
- /**
47
- * Infers Magentrix entity metadata from a file path by walking up the directory tree.
48
- * @param {string} filePath - The file path to analyze
49
- * @returns {Object} { type, entity, contentField } for Magentrix API
50
- */
51
- const inferMeta = (filePath) => {
52
- let currentPath = path.dirname(filePath);
53
- const exportRootAbs = path.resolve(EXPORT_ROOT);
54
-
55
- while (currentPath.startsWith(exportRootAbs)) {
56
- const folderName = path.basename(currentPath);
57
- const type = Object.keys(TYPE_DIR_MAP).find(
58
- (k) => TYPE_DIR_MAP[k].directory === folderName
59
- );
60
-
61
- if (type) {
62
- return {
63
- type,
64
- entity: ENTITY_TYPE_MAP[type],
65
- contentField: ENTITY_FIELD_MAP[type],
66
- };
67
- }
68
-
69
- const parentPath = path.dirname(currentPath);
70
- if (parentPath === currentPath) break; // Reached root
71
- currentPath = parentPath;
72
- }
73
-
74
- return { type: undefined, entity: undefined, contentField: undefined };
75
- };
76
-
77
- /**
78
- * Safely reads file content and computes hash.
79
- * @param {string} filePath - Path to the file
80
- * @returns {Object|null} { content, hash } or null on error
81
- */
82
- const readFileSafe = (filePath) => {
83
- try {
84
- const content = fs.readFileSync(filePath, "utf-8");
85
- return { content, hash: sha256(content) };
86
- } catch (err) {
87
- console.error(chalk.red.bold("Error:") + " Unable to read file " + chalk.white.bold(filePath));
88
- console.error(chalk.gray("→ Please verify the file exists and you have read permissions."));
89
- return null;
90
- }
91
- };
92
-
93
- /**
94
- * Formats multiline error messages with proper indentation.
95
- */
96
- const formatMultilineError = (err) => {
97
- const lines = String(err).split(/\r?\n/);
98
- return lines
99
- .map((line, i) => {
100
- const prefix = i === 0 ? `${chalk.redBright(' •')} ` : ' ';
101
- return prefix + chalk.whiteBright(line);
102
- })
103
- .join('\n');
104
- };
105
-
106
- /**
107
- * Recursively walks a directory and returns all subdirectory paths.
108
- */
109
- const walkFolders = (dir) => {
110
- if (!fs.existsSync(dir)) return [];
111
-
112
- const folders = [];
113
- const items = fs.readdirSync(dir, { withFileTypes: true });
114
-
115
- for (const item of items) {
116
- if (item.isDirectory()) {
117
- const fullPath = path.join(dir, item.name);
118
- folders.push(fullPath);
119
- folders.push(...walkFolders(fullPath));
120
- }
121
- }
122
-
123
- return folders;
124
- };
125
-
126
- /**
127
- * Gets display name for an action in log messages.
128
- */
129
- const getActionDisplayName = (action) => {
130
- // Iris app actions
131
- if (action.slug) return `${action.appName || action.slug} (${action.slug})`;
132
- if (action.folderName) return action.folderName;
133
- if (action.names) return action.names.join(", ");
134
- if (action.type) return action.type;
135
- if (action.filePath) return path.basename(action.filePath);
136
- return '';
137
- };
138
-
139
- /* ==================== ACTION HANDLERS ==================== */
140
-
141
- /**
142
- * Handles CREATE action for code entities (ActiveClass/ActivePage).
143
- */
144
- const handleCreateAction = async (instanceUrl, apiKey, action) => {
145
- const data = {
146
- Name: path.basename(action.filePath, path.extname(action.filePath)),
147
- Type: action.type,
148
- ...action.fields
149
- };
150
-
151
- const result = await createEntity(instanceUrl, apiKey, action.entity, data);
152
- return { recordId: result.recordId || result.id };
153
- };
154
-
155
- /**
156
- * Handles UPDATE action for code entities (ActiveClass/ActivePage).
157
- */
158
- const handleUpdateAction = async (instanceUrl, apiKey, action) => {
159
- const data = { ...action.fields };
160
-
161
- if (action.renamed) {
162
- data.Name = path.basename(action.filePath, path.extname(action.filePath));
163
- }
164
-
165
- await updateEntity(instanceUrl, apiKey, action.entity, action.recordId, data);
166
- return { recordId: action.recordId };
167
- };
168
-
169
- /**
170
- * Handles DELETE action for code entities (ActiveClass/ActivePage).
171
- */
172
- const handleDeleteAction = async (instanceUrl, apiKey, action) => {
173
- return await deleteEntity(instanceUrl, apiKey, action.entity, action.recordId);
174
- };
175
-
176
- /**
177
- * Handles CREATE_STATIC_ASSET action.
178
- */
179
- const handleCreateStaticAssetAction = async (instanceUrl, apiKey, action) => {
180
- const response = await uploadAsset(instanceUrl, apiKey, `/${action.folder}`, [action.filePath]);
181
- if (response?.error) throw new Error(response.error);
182
- return response;
183
- };
184
-
185
- /**
186
- * Handles DELETE_STATIC_ASSET action.
187
- */
188
- const handleDeleteStaticAssetAction = async (instanceUrl, apiKey, action) => {
189
- try {
190
- const response = await deleteAsset(instanceUrl, apiKey, `/${action.folder}`, action.names);
191
- return response;
192
- } catch (error) {
193
- // Check if this is a "not found" error
194
- const errorMessage = error?.message || String(error);
195
- const errorLower = errorMessage.toLowerCase();
196
- const isNotFound = errorLower.includes('404') ||
197
- errorLower.includes('not found') ||
198
- errorLower.includes('item not found');
199
-
200
- if (isNotFound) {
201
- // Clean up base.json since file doesn't exist on server
202
- // Use the original base.json key if available (avoids path format mismatches)
203
- if (action.baseKey) {
204
- removeFromBase(action.baseKey);
205
- } else {
206
- for (const name of action.names) {
207
- const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
208
- removeFromBase(filePath);
209
- }
210
- }
211
- return { cleanedFromCache: true };
212
- }
213
-
214
- // Other errors should still fail
215
- throw error;
216
- }
217
- };
218
-
219
- /**
220
- * Handles CREATE_FOLDER action.
221
- */
222
- const handleCreateFolderAction = async (instanceUrl, apiKey, action) => {
223
- try {
224
- const response = await createFolder(instanceUrl, apiKey, action.parentPath, action.folderName);
225
- return response;
226
- } catch (error) {
227
- // Check if folder already exists (likely created by file upload)
228
- const errorMessage = error?.message || String(error);
229
- const errorLower = errorMessage.toLowerCase();
230
- const alreadyExists = errorLower.includes('already exists') ||
231
- errorLower.includes('folder exists') ||
232
- errorLower.includes('duplicate');
233
-
234
- if (alreadyExists) {
235
- // Folder already exists, update cache and treat as success
236
- return { alreadyExisted: true };
237
- }
238
-
239
- // Other errors should still fail
240
- throw error;
241
- }
242
- };
243
-
244
- /**
245
- * Handles DELETE_FOLDER action.
246
- */
247
- const handleDeleteFolderAction = async (instanceUrl, apiKey, action) => {
248
- try {
249
- const response = await deleteAsset(instanceUrl, apiKey, action.parentPath, [action.folderName]);
250
- return response;
251
- } catch (error) {
252
- // Check if this is a "not found" error
253
- const errorMessage = error?.message || String(error);
254
- const errorLower = errorMessage.toLowerCase();
255
- const isNotFound = errorLower.includes('404') ||
256
- errorLower.includes('not found') ||
257
- errorLower.includes('item not found');
258
-
259
- if (isNotFound) {
260
- // Clean up base.json since folder doesn't exist on server
261
- // Use original base.json key if available (avoids path format mismatches)
262
- removeFromBase(action.baseKey || action.folderPath);
263
-
264
- // Also remove all files and subfolders inside this folder from base
265
- const hits = await config.searchObject({}, { filename: "base.json", global: false });
266
- const cachedResults = hits?.[0]?.value || {};
267
-
268
- for (const [recordId, cachedEntry] of Object.entries(cachedResults)) {
269
- // Check all possible path fields
270
- const entryPath = cachedEntry.lastKnownActualPath || cachedEntry.filePath || cachedEntry.lastKnownPath;
271
- if (entryPath && typeof entryPath === 'string') {
272
- const normalizedEntryPath = path.normalize(path.resolve(entryPath)).toLowerCase();
273
- const normalizedFolderPath = path.normalize(path.resolve(action.folderPath)).toLowerCase();
274
-
275
- // Check if this entry is inside the deleted folder
276
- if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep) ||
277
- normalizedEntryPath === normalizedFolderPath) {
278
- removeFromBase(recordId);
279
- }
280
- }
281
- }
282
-
283
- return { cleanedFromCache: true };
284
- }
285
-
286
- // Other errors should still fail
287
- throw error;
288
- }
289
- };
290
-
291
- /**
292
- * Handles CREATE_IRIS_APP or UPDATE_IRIS_APP action.
293
- */
294
- const handlePublishIrisAppAction = async (instanceUrl, apiKey, action) => {
295
- // Create zip from the app folder
296
- const zipBuffer = await createIrisZip(action.appPath, action.slug);
297
-
298
- // For updates, don't send app-name/description/icon to avoid triggering
299
- // metadata modification that may require permissions the user doesn't have.
300
- // The server already has the correct metadata from the original create.
301
- // For creates, send all metadata so the app is properly registered.
302
- const isUpdate = action.action === 'update_iris_app';
303
-
304
- const response = await publishApp(
305
- instanceUrl,
306
- apiKey,
307
- zipBuffer,
308
- `${action.slug}.zip`,
309
- isUpdate ? null : action.appName,
310
- isUpdate ? {} : {
311
- appDescription: action.appDescription,
312
- appIconId: action.appIconId
313
- }
314
- );
315
-
316
- return response;
317
- };
318
-
319
- /**
320
- * Handles DELETE_IRIS_APP action.
321
- */
322
- const handleDeleteIrisAppAction = async (instanceUrl, apiKey, action) => {
323
- // Use shared delete utility for consistency
324
- return await deleteIrisAppFromServer(instanceUrl, apiKey, action.slug, {
325
- updateCache: true // Cache will be updated by the utility
326
- });
327
- };
328
-
329
- /**
330
- * Synchronizes class name in content with filename, or vice versa.
331
- */
332
- const syncClassAndFileNames = (action, recordId) => {
333
- // Only for ActiveClass
334
- if (action.entity !== 'ActiveClass') return;
335
-
336
- const filePath = action.filePath;
337
- // If file was deleted or doesn't exist, skip
338
- if (!fs.existsSync(filePath)) return;
339
-
340
- const content = fs.readFileSync(filePath, 'utf-8');
341
- const filename = path.basename(filePath, path.extname(filePath));
342
-
343
- // Regex to find class/interface/enum name
344
- // Matches: public class Name, public interface Name, public enum Name
345
- // We assume standard formatting
346
- const classRegex = /public\s+(?:class|interface|enum)\s+(\w+)/;
347
- const match = content.match(classRegex);
348
-
349
- if (match) {
350
- const classNameInContent = match[1];
351
-
352
- if (classNameInContent !== filename) {
353
- // Mismatch detected
354
-
355
- // Case 1: File was renamed (action.renamed is true) -> Update content
356
- if (action.renamed) {
357
- const newContent = content.replace(classRegex, (fullMatch, name) => {
358
- return fullMatch.replace(name, filename);
359
- });
360
- fs.writeFileSync(filePath, newContent);
361
- console.log(chalk.cyan(` ↻ Updated class name in file to: ${filename}`));
362
-
363
- // Update cache with new content hash
364
- updateBase(filePath, { Id: recordId, Type: 'ActiveClass' });
365
- }
366
- // Case 2: Content was updated (action.renamed is false) -> Rename file
367
- else {
368
- // Rename file to match class name
369
- const dir = path.dirname(filePath);
370
- const ext = path.extname(filePath);
371
- const newFilename = `${classNameInContent}${ext}`;
372
- const newFilePath = path.join(dir, newFilename);
373
-
374
- if (fs.existsSync(newFilePath)) {
375
- console.warn(chalk.yellow(` ⚠️ Cannot rename ${filename} to ${classNameInContent}: File already exists.`));
376
- return;
377
- }
378
-
379
- try {
380
- fs.renameSync(filePath, newFilePath);
381
- console.log(chalk.cyan(` ↻ Renamed file to match class: ${newFilename}`));
382
-
383
- // Update cache: update the entry for this recordId to point to new path
384
- updateBase(newFilePath, { Id: recordId, Type: 'ActiveClass' }, newFilePath);
385
- } catch (err) {
386
- console.warn(chalk.yellow(` ⚠️ Failed to rename file: ${err.message}`));
387
- }
388
- }
389
- }
390
- }
391
- };
392
-
393
- /**
394
- * Updates cache after successful operations.
395
- */
396
- const updateCacheAfterSuccess = async (action, operationResult) => {
397
- try {
398
- // Sync class names/filenames if needed (Bug 2 Fix)
399
- if (action.action === 'update' && operationResult?.recordId) {
400
- syncClassAndFileNames(action, operationResult.recordId);
401
- }
402
-
403
- switch (action.action) {
404
- case "create": {
405
- const createSnapshot = action.fields && Object.values(action.fields)[0]
406
- ? { content: Object.values(action.fields)[0], hash: action.contentHash }
407
- : null;
408
- await setFileTag(action.filePath, operationResult.recordId);
409
- updateBase(
410
- action.filePath,
411
- { Id: operationResult.recordId, Type: action.type },
412
- '',
413
- createSnapshot
414
- );
415
- break;
416
- }
417
-
418
- case "update": {
419
- const updateSnapshot = action.fields && Object.values(action.fields)[0]
420
- ? { content: Object.values(action.fields)[0], hash: sha256(Object.values(action.fields)[0]) }
421
- : null;
422
- const type = Object.keys(ENTITY_TYPE_MAP).find(key => ENTITY_TYPE_MAP[key] === action.entity);
423
- updateBase(
424
- action.filePath,
425
- { Id: action.recordId, Type: type },
426
- '',
427
- updateSnapshot
428
- );
429
- break;
430
- }
431
-
432
- case "delete":
433
- removeFromBase(action.recordId);
434
- break;
435
-
436
- case "delete_static_asset":
437
- // Skip if already cleaned from cache during 404 handling
438
- if (!operationResult?.cleanedFromCache) {
439
- // Use the original base.json key if available (avoids path format mismatches)
440
- if (action.baseKey) {
441
- removeFromBase(action.baseKey);
442
- } else {
443
- for (const name of action.names) {
444
- const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
445
- removeFromBase(filePath);
446
- }
447
- }
448
- }
449
- break;
450
-
451
- case "create_static_asset":
452
- updateBase(action.filePath, { Id: action.filePath, Type: "File" });
453
- break;
454
-
455
- case "create_folder":
456
- updateBase(action.folderPath, { Id: action.folderPath, Type: "Folder" });
457
- break;
458
-
459
- case "delete_folder": {
460
- // Skip if already cleaned from cache during 404 handling
461
- if (!operationResult?.cleanedFromCache) {
462
- // Remove the folder itself from base using the original base.json key
463
- removeFromBase(action.baseKey || action.folderPath);
464
-
465
- // Also remove all files and subfolders inside this folder from base
466
- const hits = await config.searchObject({}, { filename: "base.json", global: false });
467
- const cachedResults = hits?.[0]?.value || {};
468
-
469
- for (const [recordId, cachedEntry] of Object.entries(cachedResults)) {
470
- // Check all possible path fields
471
- const entryPath = cachedEntry.lastKnownActualPath || cachedEntry.filePath || cachedEntry.lastKnownPath;
472
- if (entryPath && typeof entryPath === 'string') {
473
- const normalizedEntryPath = path.normalize(path.resolve(entryPath)).toLowerCase();
474
- const normalizedFolderPath = path.normalize(path.resolve(action.folderPath)).toLowerCase();
475
-
476
- // Check if this entry is inside the deleted folder
477
- if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep) ||
478
- normalizedEntryPath === normalizedFolderPath) {
479
- removeFromBase(recordId);
480
- }
481
- }
482
- }
483
- }
484
- break;
485
- }
486
-
487
- case "create_iris_app":
488
- case "update_iris_app": {
489
- // Update base.json with new/updated Iris app info including content hash
490
- updateBase(action.appPath, {
491
- Id: `iris-app:${action.slug}`,
492
- Type: 'IrisApp',
493
- folderName: action.slug,
494
- appName: action.appName,
495
- modifiedOn: new Date().toISOString(),
496
- contentHash: action.contentHash // Store hash for change detection
497
- }, action.appPath);
498
- break;
499
- }
500
-
501
- case "delete_iris_app": {
502
- // Skip if already cleaned from cache during 404 handling
503
- if (!operationResult?.cleanedFromCache) {
504
- removeFromBase(`iris-app:${action.slug}`);
505
- }
506
- break;
507
- }
508
- }
509
- } catch (error) {
510
- console.warn(chalk.yellow(`Warning: Failed to update cache for ${action.action}: ${error.message}`));
511
- }
512
- };
513
-
514
- /* ==================== NETWORK REQUEST HANDLER ==================== */
515
-
516
- /**
517
- * Groups actions by resource ID to detect conflicts and sequence operations.
518
- * @returns {Object} { byResource: Map<recordId, actions[]>, assets: actions[] }
519
- */
520
- const groupActionsByResource = (actionQueue) => {
521
- const byResource = new Map(); // recordId -> actions[]
522
- const assets = []; // Asset operations (can run in parallel)
523
-
524
- for (let i = 0; i < actionQueue.length; i++) {
525
- const action = { ...actionQueue[i], originalIndex: i };
526
-
527
- // Asset and Iris app operations don't need sequencing
528
- if (['create_static_asset', 'delete_static_asset', 'create_folder', 'delete_folder', 'create_iris_app', 'update_iris_app', 'delete_iris_app'].includes(action.action)) {
529
- assets.push(action);
530
- continue;
531
- }
532
-
533
- // Code entity operations - group by recordId
534
- const resourceId = action.recordId;
535
- if (!resourceId) {
536
- // Create actions without recordId yet - treat as unique resource
537
- assets.push(action);
538
- continue;
539
- }
540
-
541
- if (!byResource.has(resourceId)) {
542
- byResource.set(resourceId, []);
543
- }
544
- byResource.get(resourceId).push(action);
545
- }
546
-
547
- return { byResource, assets };
548
- };
549
-
550
- /**
551
- * Resolves conflicts for a single resource and returns actions to execute in sequence.
552
- * Rules:
553
- * - If DELETE exists, drop all other actions (delete wins)
554
- * - If multiple UPDATEs exist, keep them in order (will execute sequentially)
555
- */
556
- const resolveResourceConflicts = (actions) => {
557
- // Check if there's a delete action
558
- const hasDelete = actions.some(a => a.action === 'delete');
559
-
560
- if (hasDelete) {
561
- // Delete wins - drop all other actions and only keep the delete
562
- return actions.filter(a => a.action === 'delete');
563
- }
564
-
565
- // No delete - return all actions (they'll execute sequentially)
566
- return actions;
567
- };
568
-
569
- /**
570
- * Executes actions for a single resource sequentially.
571
- */
572
- const executeResourceActions = async (instanceUrl, token, actions) => {
573
- const results = [];
574
-
575
- for (const action of actions) {
576
- try {
577
- let result;
578
- switch (action.action) {
579
- case "create":
580
- result = await handleCreateAction(instanceUrl, token, action);
581
- break;
582
- case "update":
583
- result = await handleUpdateAction(instanceUrl, token, action);
584
- break;
585
- case "delete":
586
- result = await handleDeleteAction(instanceUrl, token, action);
587
- break;
588
- default:
589
- throw new Error(`Unknown action: ${action.action}`);
590
- }
591
- results.push({ index: action.originalIndex, action, result, success: true });
592
- } catch (error) {
593
- results.push({ index: action.originalIndex, action, error: error.message, success: false });
594
- }
595
- }
596
-
597
- return results;
598
- };
599
-
600
- /**
601
- * Executes a single action and returns the result.
602
- */
603
- const executeAction = async (instanceUrl, token, action) => {
604
- let result;
605
- switch (action.action) {
606
- case "create":
607
- result = await handleCreateAction(instanceUrl, token, action);
608
- break;
609
- case "update":
610
- result = await handleUpdateAction(instanceUrl, token, action);
611
- break;
612
- case "delete":
613
- result = await handleDeleteAction(instanceUrl, token, action);
614
- break;
615
- case "create_static_asset":
616
- result = await handleCreateStaticAssetAction(instanceUrl, token, action);
617
- break;
618
- case "delete_static_asset":
619
- result = await handleDeleteStaticAssetAction(instanceUrl, token, action);
620
- break;
621
- case "create_folder":
622
- result = await handleCreateFolderAction(instanceUrl, token, action);
623
- break;
624
- case "delete_folder":
625
- result = await handleDeleteFolderAction(instanceUrl, token, action);
626
- break;
627
- case "create_iris_app":
628
- case "update_iris_app":
629
- result = await handlePublishIrisAppAction(instanceUrl, token, action);
630
- break;
631
- case "delete_iris_app":
632
- result = await handleDeleteIrisAppAction(instanceUrl, token, action);
633
- break;
634
- default:
635
- throw new Error(`Unknown action: ${action.action}`);
636
- }
637
- return result;
638
- };
639
-
640
- /**
641
- * Executes all actions sequentially (one at a time) with progress messages.
642
- */
643
- const performNetworkRequestSequential = async (actionQueue) => {
644
- const { instanceUrl, token } = await ensureValidCredentials();
645
-
646
- console.log(chalk.blue(`\n🔄 Sequential processing mode (${actionQueue.length} operations)\n`));
647
-
648
- const results = [];
649
- let successCount = 0;
650
- let errorCount = 0;
651
-
652
- for (let i = 0; i < actionQueue.length; i++) {
653
- const action = { ...actionQueue[i], originalIndex: i };
654
- const displayName = getActionDisplayName(action);
655
-
656
- // Show progress message
657
- console.log(chalk.gray(`[${i + 1}/${actionQueue.length}] Processing ${action.action.toUpperCase()} ${displayName}...`));
658
-
659
- try {
660
- const result = await executeAction(instanceUrl, token.value, action);
661
- results.push({ index: i, action, result, success: true });
662
-
663
- successCount++;
664
- console.log(
665
- chalk.green(`✓ [${i + 1}]`) +
666
- ` ${chalk.yellow(action.action.toUpperCase())} ${chalk.cyan(displayName)} ` +
667
- (result?.recordId ? chalk.magenta(result.recordId) : "")
668
- );
669
- await updateCacheAfterSuccess(action, result);
670
- } catch (error) {
671
- results.push({ index: i, action, error: error.message, success: false });
672
-
673
- errorCount++;
674
- console.log();
675
- console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
676
- console.log(chalk.redBright('─'.repeat(48)));
677
- const actionPath = action.filePath || action.folderPath || action.folder || action.appPath || action.slug || 'unknown';
678
- console.log(chalk.red.bold(`[${i + 1}] ${action.action.toUpperCase()} ${displayName} (${actionPath}):`));
679
- console.log(formatMultilineError(error.message));
680
- console.log(chalk.redBright('─'.repeat(48)));
681
- }
682
-
683
- console.log(); // Add spacing between operations
684
- }
685
-
686
- // Summary
687
- console.log(chalk.blue("--- Publish Summary ---"));
688
- console.log(chalk.green(`✓ Successful: ${successCount}`));
689
- if (errorCount > 0) {
690
- console.log(chalk.red(`✗ Failed: ${errorCount}`));
691
- } else {
692
- console.log(chalk.green("All operations completed successfully! 🎉"));
693
- }
694
- };
695
-
696
- /**
697
- * Executes all actions in the queue with proper sequencing and conflict resolution (parallel mode).
698
- */
699
- const performNetworkRequestParallel = async (actionQueue) => {
700
- const { instanceUrl, token } = await ensureValidCredentials();
701
-
702
- // Group actions by resource
703
- const { byResource, assets } = groupActionsByResource(actionQueue);
704
-
705
- // Resolve conflicts for each resource
706
- const sequencedCodeActions = [];
707
- const droppedActions = [];
708
-
709
- for (const [resourceId, actions] of byResource.entries()) {
710
- const resolved = resolveResourceConflicts(actions);
711
- sequencedCodeActions.push(resolved);
712
-
713
- // Track dropped actions
714
- const droppedCount = actions.length - resolved.length;
715
- if (droppedCount > 0) {
716
- const dropped = actions.filter(a => !resolved.includes(a));
717
- droppedActions.push(...dropped);
718
- }
719
- }
720
-
721
- // Log dropped actions
722
- if (droppedActions.length > 0) {
723
- console.log(chalk.yellow(`\n⚠️ Dropped ${droppedActions.length} redundant operation(s) due to delete:`));
724
- droppedActions.forEach(action => {
725
- console.log(chalk.gray(` • ${action.action.toUpperCase()} on ${getActionDisplayName(action)} (superseded by DELETE)`));
726
- });
727
- }
728
-
729
- // Execute asset operations in parallel (they don't conflict)
730
- const assetPromises = assets.map(async (action) => {
731
- try {
732
- const result = await executeAction(instanceUrl, token.value, action);
733
- return { index: action.originalIndex, action, result, success: true };
734
- } catch (error) {
735
- return { index: action.originalIndex, action, error: error.message, success: false };
736
- }
737
- });
738
-
739
- // Execute code entity operations sequentially per resource (but resources in parallel)
740
- const codePromises = sequencedCodeActions.map(actions =>
741
- executeResourceActions(instanceUrl, token.value, actions)
742
- );
743
-
744
- // Wait for all operations
745
- const assetResults = await Promise.allSettled(assetPromises);
746
- const codeResults = await Promise.allSettled(codePromises);
747
-
748
- // Flatten code results
749
- const allCodeResults = codeResults
750
- .filter(r => r.status === 'fulfilled')
751
- .flatMap(r => r.value);
752
-
753
- const allAssetResults = assetResults
754
- .map(r => r.status === 'fulfilled' ? r.value : { status: 'rejected', reason: r.reason });
755
-
756
- // Combine all results
757
- const allResults = [...allCodeResults, ...allAssetResults];
758
-
759
- // Sort by original index to maintain order
760
- allResults.sort((a, b) => (a.index || 0) - (b.index || 0));
761
-
762
- // Process and display results
763
- let successCount = 0;
764
- let errorCount = 0;
765
-
766
- for (const result of allResults) {
767
- if (result.success !== undefined) {
768
- const { index, action, success, error, result: operationResult } = result;
769
-
770
- if (success) {
771
- successCount++;
772
- const displayName = getActionDisplayName(action);
773
- console.log(
774
- chalk.green(`✓ [${index + 1}]`) +
775
- ` ${chalk.yellow(action.action.toUpperCase())} ${chalk.cyan(displayName)} ` +
776
- (operationResult?.recordId ? chalk.magenta(operationResult.recordId) : "")
777
- );
778
- await updateCacheAfterSuccess(action, operationResult);
779
- } else {
780
- errorCount++;
781
- console.log();
782
- console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
783
- console.log(chalk.redBright('─'.repeat(48)));
784
- const actionPath = action.filePath || action.folderPath || action.folder || action.appPath || action.slug || 'unknown';
785
- console.log(chalk.red.bold(`[${index + 1}] ${action.action.toUpperCase()} ${getActionDisplayName(action)} (${actionPath}):`));
786
- console.log(formatMultilineError(error));
787
- console.log(chalk.redBright('─'.repeat(48)));
788
- }
789
- } else if (result.status === 'rejected') {
790
- errorCount++;
791
- console.log();
792
- console.log(chalk.bgRed.bold.white(' ✖ Unexpected Error '));
793
- console.log(chalk.redBright('─'.repeat(48)));
794
- console.log(`${chalk.redBright(' •')} ${chalk.whiteBright(result.reason)}`);
795
- console.log(chalk.redBright('─'.repeat(48)));
796
- }
797
- }
798
-
799
- // Summary
800
- console.log(chalk.blue("\n--- Publish Summary ---"));
801
- console.log(chalk.green(`✓ Successful: ${successCount}`));
802
- if (droppedActions.length > 0) {
803
- console.log(chalk.yellow(`⊝ Dropped: ${droppedActions.length} (redundant)`));
804
- }
805
- if (errorCount > 0) {
806
- console.log(chalk.red(`✗ Failed: ${errorCount}`));
807
- } else {
808
- console.log(chalk.green("All operations completed successfully! 🎉"));
809
- }
810
- };
811
-
812
- /**
813
- * Executes all actions in the queue (dispatcher function).
814
- */
815
- const performNetworkRequest = async (actionQueue) => {
816
- if (USE_SEQUENTIAL_PROCESSING) {
817
- return await performNetworkRequestSequential(actionQueue);
818
- } else {
819
- return await performNetworkRequestParallel(actionQueue);
820
- }
821
- };
822
-
823
- /* ==================== MAIN PUBLISH LOGIC ==================== */
824
-
825
- /**
826
- * Core publish logic - can be called from autopublish or directly.
827
- * @param {Object} options - Configuration options
828
- * @param {boolean} options.silent - If true, suppress summary output
829
- * @returns {Promise<{actionQueue: Array, hasChanges: boolean}>}
830
- */
831
- export const runPublish = async (options = {}) => {
832
- const { silent = false } = options;
833
-
834
- // Create progress tracker
835
- const progress = silent ? null : new ProgressTracker('Publish to Magentrix');
836
- if (progress) {
837
- progress.addStep('auth', 'Authenticating...');
838
- progress.addStep('load', 'Loading cached data...');
839
- progress.addStep('scan', 'Scanning local files...');
840
- progress.addStep('compare-assets', 'Comparing assets...', { hasProgress: true });
841
- progress.addStep('compare-code', 'Comparing code entities...', { hasProgress: true });
842
- progress.addStep('prepare', 'Preparing action queue...');
843
- progress.start();
844
- // Start first step immediately so UI shows up
845
- progress.startStep('auth');
846
- }
847
-
848
- // Step 1: Authenticate
849
- await ensureValidCredentials().catch((err) => {
850
- if (progress) {
851
- progress.abort(err.message);
852
- } else if (!silent) {
853
- console.error(chalk.red.bold("Authentication failed:"), chalk.white(err.message));
854
- }
855
- throw err;
856
- });
857
- if (progress) progress.completeStep('auth', '✓ Authenticated');
858
-
859
- // Step 2: Load cached file state
860
- if (progress) progress.startStep('load');
861
-
862
- const loadStart = Date.now();
863
- const hits = await config.searchObject({}, { filename: "base.json", global: false });
864
- const cachedResults = hits?.[0]?.value || {};
865
- const loadTime = Date.now() - loadStart;
866
-
867
- if (!Object.keys(cachedResults).length) {
868
- if (progress) {
869
- progress.abort('No file cache found');
870
- } else if (!silent) {
871
- console.log(chalk.red.bold("No file cache found!"));
872
- console.log(`Run ${chalk.cyan("magentrix pull")} to initialize your workspace.`);
873
- }
874
- throw new Error("No file cache found");
875
- }
876
-
877
- const mapStart = Date.now();
878
- const cachedFiles = Object.values(cachedResults).map((c) => ({
879
- ...c,
880
- tag: c.recordId,
881
- filePath: c.filePath || c.lastKnownPath,
882
- type: c.type || c.Type, // Normalize Type/type property
883
- }));
884
- const mapTime = Date.now() - mapStart;
885
-
886
- if (progress) {
887
- progress.completeStep('load', `✓ Loaded ${cachedFiles.length} entries (${loadTime}ms load, ${mapTime}ms map)`);
888
- }
889
-
890
- // Step 3: Scan local workspace (only whitelisted code entity directories)
891
- if (progress) progress.startStep('scan');
892
-
893
- const walkStart = Date.now();
894
- // Only scan whitelisted directories for code entities (exclude Assets and iris-apps, handled separately)
895
- const codeEntityDirs = ALLOWED_SRC_DIRS.filter(dir => dir !== 'Assets' && dir !== IRIS_APPS_DIR);
896
- const localPathArrays = await Promise.all(
897
- codeEntityDirs.map(dir => {
898
- const dirPath = path.join(EXPORT_ROOT, dir);
899
- return fs.existsSync(dirPath) ? walkFiles(dirPath) : Promise.resolve([]);
900
- })
901
- );
902
- const localPaths = localPathArrays.flat();
903
- const walkTime = Date.now() - walkStart;
904
-
905
- const tagStart = Date.now();
906
- const localFiles = await Promise.all(
907
- localPaths.map(async (p) => {
908
- try {
909
- const tag = await getFileTag(p);
910
- return { tag, path: p };
911
- } catch {
912
- return { tag: null, path: p };
913
- }
914
- })
915
- );
916
- const tagTime = Date.now() - tagStart;
917
-
918
- if (progress) {
919
- progress.completeStep('scan', `✓ Found ${localPaths.length} files (${walkTime}ms walk, ${tagTime}ms tags)`);
920
- }
921
-
922
- // Step 4: Create lookup maps
923
- if (progress) progress.startStep('compare-assets');
924
-
925
- const mapBuildStart = Date.now();
926
- const cacheById = Object.fromEntries(cachedFiles.map((c) => [c.tag, c]));
927
- const localById = Object.fromEntries(localFiles.filter((f) => f.tag).map((f) => [f.tag, f]));
928
- const newLocalNoId = localFiles.filter((f) => !f.tag);
929
- const allIds = new Set([...Object.keys(cacheById), ...Object.keys(localById)]);
930
- const mapBuildTime = Date.now() - mapBuildStart;
931
-
932
- const actionQueue = [];
933
-
934
- // Step 5: Handle static asset files - Build fast lookup map first (O(n) instead of O(n²))
935
- const assetWalkStart = Date.now();
936
- const assetPaths = await walkFiles(path.join(EXPORT_ROOT, 'Assets'));
937
- const assetWalkTime = Date.now() - assetWalkStart;
938
-
939
- // Build a Set of normalized cached asset paths for O(1) lookup
940
- const setStart = Date.now();
941
- const cachedAssetPaths = new Set();
942
- cachedFiles
943
- .filter(cf => cf.type === 'File' || cf.type === 'Folder')
944
- .forEach(cf => {
945
- if (cf.lastKnownActualPath) {
946
- cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownActualPath)).toLowerCase());
947
- }
948
- if (cf.filePath) {
949
- cachedAssetPaths.add(path.normalize(path.resolve(cf.filePath)).toLowerCase());
950
- }
951
- if (cf.lastKnownPath) {
952
- cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownPath)).toLowerCase());
953
- }
954
- });
955
- const setTime = Date.now() - setStart;
956
-
957
- // Now compare assets with O(1) lookup
958
- for (let i = 0; i < assetPaths.length; i++) {
959
- const assetPath = assetPaths[i];
960
- const normalizedAssetPath = path.normalize(path.resolve(assetPath)).toLowerCase();
961
-
962
- // Update progress every 100 files
963
- if (progress && i % 100 === 0) {
964
- progress.updateProgress('compare-assets', i, assetPaths.length, `Checking ${i}/${assetPaths.length} assets`);
965
- }
966
-
967
- // O(1) lookup instead of O(n) find()
968
- if (cachedAssetPaths.has(normalizedAssetPath)) {
969
- continue;
970
- }
971
-
972
- actionQueue.push({
973
- action: "create_static_asset",
974
- folder: toApiPath(assetPath),
975
- filePath: assetPath
976
- });
977
- }
978
-
979
- if (progress) {
980
- progress.completeStep('compare-assets', `✓ Compared ${assetPaths.length} assets (walk:${assetWalkTime}ms, set:${setTime}ms, map:${mapBuildTime}ms)`);
981
- }
982
-
983
- // Step 6: Handle folder creation and deletion - Also optimized with Set
984
- const assetsDir = path.join(EXPORT_ROOT, 'Assets');
985
- if (fs.existsSync(assetsDir)) {
986
- const localFolders = walkFolders(assetsDir);
987
- const cachedFolders = cachedFiles
988
- .filter(c => c.type === 'Folder' && (c.filePath || c.lastKnownPath || c.lastKnownActualPath));
989
-
990
- // Build Set of cached folder paths for O(1) lookup
991
- const cachedFolderPaths = new Set();
992
- cachedFolders.forEach(cf => {
993
- if (cf.lastKnownActualPath) {
994
- cachedFolderPaths.add(path.normalize(path.resolve(cf.lastKnownActualPath)).toLowerCase());
995
- }
996
- if (cf.lastKnownPath) {
997
- cachedFolderPaths.add(path.normalize(path.resolve(cf.lastKnownPath)).toLowerCase());
998
- }
999
- if (cf.filePath) {
1000
- cachedFolderPaths.add(path.normalize(path.resolve(cf.filePath)).toLowerCase());
1001
- }
1002
- });
1003
-
1004
- // Build Set of local folder paths for O(1) lookup
1005
- const localFolderPaths = new Set(
1006
- localFolders.map(lf => path.normalize(path.resolve(lf)).toLowerCase())
1007
- );
1008
-
1009
- // New folders - O(1) lookup
1010
- for (const folderPath of localFolders) {
1011
- if (!folderPath) continue;
1012
-
1013
- const normalizedFolderPath = path.normalize(path.resolve(folderPath)).toLowerCase();
1014
-
1015
- if (cachedFolderPaths.has(normalizedFolderPath)) {
1016
- continue;
1017
- }
1018
-
1019
- const parentDir = path.dirname(folderPath);
1020
- if (!parentDir || parentDir === '.' || parentDir === folderPath) continue;
1021
-
1022
- actionQueue.push({
1023
- action: "create_folder",
1024
- folderPath: folderPath,
1025
- parentPath: toApiFolderPath(parentDir),
1026
- folderName: path.basename(folderPath)
1027
- });
1028
- }
1029
-
1030
- // Deleted folders - O(1) lookup
1031
- for (const cachedFolder of cachedFolders) {
1032
- const cachedPath = cachedFolder.lastKnownActualPath || cachedFolder.lastKnownPath || cachedFolder.filePath;
1033
- if (!cachedPath || typeof cachedPath !== 'string') continue;
1034
-
1035
- const normalizedCachedPath = path.normalize(path.resolve(cachedPath)).toLowerCase();
1036
-
1037
- if (localFolderPaths.has(normalizedCachedPath)) {
1038
- continue;
1039
- }
1040
-
1041
- const parentDir = path.dirname(cachedPath);
1042
- if (!parentDir || parentDir === '.' || parentDir === cachedPath) continue;
1043
-
1044
- actionQueue.push({
1045
- action: "delete_folder",
1046
- folderPath: cachedPath,
1047
- parentPath: toApiFolderPath(parentDir),
1048
- folderName: path.basename(cachedPath),
1049
- baseKey: cachedFolder.tag // The original base.json key for correct cache cleanup
1050
- });
1051
- }
1052
- }
1053
-
1054
- // Step 7: Process code entities (ActiveClass/ActivePage) and static assets
1055
- if (progress) progress.startStep('compare-code');
1056
-
1057
- const allIdsArray = Array.from(allIds);
1058
- for (let idx = 0; idx < allIdsArray.length; idx++) {
1059
- const id = allIdsArray[idx];
1060
-
1061
- // Update progress every 50 items
1062
- if (progress && idx % 50 === 0) {
1063
- progress.updateProgress('compare-code', idx, allIdsArray.length, `Checking ${idx}/${allIdsArray.length} entities`);
1064
- }
1065
-
1066
- try {
1067
- const cacheFile = cacheById[id];
1068
- const curFile = localById[id];
1069
-
1070
- // Skip folders - they're handled separately
1071
- if (cacheFile?.type === 'Folder') continue;
1072
-
1073
- // Skip Iris apps - they're handled separately (check both type and ID prefix)
1074
- if (cacheFile?.type === 'IrisApp' || id.startsWith('iris-app:')) continue;
1075
-
1076
- // Handle static asset files
1077
- if (cacheFile?.type === 'File') {
1078
- // Use lastKnownActualPath which has the correct path (e.g., "src/Assets/...")
1079
- const actualPath = cacheFile.lastKnownActualPath || cacheFile.filePath;
1080
- const localAssetExists = fs.existsSync(actualPath);
1081
-
1082
- if (!localAssetExists) {
1083
- actionQueue.push({
1084
- action: 'delete_static_asset',
1085
- folder: toApiPath(actualPath),
1086
- names: [path.basename(actualPath)],
1087
- filePath: actualPath, // Store actual file path for filtering
1088
- baseKey: id // The original base.json key for correct cache cleanup
1089
- });
1090
- continue;
1091
- }
1092
-
1093
- const contentHash = sha256(fs.readFileSync(actualPath, 'utf-8'));
1094
- if (contentHash !== cacheFile.contentHash) {
1095
- actionQueue.push({
1096
- action: "create_static_asset",
1097
- folder: toApiPath(actualPath),
1098
- filePath: actualPath
1099
- });
1100
- }
1101
- continue;
1102
- }
1103
-
1104
- // Handle code entity deletion
1105
- if (cacheFile && !curFile) {
1106
- const { type, entity } = inferMeta(path.resolve(cacheFile.lastKnownPath));
1107
- actionQueue.push({
1108
- action: "delete",
1109
- recordId: id,
1110
- filePath: cacheFile.lastKnownPath,
1111
- type,
1112
- entity,
1113
- });
1114
- continue;
1115
- }
1116
-
1117
- // Handle code entity creation
1118
- if (!cacheFile && curFile) {
1119
- const safe = readFileSafe(curFile.path);
1120
- if (!safe) continue;
1121
-
1122
- const { content, hash } = safe;
1123
- const { type, entity, contentField } = inferMeta(path.resolve(curFile.path));
1124
-
1125
- actionQueue.push({
1126
- action: "create",
1127
- filePath: curFile.path,
1128
- recordId: id || null,
1129
- type,
1130
- entity,
1131
- fields: { [contentField]: content },
1132
- contentHash: hash,
1133
- });
1134
- continue;
1135
- }
1136
-
1137
- // Handle code entity update
1138
- if (cacheFile && curFile) {
1139
- const safe = readFileSafe(curFile.path);
1140
- if (!safe) continue;
1141
-
1142
- const { content, hash } = safe;
1143
- // Check both paths - only consider renamed if NEITHER matches current path
1144
- // This prevents false positives from stale/corrupted tracking data
1145
- const resolvedCurPath = path.resolve(curFile.path);
1146
- const matchesActualPath = cacheFile.lastKnownActualPath === resolvedCurPath;
1147
- const matchesExpectedPath = cacheFile.lastKnownPath === resolvedCurPath;
1148
- const renamed = !matchesActualPath && !matchesExpectedPath;
1149
- const contentChanged = hash !== cacheFile.contentHash;
1150
-
1151
- if (renamed || contentChanged) {
1152
- const { type, entity, contentField } = inferMeta(path.resolve(curFile.path));
1153
-
1154
- actionQueue.push({
1155
- action: "update",
1156
- recordId: id,
1157
- type,
1158
- entity,
1159
- fields: { [contentField]: content },
1160
- renamed,
1161
- oldPath: cacheFile.lastKnownActualPath || cacheFile.lastKnownPath,
1162
- filePath: curFile.path,
1163
- });
1164
- }
1165
- }
1166
- } catch (err) {
1167
- if (!silent) {
1168
- console.error(chalk.yellow(`Warning: Error processing file with ID ${id}:`), err.message);
1169
- }
1170
- }
1171
- }
1172
-
1173
- if (progress) {
1174
- progress.completeStep('compare-code', `✓ Compared ${allIdsArray.length} code entities`);
1175
- }
1176
-
1177
- // Step 7b: Scan and compare Iris apps
1178
- const irisAppsPath = path.join(EXPORT_ROOT, IRIS_APPS_DIR);
1179
-
1180
- // Get cached Iris apps (always check, even if local folder doesn't exist)
1181
- // Also include entries with iris-app: prefix in case type wasn't properly set
1182
- const cachedIrisApps = cachedFiles
1183
- .filter(cf => cf.type === 'IrisApp' || (cf.tag && cf.tag.startsWith('iris-app:')))
1184
- .map(cf => {
1185
- // Extract slug from folderName, or from the ID (iris-app:<slug>)
1186
- const slug = cf.folderName || (cf.tag && cf.tag.startsWith('iris-app:') ? cf.tag.replace('iris-app:', '') : null);
1187
- return {
1188
- slug,
1189
- appName: cf.appName || slug,
1190
- modifiedOn: cf.modifiedOn,
1191
- contentHash: cf.contentHash || null // Track content hash for change detection
1192
- };
1193
- })
1194
- .filter(app => app.slug); // Filter out any entries without a valid slug
1195
-
1196
- // Get local Iris apps (empty if folder doesn't exist)
1197
- const localIrisApps = fs.existsSync(irisAppsPath)
1198
- ? fs.readdirSync(irisAppsPath, { withFileTypes: true })
1199
- .filter(d => d.isDirectory())
1200
- .map(d => d.name)
1201
- : [];
1202
-
1203
- const cachedIrisSlugs = new Set(cachedIrisApps.map(a => a.slug));
1204
- const localIrisSlugs = new Set(localIrisApps);
1205
-
1206
- // Detect new and modified Iris apps
1207
- for (const slug of localIrisApps) {
1208
- const appPath = path.join(irisAppsPath, slug);
1209
-
1210
- // Validate the app folder has required files
1211
- const validation = validateIrisAppFolder(appPath);
1212
- if (!validation.valid) {
1213
- if (!silent) {
1214
- console.log(chalk.yellow(`⚠ Skipping invalid Iris app '${slug}': ${validation.errors.join(', ')}`));
1215
- }
1216
- continue;
1217
- }
1218
-
1219
- // Get app metadata from linked project config (stored globally) or use slug
1220
- const linkedProjects = getLinkedProjects();
1221
- const linkedProject = linkedProjects.find(p => p.slug === slug);
1222
- const appName = linkedProject?.appName || slug;
1223
- const appDescription = linkedProject?.appDescription || null;
1224
- const appIconId = linkedProject?.appIconId || null;
1225
-
1226
- // Calculate content hash for change detection
1227
- const currentHash = hashIrisAppFolder(appPath);
1228
-
1229
- // Check if this is a new or existing app
1230
- const isNew = !cachedIrisSlugs.has(slug);
1231
-
1232
- if (isNew) {
1233
- actionQueue.push({
1234
- action: 'create_iris_app',
1235
- slug,
1236
- appName,
1237
- appDescription,
1238
- appIconId,
1239
- appPath,
1240
- contentHash: currentHash
1241
- });
1242
- } else {
1243
- // Only update if content has changed
1244
- const cachedApp = cachedIrisApps.find(a => a.slug === slug);
1245
- const contentChanged = !cachedApp?.contentHash || cachedApp.contentHash !== currentHash;
1246
-
1247
- if (contentChanged) {
1248
- actionQueue.push({
1249
- action: 'update_iris_app',
1250
- slug,
1251
- appName: linkedProject?.appName || cachedApp?.appName || slug,
1252
- appDescription,
1253
- appIconId,
1254
- appPath,
1255
- contentHash: currentHash
1256
- });
1257
- }
1258
- }
1259
- }
1260
-
1261
- // Detect deleted Iris apps (works even if iris-apps folder doesn't exist)
1262
- for (const cachedApp of cachedIrisApps) {
1263
- if (!localIrisSlugs.has(cachedApp.slug)) {
1264
- actionQueue.push({
1265
- action: 'delete_iris_app',
1266
- slug: cachedApp.slug
1267
- });
1268
- }
1269
- }
1270
-
1271
- // Step 8: Handle brand-new, tag-less files
1272
- if (progress) progress.startStep('prepare');
1273
-
1274
- for (const f of newLocalNoId) {
1275
- const safe = readFileSafe(f.path);
1276
- if (!safe) {
1277
- if (!silent) {
1278
- console.log(chalk.yellow(`Skipping unreadable file: ${f.path}`));
1279
- }
1280
- continue;
1281
- }
1282
-
1283
- const { content, hash } = safe;
1284
- const { type, entity, contentField } = inferMeta(path.resolve(f.path));
1285
-
1286
- actionQueue.push({
1287
- action: "create",
1288
- filePath: f.path,
1289
- recordId: null,
1290
- type,
1291
- entity,
1292
- fields: { [contentField]: content },
1293
- contentHash: hash,
1294
- });
1295
- }
1296
-
1297
- // Step 9: Filter out redundant file and folder deletions
1298
- const foldersBeingDeleted = actionQueue
1299
- .filter(a => a.action === 'delete_folder')
1300
- .map(a => a.folderPath);
1301
-
1302
- const filteredActionQueue = actionQueue.filter(action => {
1303
- // Filter out files inside folders being deleted
1304
- if (action.action === 'delete_static_asset' && action.filePath) {
1305
- const fileDir = path.dirname(action.filePath);
1306
- for (const deletedFolder of foldersBeingDeleted) {
1307
- const normalizedFileDir = path.normalize(fileDir);
1308
- const normalizedDeletedFolder = path.normalize(deletedFolder);
1309
-
1310
- if (normalizedFileDir === normalizedDeletedFolder ||
1311
- normalizedFileDir.startsWith(normalizedDeletedFolder + path.sep)) {
1312
- return false; // Skip - covered by folder deletion
1313
- }
1314
- }
1315
- }
1316
-
1317
- // Filter out child folders inside folders being deleted
1318
- if (action.action === 'delete_folder' && action.folderPath) {
1319
- for (const deletedFolder of foldersBeingDeleted) {
1320
- // Don't compare a folder to itself
1321
- if (action.folderPath === deletedFolder) continue;
1322
-
1323
- const normalizedChildFolder = path.normalize(action.folderPath);
1324
- const normalizedParentFolder = path.normalize(deletedFolder);
1325
-
1326
- // Check if this folder is inside another folder being deleted
1327
- if (normalizedChildFolder.startsWith(normalizedParentFolder + path.sep)) {
1328
- return false; // Skip - covered by parent folder deletion
1329
- }
1330
- }
1331
- }
1332
-
1333
- return true;
1334
- });
1335
-
1336
- if (progress) {
1337
- progress.completeStep('prepare', `✓ Prepared ${filteredActionQueue.length} actions`);
1338
- progress.finish();
1339
- }
1340
-
1341
- // Step 10: Display and execute action queue
1342
- if (!silent) {
1343
- console.log(chalk.blue("\n--- Publish Action Queue ---"));
1344
- if (!filteredActionQueue.length) {
1345
- console.log(chalk.green("All files are in sync — nothing to publish!"));
1346
- } else {
1347
- filteredActionQueue.forEach((a, i) => {
1348
- const num = chalk.green(`[${i + 1}]`);
1349
- const act = chalk.yellow(a.action.toUpperCase());
1350
-
1351
- let type, displayPath, label;
1352
- if (a.slug) {
1353
- // Iris app action
1354
- type = chalk.cyan(`${a.appName || a.slug}`);
1355
- displayPath = a.appPath || a.slug;
1356
- label = "App";
1357
- } else if (a.folderName) {
1358
- type = chalk.cyan(a.folderName);
1359
- displayPath = a.folderPath;
1360
- label = "Folder";
1361
- } else if (a.names) {
1362
- type = chalk.cyan(a.names.join(", "));
1363
- displayPath = a.folder;
1364
- label = "File";
1365
- } else if (a.filePath) {
1366
- type = chalk.cyan(a.type || path.basename(a.filePath));
1367
- displayPath = a.filePath;
1368
- label = a.type ? "Type" : "File";
1369
- } else {
1370
- type = chalk.cyan(a.type || "Unknown");
1371
- displayPath = a.folder || "Unknown";
1372
- label = "File";
1373
- }
1374
-
1375
- const idInfo = a.recordId ? ` ${chalk.magenta(a.recordId)}` : "";
1376
- const renameInfo = a.renamed
1377
- ? ` → ${chalk.gray(a.oldPath)} ${chalk.white("→")} ${chalk.gray(a.filePath)}`
1378
- : "";
1379
-
1380
- console.log(`${num} ${act} | ${label}: ${type}${idInfo}${renameInfo} (${displayPath})`);
1381
- });
1382
-
1383
- console.log(chalk.blue("\n--- Publishing Changes ---"));
1384
- }
1385
- }
1386
-
1387
- // Execute actions
1388
- if (filteredActionQueue.length > 0) {
1389
- if (silent) {
1390
- await performNetworkRequest(filteredActionQueue);
1391
- } else {
1392
- await withSpinner(
1393
- "Working...",
1394
- async () => {
1395
- await performNetworkRequest(filteredActionQueue)
1396
- },
1397
- { showCompletion: false }
1398
- );
1399
- }
1400
- }
1401
-
1402
- return {
1403
- actionQueue: filteredActionQueue,
1404
- hasChanges: filteredActionQueue.length > 0
1405
- };
1406
- };
1407
-
1408
- /**
1409
- * CLI command wrapper for publish.
1410
- */
1411
- export const publish = async () => {
1412
- process.stdout.write("\x1Bc"); // clear console
1413
-
1414
- try {
1415
- await runPublish({ silent: false });
1416
- } catch (err) {
1417
- console.error(chalk.red.bold("Publish failed:"), err.message);
1418
- process.exit(1);
1419
- }
1420
- };
1
+ import { walkFiles } from "../utils/cacher.js";
2
+ import { ensureValidCredentials } from "../utils/cli/helpers/ensureCredentials.js";
3
+ import Config from "../utils/config.js";
4
+ import { withSpinner } from "../utils/spinner.js";
5
+ import { ProgressTracker } from "../utils/progress.js";
6
+ import fs from "fs";
7
+ import path from "path";
8
+ import chalk from "chalk";
9
+ import {
10
+ ENTITY_FIELD_MAP,
11
+ ENTITY_TYPE_MAP,
12
+ EXPORT_ROOT,
13
+ TYPE_DIR_MAP,
14
+ IRIS_APPS_DIR,
15
+ ALLOWED_SRC_DIRS,
16
+ } from "../vars/global.js";
17
+ import { getFileTag, setFileTag } from "../utils/filetag.js";
18
+ import { sha256 } from "../utils/hash.js";
19
+ import { createEntity } from "../utils/magentrix/api/createEntity.js";
20
+ import { updateEntity } from "../utils/magentrix/api/updateEntity.js";
21
+ import { deleteEntity } from "../utils/magentrix/api/deleteEntity.js";
22
+ import { removeFromBase, updateBase } from "../utils/updateFileBase.js";
23
+ import { deleteAsset, uploadAsset, createFolder } from "../utils/magentrix/api/assets.js";
24
+ import { toApiPath, toApiFolderPath } from "../utils/assetPaths.js";
25
+ import { publishApp } from "../utils/magentrix/api/iris.js";
26
+ import { createIrisZip, hashIrisAppFolder } from "../utils/iris/zipper.js";
27
+ import { validateIrisAppFolder } from "../utils/iris/validator.js";
28
+ import { getLinkedProjects } from "../utils/iris/linker.js";
29
+ import { deleteIrisAppFromServer } from "../utils/iris/deleteHelper.js";
30
+
31
+ const config = new Config();
32
+
33
+ /* ==================== CONFIGURATION ==================== */
34
+
35
+ /**
36
+ * Set to true to process all operations sequentially (one at a time).
37
+ * Set to false to process operations in parallel with intelligent grouping.
38
+ *
39
+ * Sequential mode is slower but avoids any potential race conditions or
40
+ * server-side rate limiting issues.
41
+ */
42
+ const USE_SEQUENTIAL_PROCESSING = true;
43
+
44
+ /* ==================== UTILITY FUNCTIONS ==================== */
45
+
46
+ /**
47
+ * Infers Magentrix entity metadata from a file path by walking up the directory tree.
48
+ * @param {string} filePath - The file path to analyze
49
+ * @returns {Object} { type, entity, contentField } for Magentrix API
50
+ */
51
+ const inferMeta = (filePath) => {
52
+ let currentPath = path.dirname(filePath);
53
+ const exportRootAbs = path.resolve(EXPORT_ROOT);
54
+
55
+ while (currentPath.startsWith(exportRootAbs)) {
56
+ const folderName = path.basename(currentPath);
57
+ const type = Object.keys(TYPE_DIR_MAP).find(
58
+ (k) => TYPE_DIR_MAP[k].directory === folderName
59
+ );
60
+
61
+ if (type) {
62
+ return {
63
+ type,
64
+ entity: ENTITY_TYPE_MAP[type],
65
+ contentField: ENTITY_FIELD_MAP[type],
66
+ };
67
+ }
68
+
69
+ const parentPath = path.dirname(currentPath);
70
+ if (parentPath === currentPath) break; // Reached root
71
+ currentPath = parentPath;
72
+ }
73
+
74
+ return { type: undefined, entity: undefined, contentField: undefined };
75
+ };
76
+
77
+ /**
78
+ * Safely reads file content and computes hash.
79
+ * @param {string} filePath - Path to the file
80
+ * @returns {Object|null} { content, hash } or null on error
81
+ */
82
+ const readFileSafe = (filePath) => {
83
+ try {
84
+ const content = fs.readFileSync(filePath, "utf-8");
85
+ return { content, hash: sha256(content) };
86
+ } catch (err) {
87
+ console.error(chalk.red.bold("Error:") + " Unable to read file " + chalk.white.bold(filePath));
88
+ console.error(chalk.gray("→ Please verify the file exists and you have read permissions."));
89
+ return null;
90
+ }
91
+ };
92
+
93
+ /**
94
+ * Formats multiline error messages with proper indentation.
95
+ */
96
+ const formatMultilineError = (err) => {
97
+ const lines = String(err).split(/\r?\n/);
98
+ return lines
99
+ .map((line, i) => {
100
+ const prefix = i === 0 ? `${chalk.redBright(' •')} ` : ' ';
101
+ return prefix + chalk.whiteBright(line);
102
+ })
103
+ .join('\n');
104
+ };
105
+
106
+ /**
107
+ * Recursively walks a directory and returns all subdirectory paths.
108
+ */
109
+ const walkFolders = (dir) => {
110
+ if (!fs.existsSync(dir)) return [];
111
+
112
+ const folders = [];
113
+ const items = fs.readdirSync(dir, { withFileTypes: true });
114
+
115
+ for (const item of items) {
116
+ if (item.isDirectory()) {
117
+ const fullPath = path.join(dir, item.name);
118
+ folders.push(fullPath);
119
+ folders.push(...walkFolders(fullPath));
120
+ }
121
+ }
122
+
123
+ return folders;
124
+ };
125
+
126
+ /**
127
+ * Gets display name for an action in log messages.
128
+ */
129
+ const getActionDisplayName = (action) => {
130
+ // Iris app actions
131
+ if (action.slug) return `${action.appName || action.slug} (${action.slug})`;
132
+ if (action.folderName) return action.folderName;
133
+ if (action.names) return action.names.join(", ");
134
+ if (action.type) return action.type;
135
+ if (action.filePath) return path.basename(action.filePath);
136
+ return '';
137
+ };
138
+
139
+ /* ==================== ACTION HANDLERS ==================== */
140
+
141
+ /**
142
+ * Handles CREATE action for code entities (ActiveClass/ActivePage).
143
+ */
144
+ const handleCreateAction = async (instanceUrl, apiKey, action) => {
145
+ const data = {
146
+ Name: path.basename(action.filePath, path.extname(action.filePath)),
147
+ Type: action.type,
148
+ ...action.fields
149
+ };
150
+
151
+ const result = await createEntity(instanceUrl, apiKey, action.entity, data);
152
+ return { recordId: result.recordId || result.id };
153
+ };
154
+
155
+ /**
156
+ * Handles UPDATE action for code entities (ActiveClass/ActivePage).
157
+ */
158
+ const handleUpdateAction = async (instanceUrl, apiKey, action) => {
159
+ const data = { ...action.fields };
160
+
161
+ if (action.renamed) {
162
+ data.Name = path.basename(action.filePath, path.extname(action.filePath));
163
+ }
164
+
165
+ await updateEntity(instanceUrl, apiKey, action.entity, action.recordId, data);
166
+ return { recordId: action.recordId };
167
+ };
168
+
169
+ /**
170
+ * Handles DELETE action for code entities (ActiveClass/ActivePage).
171
+ */
172
+ const handleDeleteAction = async (instanceUrl, apiKey, action) => {
173
+ return await deleteEntity(instanceUrl, apiKey, action.entity, action.recordId);
174
+ };
175
+
176
+ /**
177
+ * Handles CREATE_STATIC_ASSET action.
178
+ */
179
+ const handleCreateStaticAssetAction = async (instanceUrl, apiKey, action) => {
180
+ const response = await uploadAsset(instanceUrl, apiKey, `/${action.folder}`, [action.filePath]);
181
+ if (response?.error) throw new Error(response.error);
182
+ return response;
183
+ };
184
+
185
+ /**
186
+ * Handles DELETE_STATIC_ASSET action.
187
+ */
188
+ const handleDeleteStaticAssetAction = async (instanceUrl, apiKey, action) => {
189
+ try {
190
+ const response = await deleteAsset(instanceUrl, apiKey, `/${action.folder}`, action.names);
191
+ return response;
192
+ } catch (error) {
193
+ // Check if this is a "not found" error
194
+ const errorMessage = error?.message || String(error);
195
+ const errorLower = errorMessage.toLowerCase();
196
+ const isNotFound = errorLower.includes('404') ||
197
+ errorLower.includes('not found') ||
198
+ errorLower.includes('item not found');
199
+
200
+ if (isNotFound) {
201
+ // Clean up base.json since file doesn't exist on server
202
+ // Use the original base.json key if available (avoids path format mismatches)
203
+ if (action.baseKey) {
204
+ removeFromBase(action.baseKey);
205
+ } else {
206
+ for (const name of action.names) {
207
+ const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
208
+ removeFromBase(filePath);
209
+ }
210
+ }
211
+ return { cleanedFromCache: true };
212
+ }
213
+
214
+ // Other errors should still fail
215
+ throw error;
216
+ }
217
+ };
218
+
219
+ /**
220
+ * Handles CREATE_FOLDER action.
221
+ */
222
+ const handleCreateFolderAction = async (instanceUrl, apiKey, action) => {
223
+ try {
224
+ const response = await createFolder(instanceUrl, apiKey, action.parentPath, action.folderName);
225
+ return response;
226
+ } catch (error) {
227
+ // Check if folder already exists (likely created by file upload)
228
+ const errorMessage = error?.message || String(error);
229
+ const errorLower = errorMessage.toLowerCase();
230
+ const alreadyExists = errorLower.includes('already exists') ||
231
+ errorLower.includes('folder exists') ||
232
+ errorLower.includes('duplicate');
233
+
234
+ if (alreadyExists) {
235
+ // Folder already exists, update cache and treat as success
236
+ return { alreadyExisted: true };
237
+ }
238
+
239
+ // Other errors should still fail
240
+ throw error;
241
+ }
242
+ };
243
+
244
+ /**
245
+ * Handles DELETE_FOLDER action.
246
+ */
247
+ const handleDeleteFolderAction = async (instanceUrl, apiKey, action) => {
248
+ try {
249
+ const response = await deleteAsset(instanceUrl, apiKey, action.parentPath, [action.folderName]);
250
+ return response;
251
+ } catch (error) {
252
+ // Check if this is a "not found" error
253
+ const errorMessage = error?.message || String(error);
254
+ const errorLower = errorMessage.toLowerCase();
255
+ const isNotFound = errorLower.includes('404') ||
256
+ errorLower.includes('not found') ||
257
+ errorLower.includes('item not found');
258
+
259
+ if (isNotFound) {
260
+ // Clean up base.json since folder doesn't exist on server
261
+ // Use original base.json key if available (avoids path format mismatches)
262
+ removeFromBase(action.baseKey || action.folderPath);
263
+
264
+ // Also remove all files and subfolders inside this folder from base
265
+ const hits = await config.searchObject({}, { filename: "base.json", global: false });
266
+ const cachedResults = hits?.[0]?.value || {};
267
+
268
+ for (const [recordId, cachedEntry] of Object.entries(cachedResults)) {
269
+ // Check all possible path fields
270
+ const entryPath = cachedEntry.lastKnownActualPath || cachedEntry.filePath || cachedEntry.lastKnownPath;
271
+ if (entryPath && typeof entryPath === 'string') {
272
+ const normalizedEntryPath = path.normalize(path.resolve(entryPath)).toLowerCase();
273
+ const normalizedFolderPath = path.normalize(path.resolve(action.folderPath)).toLowerCase();
274
+
275
+ // Check if this entry is inside the deleted folder
276
+ if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep) ||
277
+ normalizedEntryPath === normalizedFolderPath) {
278
+ removeFromBase(recordId);
279
+ }
280
+ }
281
+ }
282
+
283
+ return { cleanedFromCache: true };
284
+ }
285
+
286
+ // Other errors should still fail
287
+ throw error;
288
+ }
289
+ };
290
+
291
+ /**
292
+ * Handles CREATE_IRIS_APP or UPDATE_IRIS_APP action.
293
+ */
294
+ const handlePublishIrisAppAction = async (instanceUrl, apiKey, action) => {
295
+ // Create zip from the app folder
296
+ const zipBuffer = await createIrisZip(action.appPath, action.slug);
297
+
298
+ // For updates, don't send app-name/description/icon to avoid triggering
299
+ // metadata modification that may require permissions the user doesn't have.
300
+ // The server already has the correct metadata from the original create.
301
+ // For creates, send all metadata so the app is properly registered.
302
+ const isUpdate = action.action === 'update_iris_app';
303
+
304
+ const response = await publishApp(
305
+ instanceUrl,
306
+ apiKey,
307
+ zipBuffer,
308
+ `${action.slug}.zip`,
309
+ isUpdate ? null : action.appName,
310
+ isUpdate ? {} : {
311
+ appDescription: action.appDescription,
312
+ appIconId: action.appIconId
313
+ }
314
+ );
315
+
316
+ return response;
317
+ };
318
+
319
+ /**
320
+ * Handles DELETE_IRIS_APP action.
321
+ */
322
+ const handleDeleteIrisAppAction = async (instanceUrl, apiKey, action) => {
323
+ // Use shared delete utility for consistency
324
+ return await deleteIrisAppFromServer(instanceUrl, apiKey, action.slug, {
325
+ updateCache: true // Cache will be updated by the utility
326
+ });
327
+ };
328
+
329
+ /**
330
+ * Synchronizes class name in content with filename, or vice versa.
331
+ */
332
+ const syncClassAndFileNames = (action, recordId) => {
333
+ // Only for ActiveClass
334
+ if (action.entity !== 'ActiveClass') return;
335
+
336
+ const filePath = action.filePath;
337
+ // If file was deleted or doesn't exist, skip
338
+ if (!fs.existsSync(filePath)) return;
339
+
340
+ const content = fs.readFileSync(filePath, 'utf-8');
341
+ const filename = path.basename(filePath, path.extname(filePath));
342
+
343
+ // Regex to find class/interface/enum name
344
+ // Matches: public class Name, public interface Name, public enum Name
345
+ // We assume standard formatting
346
+ const classRegex = /public\s+(?:class|interface|enum)\s+(\w+)/;
347
+ const match = content.match(classRegex);
348
+
349
+ if (match) {
350
+ const classNameInContent = match[1];
351
+
352
+ if (classNameInContent !== filename) {
353
+ // Mismatch detected
354
+
355
+ // Case 1: File was renamed (action.renamed is true) -> Update content
356
+ if (action.renamed) {
357
+ const newContent = content.replace(classRegex, (fullMatch, name) => {
358
+ return fullMatch.replace(name, filename);
359
+ });
360
+ fs.writeFileSync(filePath, newContent);
361
+ console.log(chalk.cyan(` ↻ Updated class name in file to: ${filename}`));
362
+
363
+ // Update cache with new content hash
364
+ updateBase(filePath, { Id: recordId, Type: 'ActiveClass' });
365
+ }
366
+ // Case 2: Content was updated (action.renamed is false) -> Rename file
367
+ else {
368
+ // Rename file to match class name
369
+ const dir = path.dirname(filePath);
370
+ const ext = path.extname(filePath);
371
+ const newFilename = `${classNameInContent}${ext}`;
372
+ const newFilePath = path.join(dir, newFilename);
373
+
374
+ if (fs.existsSync(newFilePath)) {
375
+ console.warn(chalk.yellow(` ⚠️ Cannot rename ${filename} to ${classNameInContent}: File already exists.`));
376
+ return;
377
+ }
378
+
379
+ try {
380
+ fs.renameSync(filePath, newFilePath);
381
+ console.log(chalk.cyan(` ↻ Renamed file to match class: ${newFilename}`));
382
+
383
+ // Update cache: update the entry for this recordId to point to new path
384
+ updateBase(newFilePath, { Id: recordId, Type: 'ActiveClass' }, newFilePath);
385
+ } catch (err) {
386
+ console.warn(chalk.yellow(` ⚠️ Failed to rename file: ${err.message}`));
387
+ }
388
+ }
389
+ }
390
+ }
391
+ };
392
+
393
+ /**
394
+ * Updates cache after successful operations.
395
+ */
396
+ const updateCacheAfterSuccess = async (action, operationResult) => {
397
+ try {
398
+ // Sync class names/filenames if needed (Bug 2 Fix)
399
+ if (action.action === 'update' && operationResult?.recordId) {
400
+ syncClassAndFileNames(action, operationResult.recordId);
401
+ }
402
+
403
+ switch (action.action) {
404
+ case "create": {
405
+ const createSnapshot = action.fields && Object.values(action.fields)[0]
406
+ ? { content: Object.values(action.fields)[0], hash: action.contentHash }
407
+ : null;
408
+ await setFileTag(action.filePath, operationResult.recordId);
409
+ updateBase(
410
+ action.filePath,
411
+ { Id: operationResult.recordId, Type: action.type },
412
+ '',
413
+ createSnapshot
414
+ );
415
+ break;
416
+ }
417
+
418
+ case "update": {
419
+ const updateSnapshot = action.fields && Object.values(action.fields)[0]
420
+ ? { content: Object.values(action.fields)[0], hash: sha256(Object.values(action.fields)[0]) }
421
+ : null;
422
+ const type = Object.keys(ENTITY_TYPE_MAP).find(key => ENTITY_TYPE_MAP[key] === action.entity);
423
+ updateBase(
424
+ action.filePath,
425
+ { Id: action.recordId, Type: type },
426
+ '',
427
+ updateSnapshot
428
+ );
429
+ break;
430
+ }
431
+
432
+ case "delete":
433
+ removeFromBase(action.recordId);
434
+ break;
435
+
436
+ case "delete_static_asset":
437
+ // Skip if already cleaned from cache during 404 handling
438
+ if (!operationResult?.cleanedFromCache) {
439
+ // Use the original base.json key if available (avoids path format mismatches)
440
+ if (action.baseKey) {
441
+ removeFromBase(action.baseKey);
442
+ } else {
443
+ for (const name of action.names) {
444
+ const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
445
+ removeFromBase(filePath);
446
+ }
447
+ }
448
+ }
449
+ break;
450
+
451
+ case "create_static_asset":
452
+ updateBase(action.filePath, { Id: action.filePath, Type: "File" });
453
+ break;
454
+
455
+ case "create_folder":
456
+ updateBase(action.folderPath, { Id: action.folderPath, Type: "Folder" });
457
+ break;
458
+
459
+ case "delete_folder": {
460
+ // Skip if already cleaned from cache during 404 handling
461
+ if (!operationResult?.cleanedFromCache) {
462
+ // Remove the folder itself from base using the original base.json key
463
+ removeFromBase(action.baseKey || action.folderPath);
464
+
465
+ // Also remove all files and subfolders inside this folder from base
466
+ const hits = await config.searchObject({}, { filename: "base.json", global: false });
467
+ const cachedResults = hits?.[0]?.value || {};
468
+
469
+ for (const [recordId, cachedEntry] of Object.entries(cachedResults)) {
470
+ // Check all possible path fields
471
+ const entryPath = cachedEntry.lastKnownActualPath || cachedEntry.filePath || cachedEntry.lastKnownPath;
472
+ if (entryPath && typeof entryPath === 'string') {
473
+ const normalizedEntryPath = path.normalize(path.resolve(entryPath)).toLowerCase();
474
+ const normalizedFolderPath = path.normalize(path.resolve(action.folderPath)).toLowerCase();
475
+
476
+ // Check if this entry is inside the deleted folder
477
+ if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep) ||
478
+ normalizedEntryPath === normalizedFolderPath) {
479
+ removeFromBase(recordId);
480
+ }
481
+ }
482
+ }
483
+ }
484
+ break;
485
+ }
486
+
487
+ case "create_iris_app":
488
+ case "update_iris_app": {
489
+ // Update base.json with new/updated Iris app info including content hash
490
+ updateBase(action.appPath, {
491
+ Id: `iris-app:${action.slug}`,
492
+ Type: 'IrisApp',
493
+ folderName: action.slug,
494
+ appName: action.appName,
495
+ modifiedOn: new Date().toISOString(),
496
+ contentHash: action.contentHash // Store hash for change detection
497
+ }, action.appPath);
498
+ break;
499
+ }
500
+
501
+ case "delete_iris_app": {
502
+ // Skip if already cleaned from cache during 404 handling
503
+ if (!operationResult?.cleanedFromCache) {
504
+ removeFromBase(`iris-app:${action.slug}`);
505
+ }
506
+ break;
507
+ }
508
+ }
509
+ } catch (error) {
510
+ console.warn(chalk.yellow(`Warning: Failed to update cache for ${action.action}: ${error.message}`));
511
+ }
512
+ };
513
+
514
+ /* ==================== NETWORK REQUEST HANDLER ==================== */
515
+
516
+ /**
517
+ * Groups actions by resource ID to detect conflicts and sequence operations.
518
+ * @returns {Object} { byResource: Map<recordId, actions[]>, assets: actions[] }
519
+ */
520
+ const groupActionsByResource = (actionQueue) => {
521
+ const byResource = new Map(); // recordId -> actions[]
522
+ const assets = []; // Asset operations (can run in parallel)
523
+
524
+ for (let i = 0; i < actionQueue.length; i++) {
525
+ const action = { ...actionQueue[i], originalIndex: i };
526
+
527
+ // Asset and Iris app operations don't need sequencing
528
+ if (['create_static_asset', 'delete_static_asset', 'create_folder', 'delete_folder', 'create_iris_app', 'update_iris_app', 'delete_iris_app'].includes(action.action)) {
529
+ assets.push(action);
530
+ continue;
531
+ }
532
+
533
+ // Code entity operations - group by recordId
534
+ const resourceId = action.recordId;
535
+ if (!resourceId) {
536
+ // Create actions without recordId yet - treat as unique resource
537
+ assets.push(action);
538
+ continue;
539
+ }
540
+
541
+ if (!byResource.has(resourceId)) {
542
+ byResource.set(resourceId, []);
543
+ }
544
+ byResource.get(resourceId).push(action);
545
+ }
546
+
547
+ return { byResource, assets };
548
+ };
549
+
550
+ /**
551
+ * Resolves conflicts for a single resource and returns actions to execute in sequence.
552
+ * Rules:
553
+ * - If DELETE exists, drop all other actions (delete wins)
554
+ * - If multiple UPDATEs exist, keep them in order (will execute sequentially)
555
+ */
556
+ const resolveResourceConflicts = (actions) => {
557
+ // Check if there's a delete action
558
+ const hasDelete = actions.some(a => a.action === 'delete');
559
+
560
+ if (hasDelete) {
561
+ // Delete wins - drop all other actions and only keep the delete
562
+ return actions.filter(a => a.action === 'delete');
563
+ }
564
+
565
+ // No delete - return all actions (they'll execute sequentially)
566
+ return actions;
567
+ };
568
+
569
+ /**
570
+ * Executes actions for a single resource sequentially.
571
+ */
572
+ const executeResourceActions = async (instanceUrl, token, actions) => {
573
+ const results = [];
574
+
575
+ for (const action of actions) {
576
+ try {
577
+ let result;
578
+ switch (action.action) {
579
+ case "create":
580
+ result = await handleCreateAction(instanceUrl, token, action);
581
+ break;
582
+ case "update":
583
+ result = await handleUpdateAction(instanceUrl, token, action);
584
+ break;
585
+ case "delete":
586
+ result = await handleDeleteAction(instanceUrl, token, action);
587
+ break;
588
+ default:
589
+ throw new Error(`Unknown action: ${action.action}`);
590
+ }
591
+ results.push({ index: action.originalIndex, action, result, success: true });
592
+ } catch (error) {
593
+ results.push({ index: action.originalIndex, action, error: error.message, success: false });
594
+ }
595
+ }
596
+
597
+ return results;
598
+ };
599
+
600
+ /**
601
+ * Executes a single action and returns the result.
602
+ */
603
+ const executeAction = async (instanceUrl, token, action) => {
604
+ let result;
605
+ switch (action.action) {
606
+ case "create":
607
+ result = await handleCreateAction(instanceUrl, token, action);
608
+ break;
609
+ case "update":
610
+ result = await handleUpdateAction(instanceUrl, token, action);
611
+ break;
612
+ case "delete":
613
+ result = await handleDeleteAction(instanceUrl, token, action);
614
+ break;
615
+ case "create_static_asset":
616
+ result = await handleCreateStaticAssetAction(instanceUrl, token, action);
617
+ break;
618
+ case "delete_static_asset":
619
+ result = await handleDeleteStaticAssetAction(instanceUrl, token, action);
620
+ break;
621
+ case "create_folder":
622
+ result = await handleCreateFolderAction(instanceUrl, token, action);
623
+ break;
624
+ case "delete_folder":
625
+ result = await handleDeleteFolderAction(instanceUrl, token, action);
626
+ break;
627
+ case "create_iris_app":
628
+ case "update_iris_app":
629
+ result = await handlePublishIrisAppAction(instanceUrl, token, action);
630
+ break;
631
+ case "delete_iris_app":
632
+ result = await handleDeleteIrisAppAction(instanceUrl, token, action);
633
+ break;
634
+ default:
635
+ throw new Error(`Unknown action: ${action.action}`);
636
+ }
637
+ return result;
638
+ };
639
+
640
+ /**
641
+ * Executes all actions sequentially (one at a time) with progress messages.
642
+ */
643
+ const performNetworkRequestSequential = async (actionQueue) => {
644
+ const { instanceUrl, token } = await ensureValidCredentials();
645
+
646
+ console.log(chalk.blue(`\n🔄 Sequential processing mode (${actionQueue.length} operations)\n`));
647
+
648
+ const results = [];
649
+ let successCount = 0;
650
+ let errorCount = 0;
651
+
652
+ for (let i = 0; i < actionQueue.length; i++) {
653
+ const action = { ...actionQueue[i], originalIndex: i };
654
+ const displayName = getActionDisplayName(action);
655
+
656
+ // Show progress message
657
+ console.log(chalk.gray(`[${i + 1}/${actionQueue.length}] Processing ${action.action.toUpperCase()} ${displayName}...`));
658
+
659
+ try {
660
+ const result = await executeAction(instanceUrl, token.value, action);
661
+ results.push({ index: i, action, result, success: true });
662
+
663
+ successCount++;
664
+ console.log(
665
+ chalk.green(`✓ [${i + 1}]`) +
666
+ ` ${chalk.yellow(action.action.toUpperCase())} ${chalk.cyan(displayName)} ` +
667
+ (result?.recordId ? chalk.magenta(result.recordId) : "")
668
+ );
669
+ await updateCacheAfterSuccess(action, result);
670
+ } catch (error) {
671
+ results.push({ index: i, action, error: error.message, success: false });
672
+
673
+ errorCount++;
674
+ console.log();
675
+ console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
676
+ console.log(chalk.redBright('─'.repeat(48)));
677
+ const actionPath = action.filePath || action.folderPath || action.folder || action.appPath || action.slug || 'unknown';
678
+ console.log(chalk.red.bold(`[${i + 1}] ${action.action.toUpperCase()} ${displayName} (${actionPath}):`));
679
+ console.log(formatMultilineError(error.message));
680
+ console.log(chalk.redBright('─'.repeat(48)));
681
+ }
682
+
683
+ console.log(); // Add spacing between operations
684
+ }
685
+
686
+ // Summary
687
+ console.log(chalk.blue("--- Publish Summary ---"));
688
+ console.log(chalk.green(`✓ Successful: ${successCount}`));
689
+ if (errorCount > 0) {
690
+ console.log(chalk.red(`✗ Failed: ${errorCount}`));
691
+ } else {
692
+ console.log(chalk.green("All operations completed successfully! 🎉"));
693
+ }
694
+ };
695
+
696
+ /**
697
+ * Executes all actions in the queue with proper sequencing and conflict resolution (parallel mode).
698
+ */
699
+ const performNetworkRequestParallel = async (actionQueue) => {
700
+ const { instanceUrl, token } = await ensureValidCredentials();
701
+
702
+ // Group actions by resource
703
+ const { byResource, assets } = groupActionsByResource(actionQueue);
704
+
705
+ // Resolve conflicts for each resource
706
+ const sequencedCodeActions = [];
707
+ const droppedActions = [];
708
+
709
+ for (const [resourceId, actions] of byResource.entries()) {
710
+ const resolved = resolveResourceConflicts(actions);
711
+ sequencedCodeActions.push(resolved);
712
+
713
+ // Track dropped actions
714
+ const droppedCount = actions.length - resolved.length;
715
+ if (droppedCount > 0) {
716
+ const dropped = actions.filter(a => !resolved.includes(a));
717
+ droppedActions.push(...dropped);
718
+ }
719
+ }
720
+
721
+ // Log dropped actions
722
+ if (droppedActions.length > 0) {
723
+ console.log(chalk.yellow(`\n⚠️ Dropped ${droppedActions.length} redundant operation(s) due to delete:`));
724
+ droppedActions.forEach(action => {
725
+ console.log(chalk.gray(` • ${action.action.toUpperCase()} on ${getActionDisplayName(action)} (superseded by DELETE)`));
726
+ });
727
+ }
728
+
729
+ // Execute asset operations in parallel (they don't conflict)
730
+ const assetPromises = assets.map(async (action) => {
731
+ try {
732
+ const result = await executeAction(instanceUrl, token.value, action);
733
+ return { index: action.originalIndex, action, result, success: true };
734
+ } catch (error) {
735
+ return { index: action.originalIndex, action, error: error.message, success: false };
736
+ }
737
+ });
738
+
739
+ // Execute code entity operations sequentially per resource (but resources in parallel)
740
+ const codePromises = sequencedCodeActions.map(actions =>
741
+ executeResourceActions(instanceUrl, token.value, actions)
742
+ );
743
+
744
+ // Wait for all operations
745
+ const assetResults = await Promise.allSettled(assetPromises);
746
+ const codeResults = await Promise.allSettled(codePromises);
747
+
748
+ // Flatten code results
749
+ const allCodeResults = codeResults
750
+ .filter(r => r.status === 'fulfilled')
751
+ .flatMap(r => r.value);
752
+
753
+ const allAssetResults = assetResults
754
+ .map(r => r.status === 'fulfilled' ? r.value : { status: 'rejected', reason: r.reason });
755
+
756
+ // Combine all results
757
+ const allResults = [...allCodeResults, ...allAssetResults];
758
+
759
+ // Sort by original index to maintain order
760
+ allResults.sort((a, b) => (a.index || 0) - (b.index || 0));
761
+
762
+ // Process and display results
763
+ let successCount = 0;
764
+ let errorCount = 0;
765
+
766
+ for (const result of allResults) {
767
+ if (result.success !== undefined) {
768
+ const { index, action, success, error, result: operationResult } = result;
769
+
770
+ if (success) {
771
+ successCount++;
772
+ const displayName = getActionDisplayName(action);
773
+ console.log(
774
+ chalk.green(`✓ [${index + 1}]`) +
775
+ ` ${chalk.yellow(action.action.toUpperCase())} ${chalk.cyan(displayName)} ` +
776
+ (operationResult?.recordId ? chalk.magenta(operationResult.recordId) : "")
777
+ );
778
+ await updateCacheAfterSuccess(action, operationResult);
779
+ } else {
780
+ errorCount++;
781
+ console.log();
782
+ console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
783
+ console.log(chalk.redBright('─'.repeat(48)));
784
+ const actionPath = action.filePath || action.folderPath || action.folder || action.appPath || action.slug || 'unknown';
785
+ console.log(chalk.red.bold(`[${index + 1}] ${action.action.toUpperCase()} ${getActionDisplayName(action)} (${actionPath}):`));
786
+ console.log(formatMultilineError(error));
787
+ console.log(chalk.redBright('─'.repeat(48)));
788
+ }
789
+ } else if (result.status === 'rejected') {
790
+ errorCount++;
791
+ console.log();
792
+ console.log(chalk.bgRed.bold.white(' ✖ Unexpected Error '));
793
+ console.log(chalk.redBright('─'.repeat(48)));
794
+ console.log(`${chalk.redBright(' •')} ${chalk.whiteBright(result.reason)}`);
795
+ console.log(chalk.redBright('─'.repeat(48)));
796
+ }
797
+ }
798
+
799
+ // Summary
800
+ console.log(chalk.blue("\n--- Publish Summary ---"));
801
+ console.log(chalk.green(`✓ Successful: ${successCount}`));
802
+ if (droppedActions.length > 0) {
803
+ console.log(chalk.yellow(`⊝ Dropped: ${droppedActions.length} (redundant)`));
804
+ }
805
+ if (errorCount > 0) {
806
+ console.log(chalk.red(`✗ Failed: ${errorCount}`));
807
+ } else {
808
+ console.log(chalk.green("All operations completed successfully! 🎉"));
809
+ }
810
+ };
811
+
812
+ /**
813
+ * Executes all actions in the queue (dispatcher function).
814
+ */
815
+ const performNetworkRequest = async (actionQueue) => {
816
+ if (USE_SEQUENTIAL_PROCESSING) {
817
+ return await performNetworkRequestSequential(actionQueue);
818
+ } else {
819
+ return await performNetworkRequestParallel(actionQueue);
820
+ }
821
+ };
822
+
823
+ /* ==================== MAIN PUBLISH LOGIC ==================== */
824
+
825
+ /**
826
+ * Core publish logic - can be called from autopublish or directly.
827
+ * @param {Object} options - Configuration options
828
+ * @param {boolean} options.silent - If true, suppress summary output
829
+ * @returns {Promise<{actionQueue: Array, hasChanges: boolean}>}
830
+ */
831
+ export const runPublish = async (options = {}) => {
832
+ const { silent = false } = options;
833
+
834
+ // Create progress tracker
835
+ const progress = silent ? null : new ProgressTracker('Publish to Magentrix');
836
+ if (progress) {
837
+ progress.addStep('auth', 'Authenticating...');
838
+ progress.addStep('load', 'Loading cached data...');
839
+ progress.addStep('scan', 'Scanning local files...');
840
+ progress.addStep('compare-assets', 'Comparing assets...', { hasProgress: true });
841
+ progress.addStep('compare-code', 'Comparing code entities...', { hasProgress: true });
842
+ progress.addStep('prepare', 'Preparing action queue...');
843
+ progress.start();
844
+ // Start first step immediately so UI shows up
845
+ progress.startStep('auth');
846
+ }
847
+
848
+ // Step 1: Authenticate
849
+ await ensureValidCredentials().catch((err) => {
850
+ if (progress) {
851
+ progress.abort(err.message);
852
+ } else if (!silent) {
853
+ console.error(chalk.red.bold("Authentication failed:"), chalk.white(err.message));
854
+ }
855
+ throw err;
856
+ });
857
+ if (progress) progress.completeStep('auth', '✓ Authenticated');
858
+
859
+ // Step 2: Load cached file state
860
+ if (progress) progress.startStep('load');
861
+
862
+ const loadStart = Date.now();
863
+ const hits = await config.searchObject({}, { filename: "base.json", global: false });
864
+ const cachedResults = hits?.[0]?.value || {};
865
+ const loadTime = Date.now() - loadStart;
866
+
867
+ if (!Object.keys(cachedResults).length) {
868
+ if (progress) {
869
+ progress.abort('No file cache found');
870
+ } else if (!silent) {
871
+ console.log(chalk.red.bold("No file cache found!"));
872
+ console.log(`Run ${chalk.cyan("magentrix pull")} to initialize your workspace.`);
873
+ }
874
+ throw new Error("No file cache found");
875
+ }
876
+
877
+ const mapStart = Date.now();
878
+ const cachedFiles = Object.values(cachedResults).map((c) => ({
879
+ ...c,
880
+ tag: c.recordId,
881
+ filePath: c.filePath || c.lastKnownPath,
882
+ type: c.type || c.Type, // Normalize Type/type property
883
+ }));
884
+ const mapTime = Date.now() - mapStart;
885
+
886
+ if (progress) {
887
+ progress.completeStep('load', `✓ Loaded ${cachedFiles.length} entries (${loadTime}ms load, ${mapTime}ms map)`);
888
+ }
889
+
890
+ // Step 3: Scan local workspace (only whitelisted code entity directories)
891
+ if (progress) progress.startStep('scan');
892
+
893
+ const walkStart = Date.now();
894
+ // Only scan whitelisted directories for code entities (exclude Assets and iris-apps, handled separately)
895
+ const codeEntityDirs = ALLOWED_SRC_DIRS.filter(dir => dir !== 'Assets' && dir !== IRIS_APPS_DIR);
896
+ const localPathArrays = await Promise.all(
897
+ codeEntityDirs.map(dir => {
898
+ const dirPath = path.join(EXPORT_ROOT, dir);
899
+ return fs.existsSync(dirPath) ? walkFiles(dirPath) : Promise.resolve([]);
900
+ })
901
+ );
902
+ const localPaths = localPathArrays.flat();
903
+ const walkTime = Date.now() - walkStart;
904
+
905
+ const tagStart = Date.now();
906
+ const localFiles = await Promise.all(
907
+ localPaths.map(async (p) => {
908
+ try {
909
+ const tag = await getFileTag(p);
910
+ return { tag, path: p };
911
+ } catch {
912
+ return { tag: null, path: p };
913
+ }
914
+ })
915
+ );
916
+ const tagTime = Date.now() - tagStart;
917
+
918
+ if (progress) {
919
+ progress.completeStep('scan', `✓ Found ${localPaths.length} files (${walkTime}ms walk, ${tagTime}ms tags)`);
920
+ }
921
+
922
+ // Step 4: Create lookup maps
923
+ if (progress) progress.startStep('compare-assets');
924
+
925
+ const mapBuildStart = Date.now();
926
+ const cacheById = Object.fromEntries(cachedFiles.map((c) => [c.tag, c]));
927
+ const localById = Object.fromEntries(localFiles.filter((f) => f.tag).map((f) => [f.tag, f]));
928
+ const newLocalNoId = localFiles.filter((f) => !f.tag);
929
+ const allIds = new Set([...Object.keys(cacheById), ...Object.keys(localById)]);
930
+ const mapBuildTime = Date.now() - mapBuildStart;
931
+
932
+ const actionQueue = [];
933
+
934
+ // Step 5: Handle static asset files - Build fast lookup map first (O(n) instead of O(n²))
935
+ const assetWalkStart = Date.now();
936
+ const assetPaths = await walkFiles(path.join(EXPORT_ROOT, 'Assets'));
937
+ const assetWalkTime = Date.now() - assetWalkStart;
938
+
939
+ // Build a Set of normalized cached asset paths for O(1) lookup
940
+ const setStart = Date.now();
941
+ const cachedAssetPaths = new Set();
942
+ cachedFiles
943
+ .filter(cf => cf.type === 'File' || cf.type === 'Folder')
944
+ .forEach(cf => {
945
+ if (cf.lastKnownActualPath) {
946
+ cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownActualPath)).toLowerCase());
947
+ }
948
+ if (cf.filePath) {
949
+ cachedAssetPaths.add(path.normalize(path.resolve(cf.filePath)).toLowerCase());
950
+ }
951
+ if (cf.lastKnownPath) {
952
+ cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownPath)).toLowerCase());
953
+ }
954
+ });
955
+ const setTime = Date.now() - setStart;
956
+
957
+ // Now compare assets with O(1) lookup
958
+ for (let i = 0; i < assetPaths.length; i++) {
959
+ const assetPath = assetPaths[i];
960
+ const normalizedAssetPath = path.normalize(path.resolve(assetPath)).toLowerCase();
961
+
962
+ // Update progress every 100 files
963
+ if (progress && i % 100 === 0) {
964
+ progress.updateProgress('compare-assets', i, assetPaths.length, `Checking ${i}/${assetPaths.length} assets`);
965
+ }
966
+
967
+ // O(1) lookup instead of O(n) find()
968
+ if (cachedAssetPaths.has(normalizedAssetPath)) {
969
+ continue;
970
+ }
971
+
972
+ actionQueue.push({
973
+ action: "create_static_asset",
974
+ folder: toApiPath(assetPath),
975
+ filePath: assetPath
976
+ });
977
+ }
978
+
979
+ if (progress) {
980
+ progress.completeStep('compare-assets', `✓ Compared ${assetPaths.length} assets (walk:${assetWalkTime}ms, set:${setTime}ms, map:${mapBuildTime}ms)`);
981
+ }
982
+
983
+ // Step 6: Handle folder creation and deletion - Also optimized with Set
984
+ const assetsDir = path.join(EXPORT_ROOT, 'Assets');
985
+ if (fs.existsSync(assetsDir)) {
986
+ const localFolders = walkFolders(assetsDir);
987
+ const cachedFolders = cachedFiles
988
+ .filter(c => c.type === 'Folder' && (c.filePath || c.lastKnownPath || c.lastKnownActualPath));
989
+
990
+ // Build Set of cached folder paths for O(1) lookup
991
+ const cachedFolderPaths = new Set();
992
+ cachedFolders.forEach(cf => {
993
+ if (cf.lastKnownActualPath) {
994
+ cachedFolderPaths.add(path.normalize(path.resolve(cf.lastKnownActualPath)).toLowerCase());
995
+ }
996
+ if (cf.lastKnownPath) {
997
+ cachedFolderPaths.add(path.normalize(path.resolve(cf.lastKnownPath)).toLowerCase());
998
+ }
999
+ if (cf.filePath) {
1000
+ cachedFolderPaths.add(path.normalize(path.resolve(cf.filePath)).toLowerCase());
1001
+ }
1002
+ });
1003
+
1004
+ // Build Set of local folder paths for O(1) lookup
1005
+ const localFolderPaths = new Set(
1006
+ localFolders.map(lf => path.normalize(path.resolve(lf)).toLowerCase())
1007
+ );
1008
+
1009
+ // New folders - O(1) lookup
1010
+ for (const folderPath of localFolders) {
1011
+ if (!folderPath) continue;
1012
+
1013
+ const normalizedFolderPath = path.normalize(path.resolve(folderPath)).toLowerCase();
1014
+
1015
+ if (cachedFolderPaths.has(normalizedFolderPath)) {
1016
+ continue;
1017
+ }
1018
+
1019
+ const parentDir = path.dirname(folderPath);
1020
+ if (!parentDir || parentDir === '.' || parentDir === folderPath) continue;
1021
+
1022
+ actionQueue.push({
1023
+ action: "create_folder",
1024
+ folderPath: folderPath,
1025
+ parentPath: toApiFolderPath(parentDir),
1026
+ folderName: path.basename(folderPath)
1027
+ });
1028
+ }
1029
+
1030
+ // Deleted folders - O(1) lookup
1031
+ for (const cachedFolder of cachedFolders) {
1032
+ const cachedPath = cachedFolder.lastKnownActualPath || cachedFolder.lastKnownPath || cachedFolder.filePath;
1033
+ if (!cachedPath || typeof cachedPath !== 'string') continue;
1034
+
1035
+ const normalizedCachedPath = path.normalize(path.resolve(cachedPath)).toLowerCase();
1036
+
1037
+ if (localFolderPaths.has(normalizedCachedPath)) {
1038
+ continue;
1039
+ }
1040
+
1041
+ const parentDir = path.dirname(cachedPath);
1042
+ if (!parentDir || parentDir === '.' || parentDir === cachedPath) continue;
1043
+
1044
+ actionQueue.push({
1045
+ action: "delete_folder",
1046
+ folderPath: cachedPath,
1047
+ parentPath: toApiFolderPath(parentDir),
1048
+ folderName: path.basename(cachedPath),
1049
+ baseKey: cachedFolder.tag // The original base.json key for correct cache cleanup
1050
+ });
1051
+ }
1052
+ }
1053
+
1054
+ // Step 7: Process code entities (ActiveClass/ActivePage) and static assets
1055
+ if (progress) progress.startStep('compare-code');
1056
+
1057
+ const allIdsArray = Array.from(allIds);
1058
+ for (let idx = 0; idx < allIdsArray.length; idx++) {
1059
+ const id = allIdsArray[idx];
1060
+
1061
+ // Update progress every 50 items
1062
+ if (progress && idx % 50 === 0) {
1063
+ progress.updateProgress('compare-code', idx, allIdsArray.length, `Checking ${idx}/${allIdsArray.length} entities`);
1064
+ }
1065
+
1066
+ try {
1067
+ const cacheFile = cacheById[id];
1068
+ const curFile = localById[id];
1069
+
1070
+ // Skip folders - they're handled separately
1071
+ if (cacheFile?.type === 'Folder') continue;
1072
+
1073
+ // Skip Iris apps - they're handled separately (check both type and ID prefix)
1074
+ if (cacheFile?.type === 'IrisApp' || id.startsWith('iris-app:')) continue;
1075
+
1076
+ // Handle static asset files
1077
+ if (cacheFile?.type === 'File') {
1078
+ // Use lastKnownActualPath which has the correct path (e.g., "src/Assets/...")
1079
+ const actualPath = cacheFile.lastKnownActualPath || cacheFile.filePath;
1080
+ const localAssetExists = fs.existsSync(actualPath);
1081
+
1082
+ if (!localAssetExists) {
1083
+ actionQueue.push({
1084
+ action: 'delete_static_asset',
1085
+ folder: toApiPath(actualPath),
1086
+ names: [path.basename(actualPath)],
1087
+ filePath: actualPath, // Store actual file path for filtering
1088
+ baseKey: id // The original base.json key for correct cache cleanup
1089
+ });
1090
+ continue;
1091
+ }
1092
+
1093
+ const contentHash = sha256(fs.readFileSync(actualPath, 'utf-8'));
1094
+ if (contentHash !== cacheFile.contentHash) {
1095
+ actionQueue.push({
1096
+ action: "create_static_asset",
1097
+ folder: toApiPath(actualPath),
1098
+ filePath: actualPath
1099
+ });
1100
+ }
1101
+ continue;
1102
+ }
1103
+
1104
+ // Handle code entity deletion
1105
+ if (cacheFile && !curFile) {
1106
+ const { type, entity } = inferMeta(path.resolve(cacheFile.lastKnownPath));
1107
+ actionQueue.push({
1108
+ action: "delete",
1109
+ recordId: id,
1110
+ filePath: cacheFile.lastKnownPath,
1111
+ type,
1112
+ entity,
1113
+ });
1114
+ continue;
1115
+ }
1116
+
1117
+ // Handle code entity creation
1118
+ if (!cacheFile && curFile) {
1119
+ const safe = readFileSafe(curFile.path);
1120
+ if (!safe) continue;
1121
+
1122
+ const { content, hash } = safe;
1123
+ const { type, entity, contentField } = inferMeta(path.resolve(curFile.path));
1124
+
1125
+ actionQueue.push({
1126
+ action: "create",
1127
+ filePath: curFile.path,
1128
+ recordId: id || null,
1129
+ type,
1130
+ entity,
1131
+ fields: { [contentField]: content },
1132
+ contentHash: hash,
1133
+ });
1134
+ continue;
1135
+ }
1136
+
1137
+ // Handle code entity update
1138
+ if (cacheFile && curFile) {
1139
+ const safe = readFileSafe(curFile.path);
1140
+ if (!safe) continue;
1141
+
1142
+ const { content, hash } = safe;
1143
+ // Check both paths - only consider renamed if NEITHER matches current path
1144
+ // This prevents false positives from stale/corrupted tracking data
1145
+ const resolvedCurPath = path.resolve(curFile.path);
1146
+ const matchesActualPath = cacheFile.lastKnownActualPath === resolvedCurPath;
1147
+ const matchesExpectedPath = cacheFile.lastKnownPath === resolvedCurPath;
1148
+ const renamed = !matchesActualPath && !matchesExpectedPath;
1149
+ const contentChanged = hash !== cacheFile.contentHash;
1150
+
1151
+ if (renamed || contentChanged) {
1152
+ const { type, entity, contentField } = inferMeta(path.resolve(curFile.path));
1153
+
1154
+ actionQueue.push({
1155
+ action: "update",
1156
+ recordId: id,
1157
+ type,
1158
+ entity,
1159
+ fields: { [contentField]: content },
1160
+ renamed,
1161
+ oldPath: cacheFile.lastKnownActualPath || cacheFile.lastKnownPath,
1162
+ filePath: curFile.path,
1163
+ });
1164
+ }
1165
+ }
1166
+ } catch (err) {
1167
+ if (!silent) {
1168
+ console.error(chalk.yellow(`Warning: Error processing file with ID ${id}:`), err.message);
1169
+ }
1170
+ }
1171
+ }
1172
+
1173
+ if (progress) {
1174
+ progress.completeStep('compare-code', `✓ Compared ${allIdsArray.length} code entities`);
1175
+ }
1176
+
1177
+ // Step 7b: Scan and compare Iris apps
1178
+ const irisAppsPath = path.join(EXPORT_ROOT, IRIS_APPS_DIR);
1179
+
1180
+ // Get cached Iris apps (always check, even if local folder doesn't exist)
1181
+ // Also include entries with iris-app: prefix in case type wasn't properly set
1182
+ const cachedIrisApps = cachedFiles
1183
+ .filter(cf => cf.type === 'IrisApp' || (cf.tag && cf.tag.startsWith('iris-app:')))
1184
+ .map(cf => {
1185
+ // Extract slug from folderName, or from the ID (iris-app:<slug>)
1186
+ const slug = cf.folderName || (cf.tag && cf.tag.startsWith('iris-app:') ? cf.tag.replace('iris-app:', '') : null);
1187
+ return {
1188
+ slug,
1189
+ appName: cf.appName || slug,
1190
+ modifiedOn: cf.modifiedOn,
1191
+ contentHash: cf.contentHash || null // Track content hash for change detection
1192
+ };
1193
+ })
1194
+ .filter(app => app.slug); // Filter out any entries without a valid slug
1195
+
1196
+ // Get local Iris apps (empty if folder doesn't exist)
1197
+ const localIrisApps = fs.existsSync(irisAppsPath)
1198
+ ? fs.readdirSync(irisAppsPath, { withFileTypes: true })
1199
+ .filter(d => d.isDirectory())
1200
+ .map(d => d.name)
1201
+ : [];
1202
+
1203
+ const cachedIrisSlugs = new Set(cachedIrisApps.map(a => a.slug));
1204
+ const localIrisSlugs = new Set(localIrisApps);
1205
+
1206
+ // Detect new and modified Iris apps
1207
+ for (const slug of localIrisApps) {
1208
+ const appPath = path.join(irisAppsPath, slug);
1209
+
1210
+ // Validate the app folder has required files
1211
+ const validation = validateIrisAppFolder(appPath);
1212
+ if (!validation.valid) {
1213
+ if (!silent) {
1214
+ console.log(chalk.yellow(`⚠ Skipping invalid Iris app '${slug}': ${validation.errors.join(', ')}`));
1215
+ }
1216
+ continue;
1217
+ }
1218
+
1219
+ // Get app metadata from linked project config (stored globally) or use slug
1220
+ const linkedProjects = getLinkedProjects();
1221
+ const linkedProject = linkedProjects.find(p => p.slug === slug);
1222
+ const appName = linkedProject?.appName || slug;
1223
+ const appDescription = linkedProject?.appDescription || null;
1224
+ const appIconId = linkedProject?.appIconId || null;
1225
+
1226
+ // Calculate content hash for change detection
1227
+ const currentHash = hashIrisAppFolder(appPath);
1228
+
1229
+ // Check if this is a new or existing app
1230
+ const isNew = !cachedIrisSlugs.has(slug);
1231
+
1232
+ if (isNew) {
1233
+ actionQueue.push({
1234
+ action: 'create_iris_app',
1235
+ slug,
1236
+ appName,
1237
+ appDescription,
1238
+ appIconId,
1239
+ appPath,
1240
+ contentHash: currentHash
1241
+ });
1242
+ } else {
1243
+ // Only update if content has changed
1244
+ const cachedApp = cachedIrisApps.find(a => a.slug === slug);
1245
+ const contentChanged = !cachedApp?.contentHash || cachedApp.contentHash !== currentHash;
1246
+
1247
+ if (contentChanged) {
1248
+ actionQueue.push({
1249
+ action: 'update_iris_app',
1250
+ slug,
1251
+ appName: linkedProject?.appName || cachedApp?.appName || slug,
1252
+ appDescription,
1253
+ appIconId,
1254
+ appPath,
1255
+ contentHash: currentHash
1256
+ });
1257
+ }
1258
+ }
1259
+ }
1260
+
1261
+ // Detect deleted Iris apps (works even if iris-apps folder doesn't exist)
1262
+ for (const cachedApp of cachedIrisApps) {
1263
+ if (!localIrisSlugs.has(cachedApp.slug)) {
1264
+ actionQueue.push({
1265
+ action: 'delete_iris_app',
1266
+ slug: cachedApp.slug
1267
+ });
1268
+ }
1269
+ }
1270
+
1271
+ // Step 8: Handle brand-new, tag-less files
1272
+ if (progress) progress.startStep('prepare');
1273
+
1274
+ for (const f of newLocalNoId) {
1275
+ const safe = readFileSafe(f.path);
1276
+ if (!safe) {
1277
+ if (!silent) {
1278
+ console.log(chalk.yellow(`Skipping unreadable file: ${f.path}`));
1279
+ }
1280
+ continue;
1281
+ }
1282
+
1283
+ const { content, hash } = safe;
1284
+ const { type, entity, contentField } = inferMeta(path.resolve(f.path));
1285
+
1286
+ actionQueue.push({
1287
+ action: "create",
1288
+ filePath: f.path,
1289
+ recordId: null,
1290
+ type,
1291
+ entity,
1292
+ fields: { [contentField]: content },
1293
+ contentHash: hash,
1294
+ });
1295
+ }
1296
+
1297
+ // Step 9: Filter out redundant file and folder deletions
1298
+ const foldersBeingDeleted = actionQueue
1299
+ .filter(a => a.action === 'delete_folder')
1300
+ .map(a => a.folderPath);
1301
+
1302
+ const filteredActionQueue = actionQueue.filter(action => {
1303
+ // Filter out files inside folders being deleted
1304
+ if (action.action === 'delete_static_asset' && action.filePath) {
1305
+ const fileDir = path.dirname(action.filePath);
1306
+ for (const deletedFolder of foldersBeingDeleted) {
1307
+ const normalizedFileDir = path.normalize(fileDir);
1308
+ const normalizedDeletedFolder = path.normalize(deletedFolder);
1309
+
1310
+ if (normalizedFileDir === normalizedDeletedFolder ||
1311
+ normalizedFileDir.startsWith(normalizedDeletedFolder + path.sep)) {
1312
+ return false; // Skip - covered by folder deletion
1313
+ }
1314
+ }
1315
+ }
1316
+
1317
+ // Filter out child folders inside folders being deleted
1318
+ if (action.action === 'delete_folder' && action.folderPath) {
1319
+ for (const deletedFolder of foldersBeingDeleted) {
1320
+ // Don't compare a folder to itself
1321
+ if (action.folderPath === deletedFolder) continue;
1322
+
1323
+ const normalizedChildFolder = path.normalize(action.folderPath);
1324
+ const normalizedParentFolder = path.normalize(deletedFolder);
1325
+
1326
+ // Check if this folder is inside another folder being deleted
1327
+ if (normalizedChildFolder.startsWith(normalizedParentFolder + path.sep)) {
1328
+ return false; // Skip - covered by parent folder deletion
1329
+ }
1330
+ }
1331
+ }
1332
+
1333
+ return true;
1334
+ });
1335
+
1336
+ if (progress) {
1337
+ progress.completeStep('prepare', `✓ Prepared ${filteredActionQueue.length} actions`);
1338
+ progress.finish();
1339
+ }
1340
+
1341
+ // Step 10: Display and execute action queue
1342
+ if (!silent) {
1343
+ console.log(chalk.blue("\n--- Publish Action Queue ---"));
1344
+ if (!filteredActionQueue.length) {
1345
+ console.log(chalk.green("All files are in sync — nothing to publish!"));
1346
+ } else {
1347
+ filteredActionQueue.forEach((a, i) => {
1348
+ const num = chalk.green(`[${i + 1}]`);
1349
+ const act = chalk.yellow(a.action.toUpperCase());
1350
+
1351
+ let type, displayPath, label;
1352
+ if (a.slug) {
1353
+ // Iris app action
1354
+ type = chalk.cyan(`${a.appName || a.slug}`);
1355
+ displayPath = a.appPath || a.slug;
1356
+ label = "App";
1357
+ } else if (a.folderName) {
1358
+ type = chalk.cyan(a.folderName);
1359
+ displayPath = a.folderPath;
1360
+ label = "Folder";
1361
+ } else if (a.names) {
1362
+ type = chalk.cyan(a.names.join(", "));
1363
+ displayPath = a.folder;
1364
+ label = "File";
1365
+ } else if (a.filePath) {
1366
+ type = chalk.cyan(a.type || path.basename(a.filePath));
1367
+ displayPath = a.filePath;
1368
+ label = a.type ? "Type" : "File";
1369
+ } else {
1370
+ type = chalk.cyan(a.type || "Unknown");
1371
+ displayPath = a.folder || "Unknown";
1372
+ label = "File";
1373
+ }
1374
+
1375
+ const idInfo = a.recordId ? ` ${chalk.magenta(a.recordId)}` : "";
1376
+ const renameInfo = a.renamed
1377
+ ? ` → ${chalk.gray(a.oldPath)} ${chalk.white("→")} ${chalk.gray(a.filePath)}`
1378
+ : "";
1379
+
1380
+ console.log(`${num} ${act} | ${label}: ${type}${idInfo}${renameInfo} (${displayPath})`);
1381
+ });
1382
+
1383
+ console.log(chalk.blue("\n--- Publishing Changes ---"));
1384
+ }
1385
+ }
1386
+
1387
+ // Execute actions
1388
+ if (filteredActionQueue.length > 0) {
1389
+ if (silent) {
1390
+ await performNetworkRequest(filteredActionQueue);
1391
+ } else {
1392
+ await withSpinner(
1393
+ "Working...",
1394
+ async () => {
1395
+ await performNetworkRequest(filteredActionQueue)
1396
+ },
1397
+ { showCompletion: false }
1398
+ );
1399
+ }
1400
+ }
1401
+
1402
+ return {
1403
+ actionQueue: filteredActionQueue,
1404
+ hasChanges: filteredActionQueue.length > 0
1405
+ };
1406
+ };
1407
+
1408
+ /**
1409
+ * CLI command wrapper for publish.
1410
+ */
1411
+ export const publish = async () => {
1412
+ process.stdout.write("\x1Bc"); // clear console
1413
+
1414
+ try {
1415
+ await runPublish({ silent: false });
1416
+ } catch (err) {
1417
+ console.error(chalk.red.bold("Publish failed:"), err.message);
1418
+ process.exit(1);
1419
+ }
1420
+ };