@magentrix-corp/magentrix-cli 1.0.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.
Files changed (43) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +471 -0
  3. package/actions/autopublish.js +283 -0
  4. package/actions/autopublish.old.js +293 -0
  5. package/actions/autopublish.v2.js +447 -0
  6. package/actions/create.js +329 -0
  7. package/actions/help.js +165 -0
  8. package/actions/main.js +81 -0
  9. package/actions/publish.js +567 -0
  10. package/actions/pull.js +139 -0
  11. package/actions/setup.js +61 -0
  12. package/actions/status.js +17 -0
  13. package/bin/magentrix.js +159 -0
  14. package/package.json +61 -0
  15. package/utils/cacher.js +112 -0
  16. package/utils/cli/checkInstanceUrl.js +29 -0
  17. package/utils/cli/helpers/compare.js +281 -0
  18. package/utils/cli/helpers/ensureApiKey.js +57 -0
  19. package/utils/cli/helpers/ensureCredentials.js +60 -0
  20. package/utils/cli/helpers/ensureInstanceUrl.js +63 -0
  21. package/utils/cli/writeRecords.js +223 -0
  22. package/utils/compare.js +135 -0
  23. package/utils/compress.js +18 -0
  24. package/utils/config.js +451 -0
  25. package/utils/diff.js +49 -0
  26. package/utils/downloadAssets.js +75 -0
  27. package/utils/filetag.js +115 -0
  28. package/utils/hash.js +14 -0
  29. package/utils/magentrix/api/assets.js +145 -0
  30. package/utils/magentrix/api/auth.js +56 -0
  31. package/utils/magentrix/api/createEntity.js +61 -0
  32. package/utils/magentrix/api/deleteEntity.js +55 -0
  33. package/utils/magentrix/api/meqlQuery.js +31 -0
  34. package/utils/magentrix/api/retrieveEntity.js +32 -0
  35. package/utils/magentrix/api/updateEntity.js +66 -0
  36. package/utils/magentrix/fetch.js +154 -0
  37. package/utils/merge.js +22 -0
  38. package/utils/preferences.js +40 -0
  39. package/utils/spinner.js +43 -0
  40. package/utils/template.js +52 -0
  41. package/utils/updateFileBase.js +103 -0
  42. package/vars/config.js +1 -0
  43. package/vars/global.js +33 -0
@@ -0,0 +1,567 @@
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 fs from "fs";
6
+ import path from "path";
7
+ import chalk from "chalk";
8
+ import {
9
+ ENTITY_FIELD_MAP,
10
+ ENTITY_TYPE_MAP,
11
+ EXPORT_ROOT,
12
+ TYPE_DIR_MAP,
13
+ } from "../vars/global.js";
14
+ import {
15
+ getFileTag,
16
+ } from "../utils/filetag.js";
17
+ import { sha256 } from "../utils/hash.js";
18
+ import { createEntity } from "../utils/magentrix/api/createEntity.js";
19
+ import { updateEntity } from "../utils/magentrix/api/updateEntity.js";
20
+ import { deleteEntity } from "../utils/magentrix/api/deleteEntity.js";
21
+ import { setFileTag } from "../utils/filetag.js";
22
+ import { removeFromBase, updateBase } from "../utils/updateFileBase.js";
23
+ import { deleteAsset, uploadAsset } from "../utils/magentrix/api/assets.js";
24
+ import { decompressString } from "../utils/compress.js";
25
+
26
+ const config = new Config();
27
+
28
+ /* ---------- Helper utilities ---------- */
29
+
30
+ /**
31
+ * Given a file path, return { type, entity, contentField } for Magentrix.
32
+ */
33
+ 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
+ };
43
+ };
44
+
45
+ /**
46
+ * Safely read file content and hash; returns null on failure.
47
+ */
48
+ const readFileSafe = (filePath) => {
49
+ try {
50
+ const content = fs.readFileSync(filePath, "utf-8");
51
+ return { content, hash: sha256(content) };
52
+ } 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
+ );
61
+ return null;
62
+ }
63
+ };
64
+
65
+ const formatMultilineError = (err) => {
66
+ // Ensure it's a string and split into lines
67
+ const lines = String(err).split(/\r?\n/);
68
+ // First line gets the bullet, rest get indentation
69
+ return lines
70
+ .map((line, i) => {
71
+ const prefix = i === 0 ? `${chalk.redBright(' •')} ` : ' '; // 4 spaces indent
72
+ return prefix + chalk.whiteBright(line);
73
+ })
74
+ .join('\n');
75
+ };
76
+
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
+ /**
90
+ * Executes all actions in the queue in parallel and handles results/errors.
91
+ * Updates cache after successful operations.
92
+ */
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 = [];
131
+
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
+ );
142
+
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)));
168
+ }
169
+ }
170
+
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
+ }
183
+ };
184
+
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
+ }
195
+
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
+ }
201
+
202
+ /**
203
+ * Handle create action
204
+ */
205
+ const handleCreateAction = async (instanceUrl, apiKey, action) => {
206
+ const data = {
207
+ Name: path.basename(action.filePath, path.extname(action.filePath)),
208
+ Type: action.type,
209
+ ...action.fields
210
+ };
211
+
212
+ const result = await createEntity(instanceUrl, apiKey, action.entity, data);
213
+ return { recordId: result.recordId || result.id };
214
+ };
215
+
216
+ /**
217
+ * Handle update action
218
+ */
219
+ const handleUpdateAction = async (instanceUrl, apiKey, action) => {
220
+ const data = {
221
+ ...action.fields
222
+ };
223
+
224
+ // If renamed, update the Name field
225
+ if (action.renamed) {
226
+ data.Name = path.basename(action.filePath, path.extname(action.filePath));
227
+ }
228
+
229
+ const result = await updateEntity(instanceUrl, apiKey, action.entity, action.recordId, data);
230
+ return { recordId: action.recordId };
231
+ };
232
+
233
+ /**
234
+ * Handle delete action
235
+ */
236
+ const handleDeleteAction = async (instanceUrl, apiKey, action) => {
237
+ const r = await deleteEntity(instanceUrl, apiKey, action.entity, action.recordId);
238
+ return r;
239
+ };
240
+
241
+ /**
242
+ * Update cache and file tags after successful operations
243
+ */
244
+ const updateCacheAfterSuccess = async (action, operationResult) => {
245
+ try {
246
+ switch (action.action) {
247
+ case "create":
248
+ // Tag the file and update base with the content that was actually published
249
+ const createSnapshot = action.fields && Object.values(action.fields)[0]
250
+ ? { content: Object.values(action.fields)[0], hash: action.contentHash }
251
+ : null;
252
+ await setFileTag(action.filePath, operationResult.recordId);
253
+ updateBase(
254
+ action.filePath,
255
+ { Id: operationResult.recordId, Type: action.type },
256
+ '',
257
+ createSnapshot
258
+ );
259
+ break;
260
+
261
+ case "update":
262
+ // Pass the content snapshot that was actually published to prevent race conditions
263
+ const updateSnapshot = action.fields && Object.values(action.fields)[0]
264
+ ? { content: Object.values(action.fields)[0], hash: sha256(Object.values(action.fields)[0]) }
265
+ : null;
266
+ updateBase(
267
+ action.filePath,
268
+ { Id: action.recordId, Type: Object.keys(ENTITY_TYPE_MAP).find(key => ENTITY_TYPE_MAP[key] === action.entity) },
269
+ '',
270
+ updateSnapshot
271
+ );
272
+ break;
273
+
274
+ case "delete":
275
+ removeFromBase(action.recordId);
276
+ break;
277
+
278
+ case "delete_static_asset":
279
+ for (const name of action.names) {
280
+ removeFromBase(`${EXPORT_ROOT}/${action.folder}/${name}`);
281
+ }
282
+ break;
283
+
284
+ case "create_static_asset":
285
+ updateBase(action.filePath, { Id: action.filePath, Type: "File" });
286
+ break;
287
+ }
288
+ } catch (error) {
289
+ console.warn(chalk.yellow(`Warning: Failed to update cache for ${action.action}: ${error.message}`));
290
+ }
291
+ };
292
+
293
+ /* ---------- Main publish routine ---------- */
294
+
295
+ /**
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}>}
302
+ */
303
+ export const runPublish = async (options = {}) => {
304
+ const { silent = false, skipAuth = false, credentials = null } = options;
305
+
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)
316
+ );
317
+ }
318
+ throw err;
319
+ });
320
+ }
321
+
322
+ /* 2 — Load previous cache */
323
+ const hits = await config.searchObject({}, { filename: "base.json", global: false });
324
+ const cachedResults = hits?.[0]?.value || {};
325
+
326
+ if (!Object.keys(cachedResults).length) {
327
+ if (!silent) {
328
+ console.log(chalk.red.bold("No file cache found!"));
329
+ console.log(
330
+ `Run ${chalk.cyan("magentrix pull")} to initialize your workspace.`
331
+ );
332
+ }
333
+ throw new Error("No file cache found");
334
+ }
335
+
336
+ const cachedFiles = Object.values(cachedResults).map((c) => ({
337
+ ...c,
338
+ tag: c.recordId,
339
+ }));
340
+
341
+ /* 3 — Scan current local workspace */
342
+ const localPaths = await walkFiles(EXPORT_ROOT, { ignore: [path.join(EXPORT_ROOT, 'Contents')] });
343
+ const localFiles = await Promise.all(
344
+ localPaths.map(async (p) => {
345
+ try {
346
+ const tag = await getFileTag(p);
347
+ return { tag, path: p };
348
+ } catch {
349
+ // console.log(
350
+ // chalk.yellow(
351
+ // `Warning: Could not retrieve tag for ${p}. Treating as new file.`
352
+ // )
353
+ // );
354
+ return { tag: null, path: p };
355
+ }
356
+ })
357
+ );
358
+
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
+ );
366
+ const newLocalNoId = localFiles.filter((f) => !f.tag);
367
+
368
+ /* 5 — Determine action per recordId */
369
+ const allIds = new Set([
370
+ ...Object.keys(cacheById),
371
+ ...Object.keys(localById),
372
+ ]);
373
+ const actionQueue = [];
374
+
375
+ // HANDLE ASSET CREATION SEPERATELY
376
+ const assetPaths = await walkFiles(path.join(EXPORT_ROOT, 'Contents/Assets'));
377
+ 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())) {
380
+ continue;
381
+ }
382
+
383
+ const fileFolderPath = getFolderFromPath(assetPath);
384
+
385
+ actionQueue.push({
386
+ action: "create_static_asset",
387
+ folder: fileFolderPath,
388
+ filePath: assetPath
389
+ });
390
+ }
391
+
392
+ for (const id of allIds) {
393
+ try {
394
+ const cacheFile = cacheById[id];
395
+ const curFile = localById[id];
396
+
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') {
401
+ const localAssetExists = fs.existsSync(cacheFile.filePath);
402
+
403
+ // DELETE
404
+ if (!localAssetExists) {
405
+ actionQueue.push({
406
+ action: 'delete_static_asset',
407
+ folder: getFolderFromPath(cacheFile.filePath),
408
+ names: [path.basename(cacheFile.filePath)]
409
+ });
410
+
411
+ continue;
412
+ }
413
+
414
+ // UPDATE
415
+ const contentHash = sha256(fs.readFileSync(cacheFile.filePath, 'utf-8'));
416
+ if (contentHash !== cacheFile.contentHash) {
417
+ // This can be handled as a file creation since the Magentrix Upload API will just overwrite the file
418
+ actionQueue.push({
419
+ aliasAction: 'update_static_asset',
420
+ action: "create_static_asset",
421
+ folder: getFolderFromPath(cacheFile.filePath),
422
+ filePath: cacheFile.filePath
423
+ });
424
+ }
425
+
426
+ continue;
427
+ }
428
+
429
+ /* DELETE */
430
+ if (cacheFile && !curFile) {
431
+ const { type, entity } = inferMeta(cacheFile.lastKnownPath);
432
+ actionQueue.push({
433
+ action: "delete",
434
+ recordId: id,
435
+ filePath: cacheFile.lastKnownPath,
436
+ type,
437
+ entity,
438
+ });
439
+ continue;
440
+ }
441
+
442
+ /* CREATE */
443
+ if (!cacheFile && curFile) {
444
+ const safe = readFileSafe(curFile.path);
445
+ if (!safe) continue;
446
+ const { content, hash } = safe;
447
+ const { type, entity, contentField } = inferMeta(curFile.path);
448
+ actionQueue.push({
449
+ action: "create",
450
+ filePath: curFile.path,
451
+ recordId: id || null,
452
+ type,
453
+ entity,
454
+ fields: { [contentField]: content },
455
+ contentHash: hash,
456
+ });
457
+ continue;
458
+ }
459
+
460
+ /* UPDATE */
461
+ if (cacheFile && curFile) {
462
+ const safe = readFileSafe(curFile.path);
463
+ if (!safe) continue;
464
+ const { content, hash } = safe;
465
+
466
+ const renamed = cacheFile.lastKnownPath !== path.resolve(curFile.path);
467
+ const contentChanged = hash !== cacheFile.contentHash;
468
+ if (renamed || contentChanged) {
469
+ const { type, entity, contentField } = inferMeta(curFile.path);
470
+ actionQueue.push({
471
+ action: "update",
472
+ recordId: id,
473
+ type,
474
+ entity,
475
+ fields: { [contentField]: content },
476
+ renamed,
477
+ oldPath: cacheFile.lastKnownPath,
478
+ filePath: curFile.path,
479
+ });
480
+ }
481
+ }
482
+ } catch (err) {
483
+ console.log(err)
484
+ }
485
+ }
486
+
487
+ /* 6 — Handle brand-new, tag-less files */
488
+ for (const f of newLocalNoId) {
489
+ const safe = readFileSafe(f.path);
490
+ if (!safe) {
491
+ console.log(
492
+ chalk.yellow(`Skipping unreadable file: ${f.path}`)
493
+ );
494
+ continue;
495
+ }
496
+ const { content, hash } = safe;
497
+ const { type, entity, contentField } = inferMeta(f.path);
498
+ actionQueue.push({
499
+ action: "create",
500
+ recordId: null,
501
+ type,
502
+ entity,
503
+ fields: { [contentField]: content },
504
+ contentHash: hash,
505
+ });
506
+ }
507
+
508
+ /* 7 — Summary and execution */
509
+ if (!silent) {
510
+ 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
+ );
515
+ } else {
516
+ actionQueue.forEach((a, i) => {
517
+ const num = chalk.green(`[${i + 1}]`);
518
+ const act = chalk.yellow(a.action.toUpperCase());
519
+ const type = chalk.cyan(a.type || a?.names?.join(", ") || path.basename(a.filePath));
520
+ const idInfo = a.recordId ? ` ${chalk.magenta(a.recordId)}` : "";
521
+ const renameInfo = a.renamed
522
+ ? ` → ${chalk.gray(a.oldPath)} ${chalk.white("→")} ${chalk.gray(a.filePath)}`
523
+ : "";
524
+
525
+ console.log(`${num} ${act} | ${a.type ? "Type" : "File"}: ${type}${idInfo}${renameInfo} (${a.filePath || a.folder})`);
526
+ });
527
+
528
+ console.log(chalk.blue("\n--- Publishing Changes ---"));
529
+ }
530
+ }
531
+
532
+ // Execute the action queue if there are changes
533
+ if (actionQueue.length > 0) {
534
+ if (silent) {
535
+ await performNetworkRequest(actionQueue);
536
+ } else {
537
+ await withSpinner(
538
+ "Working...",
539
+ async () => {
540
+ await performNetworkRequest(actionQueue)
541
+ },
542
+ {
543
+ showCompletion: false
544
+ }
545
+ );
546
+ }
547
+ }
548
+
549
+ return {
550
+ actionQueue,
551
+ hasChanges: actionQueue.length > 0
552
+ };
553
+ };
554
+
555
+ /**
556
+ * CLI command wrapper for publish
557
+ */
558
+ export const publish = async () => {
559
+ process.stdout.write("\x1Bc"); // clear console
560
+
561
+ try {
562
+ await runPublish({ silent: false });
563
+ } catch (err) {
564
+ console.error(chalk.red.bold("Publish failed:"), err.message);
565
+ process.exit(1);
566
+ }
567
+ };
@@ -0,0 +1,139 @@
1
+ import { ensureValidCredentials } from "../utils/cli/helpers/ensureCredentials.js";
2
+ import Config from "../utils/config.js";
3
+ import { meqlQuery } from "../utils/magentrix/api/meqlQuery.js";
4
+ import fs from "fs";
5
+ import { withSpinner } from "../utils/spinner.js";
6
+ import { EXPORT_ROOT, TYPE_DIR_MAP } from "../vars/global.js";
7
+ import { mapRecordToFile, writeRecords } from "../utils/cli/writeRecords.js";
8
+ import { updateBase } from "../utils/updateFileBase.js";
9
+ import { compareAllFilesAndLogStatus, promptConflictResolution, showCurrentConflicts } from "../utils/cli/helpers/compare.js";
10
+ import path from "path";
11
+ import { compareLocalAndRemote } from "../utils/compare.js";
12
+ import chalk from 'chalk';
13
+ import { getFileTag } from "../utils/filetag.js";
14
+ import { downloadAssetsZip, listAssets } from "../utils/magentrix/api/assets.js";
15
+ import { downloadAssets, walkAssets } from "../utils/downloadAssets.js";
16
+ import { v4 as uuidv4 } from 'uuid';
17
+
18
+ const config = new Config();
19
+
20
+ /**
21
+ * Pulls all ActiveClass and ActivePage records from Magentrix,
22
+ * saving them to categorized local directories with appropriate extensions.
23
+ *
24
+ * Output Structure:
25
+ * /test/
26
+ * Controllers/
27
+ * Triggers/
28
+ * Classes/
29
+ * Pages/
30
+ * <Name>.<ext>
31
+ *
32
+ * @async
33
+ * @function pull
34
+ * @returns {Promise<void>}
35
+ */
36
+ export const pull = async () => {
37
+ // Step 1: Authenticate and retrieve instance URL and token
38
+ const { instanceUrl, token } = await withSpinner('Authenticating...', async () => {
39
+ return await ensureValidCredentials();
40
+ });
41
+
42
+ // Clear the terminal
43
+ process.stdout.write('\x1Bc');
44
+
45
+ // Step 2: Prepare queries for both ActiveClass and ActivePage
46
+ const queries = [
47
+ {
48
+ name: "ActiveClass",
49
+ query: "SELECT Id,Body,Name,CreatedOn,Description,ModifiedOn,Type FROM ActiveClass",
50
+ contentField: "Body",
51
+ },
52
+ {
53
+ name: "ActivePage",
54
+ query: "SELECT Id,Content,Name,CreatedOn,Description,ModifiedOn,Type FROM ActivePage",
55
+ contentField: "Content",
56
+ }
57
+ ];
58
+
59
+ // Step 3: Download records in parallel with spinner
60
+ const [activeClassResult, activePageResult, assets] = await withSpinner("Downloading files...", async () => {
61
+ const meqlResults = await Promise.all(
62
+ queries.map(q => meqlQuery(instanceUrl, token.value, q.query))
63
+ );
64
+
65
+ const assetTree = await downloadAssets(instanceUrl, token.value);
66
+
67
+ return [
68
+ ...meqlResults,
69
+ assetTree
70
+ ]
71
+ });
72
+
73
+ // Update assets base
74
+ const processAssets = (records) => {
75
+ for (const record of records) {
76
+ if (record?.Type === 'Folder') {
77
+ if (record?.Children?.length === 0) continue;
78
+ processAssets(record.Children);
79
+ continue;
80
+ }
81
+
82
+ updateBase(
83
+ path.join(EXPORT_ROOT, record?.Path),
84
+ {
85
+ ...record,
86
+ Id: path.join(EXPORT_ROOT, record?.Path)
87
+ }
88
+ );
89
+ }
90
+ }
91
+
92
+ processAssets(assets.tree);
93
+
94
+ // Remove (clean) the export root directory
95
+ // fs.rmSync(EXPORT_ROOT, { recursive: true, force: true });
96
+
97
+ // Check for conflicts and have user select conflict resolution
98
+ const activeClassRecords = (activeClassResult.Records || []).map(record => {
99
+ record.Content = record.Body;
100
+ delete record.Body;
101
+ return record;
102
+ });
103
+
104
+ const activePageRecords = (activePageResult.Records || []);
105
+ const allRecords = [...activeClassRecords, ...activePageRecords].map(mapRecordToFile);
106
+
107
+ const issues = [];
108
+ for (const record of allRecords) {
109
+ if (record?.error) {
110
+ continue;
111
+ }
112
+
113
+ const status = compareLocalAndRemote(
114
+ path.join(EXPORT_ROOT, record.relativePath),
115
+ { ...record, content: record.Content }
116
+ );
117
+
118
+ // Missing files will just be written
119
+ if (!['in_sync', 'missing'].includes(status.status)) {
120
+ issues.push({ relativePath: record.relativePath, status: status.status });
121
+ }
122
+ }
123
+
124
+ let resolutionMethod = 'skip';
125
+
126
+ if (issues.length > 0) {
127
+ resolutionMethod = await promptConflictResolution(issues);
128
+ }
129
+
130
+ // Step 5: Write all ActiveClass and ActivePage records
131
+ await writeRecords(allRecords, resolutionMethod);
132
+
133
+ // Step 7: Success message
134
+ console.log(`\n✅ Successfully pulled:`);
135
+ console.log(` • ${activeClassResult.Records.length} ActiveClass records`);
136
+ console.log(` • ${activePageResult.Records.length} ActivePage records`);
137
+ console.log(`📁 Saved to: ./${EXPORT_ROOT}/ (organized into Controllers, Triggers, Classes, Pages)`);
138
+ // console.log(`🔍 Tip: Run 'magentrix status' to see local vs server differences.`);
139
+ };