@magentrix-corp/magentrix-cli 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,196 +11,114 @@ import {
11
11
  EXPORT_ROOT,
12
12
  TYPE_DIR_MAP,
13
13
  } from "../vars/global.js";
14
- import {
15
- getFileTag,
16
- } from "../utils/filetag.js";
14
+ import { getFileTag, setFileTag } from "../utils/filetag.js";
17
15
  import { sha256 } from "../utils/hash.js";
18
16
  import { createEntity } from "../utils/magentrix/api/createEntity.js";
19
17
  import { updateEntity } from "../utils/magentrix/api/updateEntity.js";
20
18
  import { deleteEntity } from "../utils/magentrix/api/deleteEntity.js";
21
- import { setFileTag } from "../utils/filetag.js";
22
19
  import { removeFromBase, updateBase } from "../utils/updateFileBase.js";
23
- import { deleteAsset, uploadAsset } from "../utils/magentrix/api/assets.js";
24
- import { decompressString } from "../utils/compress.js";
20
+ import { deleteAsset, uploadAsset, createFolder } from "../utils/magentrix/api/assets.js";
21
+ import { toApiPath, toApiFolderPath } from "../utils/assetPaths.js";
25
22
 
26
23
  const config = new Config();
27
24
 
28
- /* ---------- Helper utilities ---------- */
25
+ /* ==================== UTILITY FUNCTIONS ==================== */
29
26
 
30
27
  /**
31
- * Given a file path, return { type, entity, contentField } for Magentrix.
28
+ * Infers Magentrix entity metadata from a file path by walking up the directory tree.
29
+ * @param {string} filePath - The file path to analyze
30
+ * @returns {Object} { type, entity, contentField } for Magentrix API
32
31
  */
33
32
  const inferMeta = (filePath) => {
34
- const parentFolder = path.basename(path.dirname(filePath));
35
- const type = Object.keys(TYPE_DIR_MAP).find(
36
- (k) => TYPE_DIR_MAP[k].directory === parentFolder
37
- );
38
- return {
39
- type,
40
- entity: ENTITY_TYPE_MAP[type],
41
- contentField: ENTITY_FIELD_MAP[type],
42
- };
33
+ let currentPath = path.dirname(filePath);
34
+ const exportRootAbs = path.resolve(EXPORT_ROOT);
35
+
36
+ while (currentPath.startsWith(exportRootAbs)) {
37
+ const folderName = path.basename(currentPath);
38
+ const type = Object.keys(TYPE_DIR_MAP).find(
39
+ (k) => TYPE_DIR_MAP[k].directory === folderName
40
+ );
41
+
42
+ if (type) {
43
+ return {
44
+ type,
45
+ entity: ENTITY_TYPE_MAP[type],
46
+ contentField: ENTITY_FIELD_MAP[type],
47
+ };
48
+ }
49
+
50
+ const parentPath = path.dirname(currentPath);
51
+ if (parentPath === currentPath) break; // Reached root
52
+ currentPath = parentPath;
53
+ }
54
+
55
+ return { type: undefined, entity: undefined, contentField: undefined };
43
56
  };
44
57
 
45
58
  /**
46
- * Safely read file content and hash; returns null on failure.
59
+ * Safely reads file content and computes hash.
60
+ * @param {string} filePath - Path to the file
61
+ * @returns {Object|null} { content, hash } or null on error
47
62
  */
48
63
  const readFileSafe = (filePath) => {
49
64
  try {
50
65
  const content = fs.readFileSync(filePath, "utf-8");
51
66
  return { content, hash: sha256(content) };
52
67
  } catch (err) {
53
- console.error(
54
- chalk.red.bold("Error:") +
55
- " Unable to read file " +
56
- chalk.white.bold(filePath)
57
- );
58
- console.error(
59
- chalk.gray("→ Please verify the file exists and you have read permissions.")
60
- );
68
+ console.error(chalk.red.bold("Error:") + " Unable to read file " + chalk.white.bold(filePath));
69
+ console.error(chalk.gray("→ Please verify the file exists and you have read permissions."));
61
70
  return null;
62
71
  }
63
72
  };
64
73
 
74
+ /**
75
+ * Formats multiline error messages with proper indentation.
76
+ */
65
77
  const formatMultilineError = (err) => {
66
- // Ensure it's a string and split into lines
67
78
  const lines = String(err).split(/\r?\n/);
68
- // First line gets the bullet, rest get indentation
69
79
  return lines
70
80
  .map((line, i) => {
71
- const prefix = i === 0 ? `${chalk.redBright(' •')} ` : ' '; // 4 spaces indent
81
+ const prefix = i === 0 ? `${chalk.redBright(' •')} ` : ' ';
72
82
  return prefix + chalk.whiteBright(line);
73
83
  })
74
84
  .join('\n');
75
85
  };
76
86
 
77
- const getFolderFromPath = (filePath) => {
78
- const fileFolderPath = filePath
79
- .split("/")
80
- .filter((x, i) => !(x === EXPORT_ROOT && i === 0))
81
- .slice(0, -1)
82
- .join("/");
83
-
84
- return fileFolderPath;
85
- }
86
-
87
- /* ---------- Network operations ---------- */
88
-
89
87
  /**
90
- * Executes all actions in the queue in parallel and handles results/errors.
91
- * Updates cache after successful operations.
88
+ * Recursively walks a directory and returns all subdirectory paths.
92
89
  */
93
- const performNetworkRequest = async (actionQueue) => {
94
- const { instanceUrl, token } = await ensureValidCredentials();
95
-
96
- // Execute all operations in parallel
97
- const results = await Promise.allSettled(
98
- actionQueue.map(async (action, index) => {
99
- try {
100
- let result;
101
- switch (action.action) {
102
- case "create":
103
- result = await handleCreateAction(instanceUrl, token.value, action);
104
- break;
105
- case "update":
106
- result = await handleUpdateAction(instanceUrl, token.value, action);
107
- break;
108
- case "delete":
109
- result = await handleDeleteAction(instanceUrl, token.value, action);
110
- break;
111
- case "create_static_asset":
112
- result = await handleCreateStaticAssetAction(instanceUrl, token.value, action);
113
- break;
114
- case "delete_static_asset":
115
- result = await handleDeleteStaticAssetAction(instanceUrl, token.value, action);
116
- break;
117
- default:
118
- throw new Error(`Unknown action: ${action.action}`);
119
- }
120
- return { index, action, result, success: true };
121
- } catch (error) {
122
- return { index, action, error: error.message, success: false };
123
- }
124
- })
125
- );
126
-
127
- // Process results
128
- let successCount = 0;
129
- let errorCount = 0;
130
- const errors = [];
90
+ const walkFolders = (dir) => {
91
+ if (!fs.existsSync(dir)) return [];
131
92
 
132
- for (const result of results) {
133
- if (result.status === "fulfilled") {
134
- const { index, action, success, error, result: operationResult } = result.value;
135
- if (success) {
136
- successCount++;
137
- console.log(
138
- chalk.green(`✓ [${index + 1}]`) +
139
- ` ${chalk.yellow(action.action.toUpperCase())} ${chalk.cyan(action.type || action?.names?.join(", ") || path.basename(action.filePath) || '')} ` +
140
- (operationResult?.recordId ? chalk.magenta(operationResult.recordId) : "")
141
- );
93
+ const folders = [];
94
+ const items = fs.readdirSync(dir, { withFileTypes: true });
142
95
 
143
- // Update cache and file tags after successful operations
144
- await updateCacheAfterSuccess(action, operationResult);
145
- } else {
146
- errorCount++;
147
- errors.push({ index: index + 1, action, error });
148
-
149
- // Enhanced error display matching autopublish.js style
150
- console.log();
151
- console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
152
- console.log(chalk.redBright('─'.repeat(48)));
153
- console.log(
154
- chalk.red.bold(`[${index + 1}] ${action.action.toUpperCase()} ${action.type || action?.names?.join(", ") || ''} (${action.filePath || action.folder}):`)
155
- );
156
- // console.log(`${chalk.redBright(' •')} ${chalk.whiteBright(error)}`);
157
- console.log(formatMultilineError(error));
158
- console.log(chalk.redBright('─'.repeat(48)));
159
- }
160
- } else {
161
- // Promise rejection (shouldn't happen with allSettled, but just in case)
162
- errorCount++;
163
- console.log();
164
- console.log(chalk.bgRed.bold.white(' ✖ Unexpected Error '));
165
- console.log(chalk.redBright('─'.repeat(48)));
166
- console.log(`${chalk.redBright(' •')} ${chalk.whiteBright(result.reason)}`);
167
- console.log(chalk.redBright('─'.repeat(48)));
96
+ for (const item of items) {
97
+ if (item.isDirectory()) {
98
+ const fullPath = path.join(dir, item.name);
99
+ folders.push(fullPath);
100
+ folders.push(...walkFolders(fullPath));
168
101
  }
169
102
  }
170
103
 
171
- // Summary
172
- console.log(chalk.blue("\n--- Publish Summary ---"));
173
- console.log(chalk.green(`✓ Successful: ${successCount}`));
174
- if (errorCount > 0) {
175
- console.log(chalk.red(`✗ Failed: ${errorCount}`));
176
- // console.log(chalk.yellow("\nErrors encountered:"));
177
- // errors.forEach(({ index, action, error }) => {
178
- // console.log(` [${index}] ${action.action.toUpperCase()} ${action.type}: ${error}`);
179
- // });
180
- } else {
181
- console.log(chalk.green("All operations completed successfully! 🎉"));
182
- }
104
+ return folders;
183
105
  };
184
106
 
185
- const handleCreateStaticAssetAction = async (instanceUrl, apiKey, action) => {
186
- const r = await uploadAsset(
187
- instanceUrl,
188
- apiKey,
189
- `/${action.folder}`,
190
- [action.filePath]
191
- ).catch(err => ({ error: err.message }));
192
- if (r?.error) throw new Error(r.error);
193
- return r;
194
- }
107
+ /**
108
+ * Gets display name for an action in log messages.
109
+ */
110
+ const getActionDisplayName = (action) => {
111
+ if (action.folderName) return action.folderName;
112
+ if (action.names) return action.names.join(", ");
113
+ if (action.type) return action.type;
114
+ if (action.filePath) return path.basename(action.filePath);
115
+ return '';
116
+ };
195
117
 
196
- const handleDeleteStaticAssetAction = async (instanceUrl, apiKey, action) => {
197
- const response = await deleteAsset(instanceUrl, apiKey, `/${action.folder}`, action.names).catch(err => ({ error: err.message }));
198
- if (response?.error) throw new Error(response.error);
199
- return response;
200
- }
118
+ /* ==================== ACTION HANDLERS ==================== */
201
119
 
202
120
  /**
203
- * Handle create action
121
+ * Handles CREATE action for code entities (ActiveClass/ActivePage).
204
122
  */
205
123
  const handleCreateAction = async (instanceUrl, apiKey, action) => {
206
124
  const data = {
@@ -214,38 +132,69 @@ const handleCreateAction = async (instanceUrl, apiKey, action) => {
214
132
  };
215
133
 
216
134
  /**
217
- * Handle update action
135
+ * Handles UPDATE action for code entities (ActiveClass/ActivePage).
218
136
  */
219
137
  const handleUpdateAction = async (instanceUrl, apiKey, action) => {
220
- const data = {
221
- ...action.fields
222
- };
138
+ const data = { ...action.fields };
223
139
 
224
- // If renamed, update the Name field
225
140
  if (action.renamed) {
226
141
  data.Name = path.basename(action.filePath, path.extname(action.filePath));
227
142
  }
228
143
 
229
- const result = await updateEntity(instanceUrl, apiKey, action.entity, action.recordId, data);
144
+ await updateEntity(instanceUrl, apiKey, action.entity, action.recordId, data);
230
145
  return { recordId: action.recordId };
231
146
  };
232
147
 
233
148
  /**
234
- * Handle delete action
149
+ * Handles DELETE action for code entities (ActiveClass/ActivePage).
235
150
  */
236
151
  const handleDeleteAction = async (instanceUrl, apiKey, action) => {
237
- const r = await deleteEntity(instanceUrl, apiKey, action.entity, action.recordId);
238
- return r;
152
+ return await deleteEntity(instanceUrl, apiKey, action.entity, action.recordId);
153
+ };
154
+
155
+ /**
156
+ * Handles CREATE_STATIC_ASSET action.
157
+ */
158
+ const handleCreateStaticAssetAction = async (instanceUrl, apiKey, action) => {
159
+ const response = await uploadAsset(instanceUrl, apiKey, `/${action.folder}`, [action.filePath]);
160
+ if (response?.error) throw new Error(response.error);
161
+ return response;
162
+ };
163
+
164
+ /**
165
+ * Handles DELETE_STATIC_ASSET action.
166
+ */
167
+ const handleDeleteStaticAssetAction = async (instanceUrl, apiKey, action) => {
168
+ const response = await deleteAsset(instanceUrl, apiKey, `/${action.folder}`, action.names);
169
+ if (response?.error) throw new Error(response.error);
170
+ return response;
239
171
  };
240
172
 
241
173
  /**
242
- * Update cache and file tags after successful operations
174
+ * Handles CREATE_FOLDER action.
175
+ */
176
+ const handleCreateFolderAction = async (instanceUrl, apiKey, action) => {
177
+ const response = await createFolder(instanceUrl, apiKey, action.parentPath, action.folderName);
178
+ if (response?.error) throw new Error(response.error);
179
+ return response;
180
+ };
181
+
182
+ /**
183
+ * Handles DELETE_FOLDER action.
184
+ */
185
+ const handleDeleteFolderAction = async (instanceUrl, apiKey, action) => {
186
+ const response = await deleteAsset(instanceUrl, apiKey, action.parentPath, [action.folderName]);
187
+ if (response?.error) throw new Error(response.error);
188
+ return response;
189
+ };
190
+
191
+ /**
192
+ * Updates cache after successful operations.
243
193
  */
244
194
  const updateCacheAfterSuccess = async (action, operationResult) => {
245
195
  try {
246
196
  switch (action.action) {
247
- case "create":
248
- // Tag the file and update base with the content that was actually published
197
+ case "create": {
249
198
  const createSnapshot = action.fields && Object.values(action.fields)[0]
250
199
  ? { content: Object.values(action.fields)[0], hash: action.contentHash }
251
200
  : null;
@@ -257,19 +206,21 @@ const updateCacheAfterSuccess = async (action, operationResult) => {
257
206
  createSnapshot
258
207
  );
259
208
  break;
209
+ }
260
210
 
261
- case "update":
262
- // Pass the content snapshot that was actually published to prevent race conditions
211
+ case "update": {
263
212
  const updateSnapshot = action.fields && Object.values(action.fields)[0]
264
213
  ? { content: Object.values(action.fields)[0], hash: sha256(Object.values(action.fields)[0]) }
265
214
  : null;
215
+ const type = Object.keys(ENTITY_TYPE_MAP).find(key => ENTITY_TYPE_MAP[key] === action.entity);
266
216
  updateBase(
267
217
  action.filePath,
268
- { Id: action.recordId, Type: Object.keys(ENTITY_TYPE_MAP).find(key => ENTITY_TYPE_MAP[key] === action.entity) },
218
+ { Id: action.recordId, Type: type },
269
219
  '',
270
220
  updateSnapshot
271
221
  );
272
222
  break;
223
+ }
273
224
 
274
225
  case "delete":
275
226
  removeFromBase(action.recordId);
@@ -284,51 +235,137 @@ const updateCacheAfterSuccess = async (action, operationResult) => {
284
235
  case "create_static_asset":
285
236
  updateBase(action.filePath, { Id: action.filePath, Type: "File" });
286
237
  break;
238
+
239
+ case "create_folder":
240
+ updateBase(action.folderPath, { Id: action.folderPath, Type: "Folder" });
241
+ break;
242
+
243
+ case "delete_folder":
244
+ removeFromBase(action.folderPath);
245
+ break;
287
246
  }
288
247
  } catch (error) {
289
248
  console.warn(chalk.yellow(`Warning: Failed to update cache for ${action.action}: ${error.message}`));
290
249
  }
291
250
  };
292
251
 
293
- /* ---------- Main publish routine ---------- */
252
+ /* ==================== NETWORK REQUEST HANDLER ==================== */
294
253
 
295
254
  /**
296
- * Core publish logic - can be called from autopublish or directly
297
- * @param {Object} options - Configuration options
298
- * @param {boolean} options.silent - If true, suppress summary output
299
- * @param {boolean} options.skipAuth - If true, skip authentication (caller handles it)
300
- * @param {Object} options.credentials - Pre-authenticated credentials {instanceUrl, token}
301
- * @returns {Promise<{actionQueue: Array, results: Object}>}
255
+ * Executes all actions in the queue in parallel and handles results/errors.
302
256
  */
303
- export const runPublish = async (options = {}) => {
304
- const { silent = false, skipAuth = false, credentials = null } = options;
257
+ const performNetworkRequest = async (actionQueue) => {
258
+ const { instanceUrl, token } = await ensureValidCredentials();
305
259
 
306
- /* 1 Authenticate (unless skipped) */
307
- let authCreds;
308
- if (skipAuth && credentials) {
309
- authCreds = credentials;
310
- } else {
311
- authCreds = await ensureValidCredentials().catch((err) => {
312
- if (!silent) {
313
- console.error(
314
- chalk.red.bold("Authentication failed:"),
315
- chalk.white(err.message)
260
+ const results = await Promise.allSettled(
261
+ actionQueue.map(async (action, index) => {
262
+ try {
263
+ let result;
264
+ switch (action.action) {
265
+ case "create":
266
+ result = await handleCreateAction(instanceUrl, token.value, action);
267
+ break;
268
+ case "update":
269
+ result = await handleUpdateAction(instanceUrl, token.value, action);
270
+ break;
271
+ case "delete":
272
+ result = await handleDeleteAction(instanceUrl, token.value, action);
273
+ break;
274
+ case "create_static_asset":
275
+ result = await handleCreateStaticAssetAction(instanceUrl, token.value, action);
276
+ break;
277
+ case "delete_static_asset":
278
+ result = await handleDeleteStaticAssetAction(instanceUrl, token.value, action);
279
+ break;
280
+ case "create_folder":
281
+ result = await handleCreateFolderAction(instanceUrl, token.value, action);
282
+ break;
283
+ case "delete_folder":
284
+ result = await handleDeleteFolderAction(instanceUrl, token.value, action);
285
+ break;
286
+ default:
287
+ throw new Error(`Unknown action: ${action.action}`);
288
+ }
289
+ return { index, action, result, success: true };
290
+ } catch (error) {
291
+ return { index, action, error: error.message, success: false };
292
+ }
293
+ })
294
+ );
295
+
296
+ // Process and display results
297
+ let successCount = 0;
298
+ let errorCount = 0;
299
+
300
+ for (const result of results) {
301
+ if (result.status === "fulfilled") {
302
+ const { index, action, success, error, result: operationResult } = result.value;
303
+
304
+ if (success) {
305
+ successCount++;
306
+ const displayName = getActionDisplayName(action);
307
+ console.log(
308
+ chalk.green(`✓ [${index + 1}]`) +
309
+ ` ${chalk.yellow(action.action.toUpperCase())} ${chalk.cyan(displayName)} ` +
310
+ (operationResult?.recordId ? chalk.magenta(operationResult.recordId) : "")
316
311
  );
312
+ await updateCacheAfterSuccess(action, operationResult);
313
+ } else {
314
+ errorCount++;
315
+ console.log();
316
+ console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
317
+ console.log(chalk.redBright('─'.repeat(48)));
318
+ console.log(chalk.red.bold(`[${index + 1}] ${action.action.toUpperCase()} ${getActionDisplayName(action)} (${action.filePath || action.folderPath || action.folder}):`));
319
+ console.log(formatMultilineError(error));
320
+ console.log(chalk.redBright('─'.repeat(48)));
317
321
  }
318
- throw err;
319
- });
322
+ } else {
323
+ errorCount++;
324
+ console.log();
325
+ console.log(chalk.bgRed.bold.white(' ✖ Unexpected Error '));
326
+ console.log(chalk.redBright('─'.repeat(48)));
327
+ console.log(`${chalk.redBright(' •')} ${chalk.whiteBright(result.reason)}`);
328
+ console.log(chalk.redBright('─'.repeat(48)));
329
+ }
320
330
  }
321
331
 
322
- /* 2 — Load previous cache */
332
+ // Summary
333
+ console.log(chalk.blue("\n--- Publish Summary ---"));
334
+ console.log(chalk.green(`✓ Successful: ${successCount}`));
335
+ if (errorCount > 0) {
336
+ console.log(chalk.red(`✗ Failed: ${errorCount}`));
337
+ } else {
338
+ console.log(chalk.green("All operations completed successfully! 🎉"));
339
+ }
340
+ };
341
+
342
+ /* ==================== MAIN PUBLISH LOGIC ==================== */
343
+
344
+ /**
345
+ * Core publish logic - can be called from autopublish or directly.
346
+ * @param {Object} options - Configuration options
347
+ * @param {boolean} options.silent - If true, suppress summary output
348
+ * @returns {Promise<{actionQueue: Array, hasChanges: boolean}>}
349
+ */
350
+ export const runPublish = async (options = {}) => {
351
+ const { silent = false } = options;
352
+
353
+ // Step 1: Authenticate
354
+ await ensureValidCredentials().catch((err) => {
355
+ if (!silent) {
356
+ console.error(chalk.red.bold("Authentication failed:"), chalk.white(err.message));
357
+ }
358
+ throw err;
359
+ });
360
+
361
+ // Step 2: Load cached file state
323
362
  const hits = await config.searchObject({}, { filename: "base.json", global: false });
324
363
  const cachedResults = hits?.[0]?.value || {};
325
364
 
326
365
  if (!Object.keys(cachedResults).length) {
327
366
  if (!silent) {
328
367
  console.log(chalk.red.bold("No file cache found!"));
329
- console.log(
330
- `Run ${chalk.cyan("magentrix pull")} to initialize your workspace.`
331
- );
368
+ console.log(`Run ${chalk.cyan("magentrix pull")} to initialize your workspace.`);
332
369
  }
333
370
  throw new Error("No file cache found");
334
371
  }
@@ -336,99 +373,124 @@ export const runPublish = async (options = {}) => {
336
373
  const cachedFiles = Object.values(cachedResults).map((c) => ({
337
374
  ...c,
338
375
  tag: c.recordId,
376
+ filePath: c.filePath || c.lastKnownPath,
339
377
  }));
340
378
 
341
- /* 3 Scan current local workspace */
342
- const localPaths = await walkFiles(EXPORT_ROOT, { ignore: [path.join(EXPORT_ROOT, 'Contents')] });
379
+ // Step 3: Scan local workspace (excluding Assets folder)
380
+ const localPaths = await walkFiles(EXPORT_ROOT, { ignore: [path.join(EXPORT_ROOT, 'Assets')] });
343
381
  const localFiles = await Promise.all(
344
382
  localPaths.map(async (p) => {
345
383
  try {
346
384
  const tag = await getFileTag(p);
347
385
  return { tag, path: p };
348
386
  } catch {
349
- // console.log(
350
- // chalk.yellow(
351
- // `Warning: Could not retrieve tag for ${p}. Treating as new file.`
352
- // )
353
- // );
354
387
  return { tag: null, path: p };
355
388
  }
356
389
  })
357
390
  );
358
391
 
359
- /* 4 Fast lookup maps */
360
- const cacheById = Object.fromEntries(
361
- cachedFiles.map((c) => [c.tag, c])
362
- );
363
- const localById = Object.fromEntries(
364
- localFiles.filter((f) => f.tag).map((f) => [f.tag, f])
365
- );
392
+ // Step 4: Create lookup maps
393
+ const cacheById = Object.fromEntries(cachedFiles.map((c) => [c.tag, c]));
394
+ const localById = Object.fromEntries(localFiles.filter((f) => f.tag).map((f) => [f.tag, f]));
366
395
  const newLocalNoId = localFiles.filter((f) => !f.tag);
396
+ const allIds = new Set([...Object.keys(cacheById), ...Object.keys(localById)]);
367
397
 
368
- /* 5 — Determine action per recordId */
369
- const allIds = new Set([
370
- ...Object.keys(cacheById),
371
- ...Object.keys(localById),
372
- ]);
373
398
  const actionQueue = [];
374
399
 
375
- // HANDLE ASSET CREATION SEPERATELY
376
- const assetPaths = await walkFiles(path.join(EXPORT_ROOT, 'Contents/Assets'));
400
+ // Step 5: Handle static asset files
401
+ const assetPaths = await walkFiles(path.join(EXPORT_ROOT, 'Assets'));
377
402
  for (const assetPath of assetPaths) {
378
- // The file is already being handled in the next part
379
- if (cachedFiles.find(cr => cr.filePath.toLowerCase() === assetPath.toLowerCase())) {
403
+ if (cachedFiles.find(cr => cr.filePath?.toLowerCase() === assetPath.toLowerCase())) {
380
404
  continue;
381
405
  }
382
406
 
383
- const fileFolderPath = getFolderFromPath(assetPath);
384
-
385
407
  actionQueue.push({
386
408
  action: "create_static_asset",
387
- folder: fileFolderPath,
409
+ folder: toApiPath(assetPath),
388
410
  filePath: assetPath
389
411
  });
390
412
  }
391
413
 
414
+ // Step 6: Handle folder creation and deletion
415
+ const assetsDir = path.join(EXPORT_ROOT, 'Assets');
416
+ if (fs.existsSync(assetsDir)) {
417
+ const localFolders = walkFolders(assetsDir);
418
+ const cachedFolders = cachedFiles
419
+ .filter(c => c.type === 'Folder' && (c.filePath || c.lastKnownPath))
420
+ .map(c => c.filePath || c.lastKnownPath);
421
+
422
+ // New folders
423
+ for (const folderPath of localFolders) {
424
+ if (!folderPath || cachedFolders.find(cf => cf?.toLowerCase() === folderPath.toLowerCase())) {
425
+ continue;
426
+ }
427
+
428
+ const parentDir = path.dirname(folderPath);
429
+ if (!parentDir || parentDir === '.' || parentDir === folderPath) continue;
430
+
431
+ actionQueue.push({
432
+ action: "create_folder",
433
+ folderPath: folderPath,
434
+ parentPath: toApiFolderPath(parentDir),
435
+ folderName: path.basename(folderPath)
436
+ });
437
+ }
438
+
439
+ // Deleted folders
440
+ for (const cachedFolder of cachedFolders) {
441
+ if (!cachedFolder || typeof cachedFolder !== 'string') continue;
442
+ if (localFolders.find(lf => lf?.toLowerCase() === cachedFolder.toLowerCase())) {
443
+ continue;
444
+ }
445
+
446
+ const parentDir = path.dirname(cachedFolder);
447
+ if (!parentDir || parentDir === '.' || parentDir === cachedFolder) continue;
448
+
449
+ actionQueue.push({
450
+ action: "delete_folder",
451
+ folderPath: cachedFolder,
452
+ parentPath: toApiFolderPath(parentDir),
453
+ folderName: path.basename(cachedFolder)
454
+ });
455
+ }
456
+ }
457
+
458
+ // Step 7: Process code entities (ActiveClass/ActivePage) and static assets
392
459
  for (const id of allIds) {
393
460
  try {
394
461
  const cacheFile = cacheById[id];
395
462
  const curFile = localById[id];
396
463
 
397
- // This is an asset so it needs to be treated differently
398
- // Assets only have CREATE & DELETE & UPDATE actions no rename, if a files content is changed just reupload it essentially.
399
- // We may have to explore the option of listing all the files from the server first and validating against those instead of the cache
400
- if (cacheFile.type === 'File') {
464
+ // Skip folders - they're handled separately
465
+ if (cacheFile?.type === 'Folder') continue;
466
+
467
+ // Handle static asset files
468
+ if (cacheFile?.type === 'File') {
401
469
  const localAssetExists = fs.existsSync(cacheFile.filePath);
402
470
 
403
- // DELETE
404
471
  if (!localAssetExists) {
405
472
  actionQueue.push({
406
473
  action: 'delete_static_asset',
407
- folder: getFolderFromPath(cacheFile.filePath),
474
+ folder: toApiPath(cacheFile.filePath),
408
475
  names: [path.basename(cacheFile.filePath)]
409
476
  });
410
-
411
477
  continue;
412
478
  }
413
479
 
414
- // UPDATE
415
480
  const contentHash = sha256(fs.readFileSync(cacheFile.filePath, 'utf-8'));
416
481
  if (contentHash !== cacheFile.contentHash) {
417
- // This can be handled as a file creation since the Magentrix Upload API will just overwrite the file
418
482
  actionQueue.push({
419
- aliasAction: 'update_static_asset',
420
483
  action: "create_static_asset",
421
- folder: getFolderFromPath(cacheFile.filePath),
484
+ folder: toApiPath(cacheFile.filePath),
422
485
  filePath: cacheFile.filePath
423
486
  });
424
487
  }
425
-
426
488
  continue;
427
489
  }
428
490
 
429
- /* DELETE */
491
+ // Handle code entity deletion
430
492
  if (cacheFile && !curFile) {
431
- const { type, entity } = inferMeta(cacheFile.lastKnownPath);
493
+ const { type, entity } = inferMeta(path.resolve(cacheFile.lastKnownPath));
432
494
  actionQueue.push({
433
495
  action: "delete",
434
496
  recordId: id,
@@ -439,12 +501,14 @@ export const runPublish = async (options = {}) => {
439
501
  continue;
440
502
  }
441
503
 
442
- /* CREATE */
504
+ // Handle code entity creation
443
505
  if (!cacheFile && curFile) {
444
506
  const safe = readFileSafe(curFile.path);
445
507
  if (!safe) continue;
508
+
446
509
  const { content, hash } = safe;
447
- const { type, entity, contentField } = inferMeta(curFile.path);
510
+ const { type, entity, contentField } = inferMeta(path.resolve(curFile.path));
511
+
448
512
  actionQueue.push({
449
513
  action: "create",
450
514
  filePath: curFile.path,
@@ -457,16 +521,18 @@ export const runPublish = async (options = {}) => {
457
521
  continue;
458
522
  }
459
523
 
460
- /* UPDATE */
524
+ // Handle code entity update
461
525
  if (cacheFile && curFile) {
462
526
  const safe = readFileSafe(curFile.path);
463
527
  if (!safe) continue;
464
- const { content, hash } = safe;
465
528
 
529
+ const { content, hash } = safe;
466
530
  const renamed = cacheFile.lastKnownPath !== path.resolve(curFile.path);
467
531
  const contentChanged = hash !== cacheFile.contentHash;
532
+
468
533
  if (renamed || contentChanged) {
469
- const { type, entity, contentField } = inferMeta(curFile.path);
534
+ const { type, entity, contentField } = inferMeta(path.resolve(curFile.path));
535
+
470
536
  actionQueue.push({
471
537
  action: "update",
472
538
  recordId: id,
@@ -480,23 +546,24 @@ export const runPublish = async (options = {}) => {
480
546
  }
481
547
  }
482
548
  } catch (err) {
483
- console.log(err)
549
+ console.error(chalk.yellow(`Warning: Error processing file with ID ${id}:`), err.message);
484
550
  }
485
551
  }
486
552
 
487
- /* 6 Handle brand-new, tag-less files */
553
+ // Step 8: Handle brand-new, tag-less files
488
554
  for (const f of newLocalNoId) {
489
555
  const safe = readFileSafe(f.path);
490
556
  if (!safe) {
491
- console.log(
492
- chalk.yellow(`Skipping unreadable file: ${f.path}`)
493
- );
557
+ console.log(chalk.yellow(`Skipping unreadable file: ${f.path}`));
494
558
  continue;
495
559
  }
560
+
496
561
  const { content, hash } = safe;
497
- const { type, entity, contentField } = inferMeta(f.path);
562
+ const { type, entity, contentField } = inferMeta(path.resolve(f.path));
563
+
498
564
  actionQueue.push({
499
565
  action: "create",
566
+ filePath: f.path,
500
567
  recordId: null,
501
568
  type,
502
569
  entity,
@@ -505,55 +572,83 @@ export const runPublish = async (options = {}) => {
505
572
  });
506
573
  }
507
574
 
508
- /* 7 Summary and execution */
575
+ // Step 9: Filter out redundant file deletions
576
+ const foldersBeingDeleted = actionQueue
577
+ .filter(a => a.action === 'delete_folder')
578
+ .map(a => a.folderPath);
579
+
580
+ const filteredActionQueue = actionQueue.filter(action => {
581
+ if (action.action === 'delete_static_asset') {
582
+ const fileFolder = path.join(EXPORT_ROOT, action.folder);
583
+ for (const deletedFolder of foldersBeingDeleted) {
584
+ if (fileFolder.startsWith(deletedFolder)) {
585
+ return false; // Skip - covered by folder deletion
586
+ }
587
+ }
588
+ }
589
+ return true;
590
+ });
591
+
592
+ // Step 10: Display and execute action queue
509
593
  if (!silent) {
510
594
  console.log(chalk.blue("\n--- Publish Action Queue ---"));
511
- if (!actionQueue.length) {
512
- console.log(
513
- chalk.green("All files are in sync — nothing to publish!")
514
- );
595
+ if (!filteredActionQueue.length) {
596
+ console.log(chalk.green("All files are in sync — nothing to publish!"));
515
597
  } else {
516
- actionQueue.forEach((a, i) => {
598
+ filteredActionQueue.forEach((a, i) => {
517
599
  const num = chalk.green(`[${i + 1}]`);
518
600
  const act = chalk.yellow(a.action.toUpperCase());
519
- const type = chalk.cyan(a.type || a?.names?.join(", ") || path.basename(a.filePath));
601
+
602
+ let type, displayPath;
603
+ if (a.folderName) {
604
+ type = chalk.cyan(a.folderName);
605
+ displayPath = a.folderPath;
606
+ } else if (a.names) {
607
+ type = chalk.cyan(a.names.join(", "));
608
+ displayPath = a.folder;
609
+ } else if (a.filePath) {
610
+ type = chalk.cyan(a.type || path.basename(a.filePath));
611
+ displayPath = a.filePath;
612
+ } else {
613
+ type = chalk.cyan(a.type || "Unknown");
614
+ displayPath = a.folder || "Unknown";
615
+ }
616
+
520
617
  const idInfo = a.recordId ? ` ${chalk.magenta(a.recordId)}` : "";
521
618
  const renameInfo = a.renamed
522
619
  ? ` → ${chalk.gray(a.oldPath)} ${chalk.white("→")} ${chalk.gray(a.filePath)}`
523
620
  : "";
524
621
 
525
- console.log(`${num} ${act} | ${a.type ? "Type" : "File"}: ${type}${idInfo}${renameInfo} (${a.filePath || a.folder})`);
622
+ console.log(`${num} ${act} | ${a.type ? "Type" : (a.folderName ? "Folder" : "File")}: ${type}${idInfo}${renameInfo} (${displayPath})`);
526
623
  });
527
624
 
528
625
  console.log(chalk.blue("\n--- Publishing Changes ---"));
529
626
  }
530
627
  }
531
628
 
532
- // Execute the action queue if there are changes
533
- if (actionQueue.length > 0) {
629
+ // Execute actions
630
+ if (filteredActionQueue.length > 0) {
534
631
  if (silent) {
535
- await performNetworkRequest(actionQueue);
632
+ await performNetworkRequest(filteredActionQueue);
536
633
  } else {
537
634
  await withSpinner(
538
635
  "Working...",
539
636
  async () => {
540
- await performNetworkRequest(actionQueue)
637
+ await performNetworkRequest(filteredActionQueue)
541
638
  },
542
- {
543
- showCompletion: false
544
- }
639
+ { showCompletion: false }
545
640
  );
546
641
  }
547
642
  }
548
643
 
549
644
  return {
550
- actionQueue,
551
- hasChanges: actionQueue.length > 0
645
+ actionQueue: filteredActionQueue,
646
+ hasChanges: filteredActionQueue.length > 0
552
647
  };
553
648
  };
554
649
 
555
650
  /**
556
- * CLI command wrapper for publish
651
+ * CLI command wrapper for publish.
557
652
  */
558
653
  export const publish = async () => {
559
654
  process.stdout.write("\x1Bc"); // clear console