@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,154 @@
1
+ /**
2
+ * Checks if a request body should be JSON-stringified.
3
+ * Excludes FormData, Blob, ArrayBuffer, URLSearchParams, and typed arrays.
4
+ * @param {any} body
5
+ * @returns {boolean}
6
+ */
7
+ function isJsonBody(body) {
8
+ return (
9
+ typeof body === 'object' &&
10
+ body !== null &&
11
+ !(body instanceof FormData) &&
12
+ !(body instanceof Blob) &&
13
+ !(body instanceof ArrayBuffer) &&
14
+ !(body instanceof URLSearchParams) &&
15
+ !ArrayBuffer.isView(body) // covers Uint8Array, etc.
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Fetch helper for Magentrix API.
21
+ * Handles network, HTTP, and API-level errors with detailed messages or returns error info as JSON object.
22
+ *
23
+ * @async
24
+ * @function fetchMagentrix
25
+ * @param {Object} opts - Fetch options.
26
+ * @param {string} opts.instanceUrl - Magentrix instance base URL (e.g. https://your.magentrix.com).
27
+ * @param {string} [opts.token] - OAuth2 bearer token for authentication (optional for public endpoints).
28
+ * @param {string} opts.path - API path (e.g. '/api/3.0/entity/activeclass').
29
+ * @param {string} [opts.method='GET'] - HTTP method.
30
+ * @param {any} [opts.body] - Request body (object for JSON, or raw for FormData, Blob, string, etc).
31
+ * @param {Object} [opts.headers] - Additional headers to merge with defaults.
32
+ * @param {boolean} [opts.returnErrorObject=false] - If true, errors are returned as JSON objects instead of thrown as Error.
33
+ * @returns {Promise<Object>} Parsed JSON response from the API if successful.
34
+ * @throws {Error|Object} Throws Error (default) or error object if returnErrorObject is true.
35
+ */
36
+ export const fetchMagentrix = async ({
37
+ instanceUrl,
38
+ token,
39
+ path,
40
+ method = 'GET',
41
+ body,
42
+ headers = {},
43
+ ignoreContentType = false,
44
+ returnErrorObject = false,
45
+ errorConfig = {
46
+ includeStatus: false,
47
+ includeURL: false,
48
+ label: '', // 'Magentrix errors:',
49
+ bullets: false
50
+ },
51
+ }) => {
52
+ if (!instanceUrl || !path) {
53
+ const err = { type: 'client', message: 'Missing required parameter(s): instanceUrl or path' };
54
+ if (returnErrorObject) throw err;
55
+ throw new Error(err.message);
56
+ }
57
+
58
+ const finalHeaders = {
59
+ 'Accept': 'application/json',
60
+ ...headers
61
+ };
62
+ if (isJsonBody(body)) finalHeaders['Content-Type'] = 'application/json';
63
+ if (token) finalHeaders['Authorization'] = `Bearer ${token}`;
64
+ let requestBody;
65
+ if (body === undefined || body === null) {
66
+ requestBody = undefined;
67
+ } else if (isJsonBody(body)) {
68
+ requestBody = JSON.stringify(body);
69
+ } else {
70
+ requestBody = body;
71
+ }
72
+ if (!finalHeaders['Content-Type'] && !ignoreContentType) finalHeaders['Content-Type'] = 'application/json';
73
+
74
+ let response, responseData;
75
+ try {
76
+ response = await fetch(`${instanceUrl.replace(/\/$/, '')}${path}`, {
77
+ method,
78
+ headers: finalHeaders,
79
+ body: requestBody
80
+ });
81
+ } catch (err) {
82
+ const errorObj = {
83
+ type: 'network',
84
+ message: `Network error contacting Magentrix API: ${err.message}`,
85
+ error: err
86
+ };
87
+ if (returnErrorObject) throw errorObj;
88
+ throw new Error(errorObj.message);
89
+ }
90
+
91
+ try {
92
+ responseData = await response.json();
93
+ } catch {
94
+ responseData = null;
95
+ }
96
+
97
+ if (!response.ok) {
98
+ const errorObj = {
99
+ type: 'http',
100
+ status: response.status,
101
+ statusText: response.statusText,
102
+ url: response.url,
103
+ response: responseData,
104
+ };
105
+ // Optionally add detailed error message
106
+ let msg = errorConfig?.includeStatus ? `HTTP ${response.status} ${response.statusText}\n` : '';
107
+ if (responseData) {
108
+ const responseErrs = responseData.errors || responseData.Errors;
109
+
110
+ if (Array.isArray(responseErrs) && responseErrs.length) {
111
+ msg += `${errorConfig?.label}${errorConfig?.label ? '\n' : ''}` + responseErrs.map(e => `${errorConfig?.bullets ? " • " : ""}${e.code ? `[${e.code || '500'}] ` : ''}${e.message || e}`).join('\n');
112
+ } else if (responseData.message) {
113
+ msg += `Magentrix message: ${responseData.message}`;
114
+ } else {
115
+ msg += JSON.stringify(responseData);
116
+ }
117
+ }
118
+ if (errorConfig?.includeURL) msg += `\nURL: ${response.url}`;
119
+ errorObj.message = msg;
120
+ if (returnErrorObject) throw errorObj;
121
+ throw new Error(msg);
122
+ }
123
+
124
+ // Handle API-level business logic errors
125
+ if (
126
+ !responseData ||
127
+ responseData.success === false ||
128
+ (Array.isArray(responseData?.errors) && responseData.errors.length > 0) ||
129
+ responseData.error
130
+ ) {
131
+ const errorObj = {
132
+ type: 'api',
133
+ url: response.url,
134
+ response: responseData
135
+ };
136
+ let details = '';
137
+ if (Array.isArray(responseData?.errors) && responseData.errors.length) {
138
+ details = responseData.errors
139
+ .map(e => ` • ${e.code ? `[${e.code}] ` : ''}${e.message}`)
140
+ .join('\n');
141
+ } else if (responseData?.message) {
142
+ details = responseData.message;
143
+ } else if (typeof responseData === 'object') {
144
+ details = JSON.stringify(responseData);
145
+ } else {
146
+ details = String(responseData);
147
+ }
148
+ errorObj.message = `Magentrix API error:\n${details}`;
149
+ if (returnErrorObject) throw errorObj;
150
+ throw new Error(errorObj.message);
151
+ }
152
+
153
+ return responseData;
154
+ };
package/utils/merge.js ADDED
@@ -0,0 +1,22 @@
1
+ import * as diff3 from 'node-diff3'; // ESM import all
2
+
3
+ /**
4
+ * Merges local and remote file contents with a common ancestor (base),
5
+ * returning the merged result and whether there was a conflict.
6
+ *
7
+ * @param {string} baseContent - The last synced (common ancestor) file content.
8
+ * @param {string} localContent - The current local file content.
9
+ * @param {string} remoteContent - The remote/server file content.
10
+ * @returns {{ mergedText: string, hasConflict: boolean }}
11
+ */
12
+ export function mergeFiles(baseContent, localContent, remoteContent) {
13
+ const baseLines = baseContent.split('\n');
14
+ const localLines = localContent.split('\n');
15
+ const remoteLines = remoteContent.split('\n');
16
+
17
+ const result = diff3.merge(localLines, baseLines, remoteLines);
18
+ const mergedText = result.result.join('\n');
19
+
20
+ // Do NOT throw. Just return the merge, which will have conflict markers if unresolved.
21
+ return mergedText;
22
+ }
@@ -0,0 +1,40 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Ensures that the .vscode/settings.json file exists in the project root and
6
+ * updates it to associate `.xyz` files with C# syntax highlighting in VS Code.
7
+ * If the file or directory doesn't exist, it will be created. Existing settings
8
+ * are preserved and merged.
9
+ *
10
+ * @async
11
+ * @function ensureVSCodeFileAssociation
12
+ * @param {string} projectRoot - The absolute path to the project root where the `.vscode` folder resides.
13
+ * @returns {Promise<void>} Resolves once the settings file is updated or created.
14
+ *
15
+ * @example
16
+ * await ensureVSCodeFileAssociation(process.cwd());
17
+ */
18
+ export async function ensureVSCodeFileAssociation(projectRoot) {
19
+ const vscodeDir = path.join(projectRoot, '.vscode');
20
+ const settingsPath = path.join(vscodeDir, 'settings.json');
21
+
22
+ await fs.mkdir(vscodeDir, { recursive: true });
23
+
24
+ let settings = {};
25
+ try {
26
+ const raw = await fs.readFile(settingsPath, 'utf-8');
27
+ settings = JSON.parse(raw);
28
+ } catch (err) {
29
+ // Ignore error if file doesn't exist or is invalid JSON
30
+ }
31
+
32
+ settings['files.associations'] = {
33
+ ...settings['files.associations'],
34
+ '*.ac': 'csharp',
35
+ '*.trigger': 'csharp',
36
+ '*.ctrl': 'csharp'
37
+ };
38
+
39
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
40
+ }
@@ -0,0 +1,43 @@
1
+ export async function withSpinner(message, fn, config = { showCompletion: true }) {
2
+ const spinnerChars = ['|', '/', '-', '\\'];
3
+ let i = 0;
4
+ let spinnerActive = true;
5
+ let spinner;
6
+
7
+ // Patch both log and error for full coverage
8
+ const originalLog = console.log;
9
+ const originalError = console.error;
10
+ function clearSpinnerLine() {
11
+ process.stdout.write('\r' + ' '.repeat(message.length + 8) + '\r');
12
+ if (spinnerActive) {
13
+ clearInterval(spinner);
14
+ spinnerActive = false;
15
+ }
16
+ }
17
+ console.log = (...args) => { clearSpinnerLine(); originalLog(...args); };
18
+ console.error = (...args) => { clearSpinnerLine(); originalError(...args); };
19
+
20
+ process.stdout.write(`${message} `);
21
+
22
+ spinner = setInterval(() => {
23
+ process.stdout.write(`\r${message} ${spinnerChars[i++ % spinnerChars.length]}`);
24
+ }, 80);
25
+
26
+ try {
27
+ const result = await fn();
28
+ const treatAsError = result?.hasErrors;
29
+ spinnerActive = false;
30
+ clearInterval(spinner);
31
+ if (config?.showCompletion) process.stdout.write(`\r${message} ${treatAsError ? '❌' : '✅'}\n`);
32
+ return result;
33
+ } catch (err) {
34
+ spinnerActive = false;
35
+ clearInterval(spinner);
36
+ if (config?.showCompletion) process.stdout.write(`\r${message} ❌\n`);
37
+ throw err;
38
+ } finally {
39
+ // Restore logs always
40
+ console.log = originalLog;
41
+ console.error = originalError;
42
+ }
43
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Returns the default template for an ActiveClass Controller.
3
+ * @param {string} className
4
+ * @returns {string}
5
+ */
6
+ export const getControllerTemplate = (className) => `
7
+ public class ${className} : AspxController {
8
+ public override ActionResponse Index()
9
+ {
10
+ return View();
11
+ }
12
+ }
13
+ `.trim();
14
+
15
+ /**
16
+ * Returns the default template for an ActiveClass Trigger.
17
+ * @param {string} className - For instance ContactTrigger
18
+ * @param {string} entityName - For instance Force__Contact
19
+ * @returns {string}
20
+ */
21
+ export const getTriggerTemplate = (className, entityName = '<ENTITYNAME>') => `
22
+ public class ${className} : ActiveTrigger<${entityName}>
23
+ {
24
+ public override void Execute(TransactionContext<${entityName}> trigger) {
25
+
26
+ }
27
+ }
28
+ `.trim();
29
+
30
+ /**
31
+ * Returns the default template for a general ActiveClass.
32
+ * @param {string} className
33
+ * @returns {string}
34
+ */
35
+ export const getClassTemplate = (className) => `
36
+ public class ${className} {
37
+
38
+ }
39
+ `.trim();
40
+
41
+ /**
42
+ * Returns the default template for an Active Page.
43
+ * @param {string} pageName
44
+ * @param {string} pageLabel
45
+ * @returns {string}
46
+ */
47
+ export const getPageTemplate = (pageName, pageLabel) => `
48
+ <aspx:AspxPage runat="server" Id="${pageName}" title="${pageLabel}">
49
+ <body>
50
+ </body>
51
+ </aspx:AspxPage>
52
+ `.trim();
@@ -0,0 +1,103 @@
1
+ import { EXPORT_ROOT } from "../vars/global.js";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { sha256 } from "./hash.js";
5
+ import Config from "./config.js";
6
+ import { compressString } from "./compress.js";
7
+
8
+ const config = new Config();
9
+
10
+ /**
11
+ * Recursively collects all file paths under a directory, returning relative paths from base.
12
+ *
13
+ * @param {string} dirPath - Absolute or relative path to the root directory to scan.
14
+ * @param {string} [basePath=''] - Internal use. The base path for recursion, relative to dirPath.
15
+ * @returns {string[]} Array of relative file paths found under dirPath.
16
+ */
17
+ export function getAllFiles(dirPath, basePath = "") {
18
+ let results = [];
19
+ const fullDirPath = path.join(dirPath, basePath);
20
+ const entries = fs.readdirSync(fullDirPath, { withFileTypes: true });
21
+
22
+ for (const entry of entries) {
23
+ const relPath = path.join(basePath, entry.name);
24
+ const entryPath = path.join(dirPath, relPath);
25
+
26
+ if (entry.isDirectory()) {
27
+ // Recurse into subdirectory
28
+ results = results.concat(getAllFiles(dirPath, relPath));
29
+ } else if (entry.isFile()) {
30
+ // Add file's relative path to results
31
+ results.push(relPath);
32
+ }
33
+ }
34
+ return results;
35
+ }
36
+
37
+ /**
38
+ * Updates (or creates) the base sync state for a specific file.
39
+ *
40
+ * Saves the current file's lastModified time, content hash, and contents
41
+ * to base.json using config.save(), under the key of the file's relative path.
42
+ *
43
+ * This should be called only when the local and remote file are confirmed to be in sync
44
+ * (i.e., after a successful pull, push, or conflict resolution).
45
+ *
46
+ * @param {string} filePath - The expected path to the file (relative to project root or EXPORT_ROOT).
47
+ * @param {object} record - The Magentrix record
48
+ * @param {string} actualPath - Should only be provided if the file has been renamed and the base has not been updated
49
+ * @param {object} contentSnapshot - Optional { content, hash } snapshot of what was actually published (prevents race conditions)
50
+ */
51
+ export const updateBase = (filePath, record, actualPath = '', contentSnapshot = null) => {
52
+ // This is the true location of the file
53
+ const fileSystemLocation = actualPath || path.resolve(filePath);
54
+
55
+ if (!fs.existsSync(fileSystemLocation)) {
56
+ console.error(`❌ File does not exist: ${filePath}`);
57
+ return;
58
+ }
59
+
60
+ // Get file stats for mtime
61
+ const fileStats = fs.statSync(fileSystemLocation);
62
+
63
+ // Use snapshot if provided (to avoid race conditions), otherwise read from disk
64
+ let fileContent, contentHash;
65
+ if (contentSnapshot && contentSnapshot.content) {
66
+ // Use the snapshot of what was actually published
67
+ fileContent = contentSnapshot.content;
68
+ contentHash = contentSnapshot.hash;
69
+ } else {
70
+ // Read from disk (normal behavior)
71
+ fileContent = fs.readFileSync(fileSystemLocation, "utf-8");
72
+ contentHash = sha256(fileContent);
73
+ }
74
+
75
+ // Save sync metadata and content to base.json via config manager.
76
+ // - key: the relative path to the file
77
+ // - value: lastModified, contentHash, and full content
78
+ // - options: ensure writing to base.json
79
+ const saveData = {
80
+ lastModified: fileStats.mtimeMs,
81
+ contentHash,
82
+ compressedContent: compressString(fileContent),
83
+ recordId: record.Id,
84
+ type: record.Type,
85
+ filePath,
86
+ lastKnownActualPath: fileSystemLocation,
87
+ lastKnownPath: path.resolve(filePath)
88
+ }
89
+
90
+ if (saveData.type === 'File') delete saveData.compressedContent;
91
+
92
+ config.save(
93
+ record.Id,
94
+ saveData,
95
+ {
96
+ filename: "base.json"
97
+ }
98
+ );
99
+ };
100
+
101
+ export const removeFromBase = (recordId) => {
102
+ config.removeKey(recordId, { filename: "base.json" });
103
+ }
package/vars/config.js ADDED
@@ -0,0 +1 @@
1
+ export const VERSION = '1.0.0'
package/vars/global.js ADDED
@@ -0,0 +1,33 @@
1
+ import { sha256 } from '../utils/hash.js'; // Or wherever your hash function lives
2
+
3
+ export const CWD = process.cwd();
4
+ export const HASHED_CWD = sha256(CWD);
5
+ export const EXPORT_ROOT = "src";
6
+
7
+ /**
8
+ * Maps Magentrix Type fields to local folder names and extensions.
9
+ * Extensions chosen to avoid collisions and clearly indicate type.
10
+ */
11
+ export const TYPE_DIR_MAP = {
12
+ Class: { directory: "Classes", extension: "ac" },
13
+ Trigger: { directory: "Triggers", extension: "trigger" },
14
+ Controller: { directory: "Controllers", extension: "ctrl" },
15
+ "Active Page": { directory: "Pages", extension: "aspx" }, // For ActivePage
16
+ "Active Template": { directory: "Templates", extension: "aspx" }
17
+ };
18
+
19
+ export const ENTITY_TYPE_MAP = {
20
+ 'Active Page': "ActivePage",
21
+ "Active Template": "ActivePage",
22
+ "Class": "ActiveClass",
23
+ "Controller": "ActiveClass",
24
+ "Trigger": "ActiveClass"
25
+ }
26
+
27
+ export const ENTITY_FIELD_MAP = {
28
+ "Active Page": "Content",
29
+ "Active Template": "Content",
30
+ "Class": "Body",
31
+ "Controller": "Body",
32
+ "Trigger": "Body",
33
+ }