@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.
package/actions/pull.js CHANGED
@@ -14,6 +14,7 @@ import { getFileTag } from "../utils/filetag.js";
14
14
  import { downloadAssetsZip, listAssets } from "../utils/magentrix/api/assets.js";
15
15
  import { downloadAssets, walkAssets } from "../utils/downloadAssets.js";
16
16
  import { v4 as uuidv4 } from 'uuid';
17
+ import readlineSync from 'readline-sync';
17
18
 
18
19
  const config = new Config();
19
20
 
@@ -42,7 +43,44 @@ export const pull = async () => {
42
43
  // Clear the terminal
43
44
  process.stdout.write('\x1Bc');
44
45
 
45
- // Step 2: Prepare queries for both ActiveClass and ActivePage
46
+ // Step 2: Check if instance URL has changed (credential switch detected)
47
+ const lastInstanceUrl = config.read('lastInstanceUrl', { global: false, filename: 'config.json' });
48
+ const instanceChanged = lastInstanceUrl && lastInstanceUrl !== instanceUrl;
49
+
50
+ if (instanceChanged) {
51
+ console.log(chalk.yellow.bold(`\n⚠️ INSTANCE CHANGE DETECTED`));
52
+ console.log(chalk.yellow(`Previous instance: ${chalk.cyan(lastInstanceUrl)}`));
53
+ console.log(chalk.yellow(`New instance: ${chalk.cyan(instanceUrl)}`));
54
+ console.log();
55
+ console.log(chalk.red.bold(`⚠️ WARNING: This will DELETE your existing ${chalk.white(EXPORT_ROOT + '/')} directory!`));
56
+ console.log(chalk.gray(`This is necessary to prevent mixing files from different instances.`));
57
+ console.log();
58
+
59
+ const confirm = readlineSync.question(
60
+ chalk.yellow(`Type ${chalk.white.bold('yes')} to continue and delete ${EXPORT_ROOT}/, or ${chalk.white.bold('no')} to cancel: `)
61
+ );
62
+
63
+ if (confirm.trim().toLowerCase() !== 'yes') {
64
+ console.log(chalk.red('\n❌ Pull cancelled. No files were deleted.'));
65
+ console.log(chalk.gray(`Tip: To pull from ${chalk.cyan(lastInstanceUrl)}, switch back to those credentials.`));
66
+ process.exit(0);
67
+ }
68
+
69
+ console.log(chalk.yellow(`\n🗑️ Removing existing ${EXPORT_ROOT}/ directory...`));
70
+ if (fs.existsSync(EXPORT_ROOT)) {
71
+ fs.rmSync(EXPORT_ROOT, { recursive: true, force: true });
72
+ }
73
+ // Clear the base.json cache as well since it's from a different instance
74
+ fs.writeFileSync('.magentrix/base.json', JSON.stringify({}));
75
+ config.save('cachedFiles', {}, { filename: 'fileCache.json' });
76
+ config.save('trackedFileTags', {}, { filename: 'fileIdIndex.json' });
77
+ console.log(chalk.green(`✓ Removed ${EXPORT_ROOT}/ directory\n`));
78
+ }
79
+
80
+ // Save the current instance URL for future comparisons
81
+ config.save('lastInstanceUrl', instanceUrl, { global: false, filename: 'config.json' });
82
+
83
+ // Step 3: Prepare queries for both ActiveClass and ActivePage
46
84
  const queries = [
47
85
  {
48
86
  name: "ActiveClass",
@@ -63,7 +101,7 @@ export const pull = async () => {
63
101
  );
64
102
 
65
103
  const assetTree = await downloadAssets(instanceUrl, token.value);
66
-
104
+
67
105
  return [
68
106
  ...meqlResults,
69
107
  assetTree
@@ -74,13 +112,23 @@ export const pull = async () => {
74
112
  const processAssets = (records) => {
75
113
  for (const record of records) {
76
114
  if (record?.Type === 'Folder') {
77
- if (record?.Children?.length === 0) continue;
78
- processAssets(record.Children);
115
+ // Cache the folder itself
116
+ updateBase(
117
+ path.join(EXPORT_ROOT, record?.Path),
118
+ {
119
+ ...record,
120
+ Id: path.join(EXPORT_ROOT, record?.Path)
121
+ }
122
+ );
123
+ // Process children if any
124
+ if (record?.Children?.length > 0) {
125
+ processAssets(record.Children);
126
+ }
79
127
  continue;
80
128
  }
81
129
 
82
130
  updateBase(
83
- path.join(EXPORT_ROOT, record?.Path),
131
+ path.join(EXPORT_ROOT, record?.Path),
84
132
  {
85
133
  ...record,
86
134
  Id: path.join(EXPORT_ROOT, record?.Path)
@@ -91,9 +139,6 @@ export const pull = async () => {
91
139
 
92
140
  processAssets(assets.tree);
93
141
 
94
- // Remove (clean) the export root directory
95
- // fs.rmSync(EXPORT_ROOT, { recursive: true, force: true });
96
-
97
142
  // Check for conflicts and have user select conflict resolution
98
143
  const activeClassRecords = (activeClassResult.Records || []).map(record => {
99
144
  record.Content = record.Body;
package/actions/setup.js CHANGED
@@ -9,20 +9,47 @@ const config = new Config();
9
9
 
10
10
  /**
11
11
  * Runs the global setup for the Magentrix CLI.
12
- *
12
+ *
13
13
  * Prompts the user for their API key and instance URL if needed,
14
14
  * validates credentials by attempting authentication,
15
15
  * and saves them to the global config if successful.
16
- *
16
+ *
17
17
  * @async
18
- * @param {boolean} [forceNewData=false] - If true, always prompt for new data, even if values exist.
18
+ * @param {Object} cliOptions - CLI options passed from command flags
19
+ * @param {string} [cliOptions.apiKey] - API key from CLI
20
+ * @param {string} [cliOptions.instanceUrl] - Instance URL from CLI
19
21
  * @returns {Promise<{apiKey: string, instanceUrl: string}>} The saved API key and instance URL.
20
22
  * @throws {Error} If authentication fails with provided credentials.
21
23
  */
22
- export const setup = async () => {
23
- // Prompt for API key and Instance URL if missing or if forceNewData is true
24
- const apiKey = await ensureApiKey(true);
25
- const instanceUrl = await ensureInstanceUrl(true);
24
+ export const setup = async (cliOptions = {}) => {
25
+ // Validation for CLI-provided values
26
+ const urlRegex = /^https:\/\/[a-zA-Z0-9-]+\.magentrix(cloud)?\.com$/;
27
+
28
+ if (cliOptions.apiKey) {
29
+ if (cliOptions.apiKey.trim().length < 12) {
30
+ throw new Error('--api-key must be at least 12 characters long');
31
+ }
32
+ if (/\s/.test(cliOptions.apiKey)) {
33
+ throw new Error('--api-key cannot contain spaces');
34
+ }
35
+ }
36
+
37
+ if (cliOptions.instanceUrl) {
38
+ const trimmedUrl = cliOptions.instanceUrl.trim();
39
+ if (!urlRegex.test(trimmedUrl)) {
40
+ throw new Error('--instance-url must be in the form: https://subdomain.magentrixcloud.com (NO http, NO trailing /, NO extra path)');
41
+ }
42
+ }
43
+
44
+ // Get API key (from CLI or prompt)
45
+ const apiKey = cliOptions.apiKey
46
+ ? cliOptions.apiKey.trim()
47
+ : await ensureApiKey(true);
48
+
49
+ // Get instance URL (from CLI or prompt)
50
+ const instanceUrl = cliOptions.instanceUrl
51
+ ? cliOptions.instanceUrl.trim()
52
+ : await ensureInstanceUrl(true);
26
53
 
27
54
  // Validate credentials by attempting to fetch an access token
28
55
  const tokenData = await tryAuthenticate(apiKey, instanceUrl);
package/bin/magentrix.js CHANGED
@@ -113,9 +113,68 @@ program.configureOutput({
113
113
  });
114
114
 
115
115
  // ── Commands ─────────────────────────────────
116
- program.command('setup').description('Configure your Magentrix API key').action(withDefault(setup));
116
+ program
117
+ .command('setup')
118
+ .description('Configure your Magentrix API key')
119
+ .option('--api-key <apiKey>', 'Magentrix API key')
120
+ .option('--instance-url <instanceUrl>', 'Magentrix instance URL (e.g., https://example.magentrixcloud.com)')
121
+ .action(withDefault(setup));
117
122
  program.command('pull').description('Pull files from the remote server').action(withDefault(pull));
118
- program.command('create').description('Create files locally').action(withDefault(create));
123
+ const createCommand = program
124
+ .command('create')
125
+ .description('Create files locally')
126
+ .option('--type <type>', 'Entity type: class, page, or template')
127
+ .option('--class-type <classType>', 'Class type: controller, utility, or trigger (for --type class)')
128
+ .option('--name <name>', 'Name of the file to create')
129
+ .option('--description <description>', 'Optional description')
130
+ .option('--entity-id <entityId>', 'Entity ID (required for triggers)')
131
+ .action(withDefault(create));
132
+
133
+ // Override help for create command to show options
134
+ createCommand.configureHelp({
135
+ formatHelp: () => {
136
+ const divider = chalk.gray('━'.repeat(60));
137
+ const titleBar = chalk.bold.bgBlue.white(' Magentrix CLI - Create Command ');
138
+
139
+ let help = `\n${divider}\n${titleBar}\n${divider}\n\n`;
140
+ help += `${chalk.dim('Create files locally with optional parameters to bypass interactive prompts')}\n\n`;
141
+
142
+ help += `${chalk.bold.yellow('USAGE')}\n`;
143
+ help += ` ${chalk.cyan('magentrix create')} ${chalk.dim('[options]')}\n\n`;
144
+
145
+ help += `${chalk.bold.yellow('OPTIONS')}\n`;
146
+ const options = [
147
+ { name: '--type <type>', desc: 'Entity type: class, page, or template' },
148
+ { name: '--class-type <classType>', desc: 'Class type: controller, utility, or trigger (for --type class)' },
149
+ { name: '--name <name>', desc: 'Name of the file to create' },
150
+ { name: '--description <description>', desc: 'Optional description' },
151
+ { name: '--entity-id <entityId>', desc: 'Entity ID (required for triggers)' },
152
+ { name: '-h, --help', desc: 'Display this help message' }
153
+ ];
154
+
155
+ const maxNameLen = Math.max(...options.map(o => o.name.length));
156
+ options.forEach(opt => {
157
+ const padding = ' '.repeat(maxNameLen - opt.name.length);
158
+ help += ` ${chalk.cyan(opt.name)}${padding} ${chalk.dim(opt.desc)}\n`;
159
+ });
160
+
161
+ help += `\n${chalk.bold.yellow('EXAMPLES')}\n`;
162
+ help += ` ${chalk.dim('# Create a controller non-interactively')}\n`;
163
+ help += ` ${chalk.cyan('magentrix create --type class --class-type controller --name UserController')}\n\n`;
164
+ help += ` ${chalk.dim('# Create a trigger')}\n`;
165
+ help += ` ${chalk.cyan('magentrix create --type class --class-type trigger --entity-id abc123 --name AccountTrigger')}\n\n`;
166
+ help += ` ${chalk.dim('# Create a page')}\n`;
167
+ help += ` ${chalk.cyan('magentrix create --type page --name HomePage --description "Main landing page"')}\n\n`;
168
+ help += ` ${chalk.dim('# Create a template')}\n`;
169
+ help += ` ${chalk.cyan('magentrix create --type template --name EmailTemplate')}\n\n`;
170
+ help += ` ${chalk.dim('# Mix interactive and non-interactive (will prompt for missing info)')}\n`;
171
+ help += ` ${chalk.cyan('magentrix create --name MyClass')}\n`;
172
+
173
+ help += `\n${divider}\n`;
174
+
175
+ return help;
176
+ }
177
+ });
119
178
  program.command('status').description('Show file conflicts').action(withDefault(status));
120
179
  program.command('autopublish').description('Watch & sync changes in real time').action(withDefault(autoPublish));
121
180
  program.command('publish').description('Publish pending changes to the remote server').action(withDefault(publish));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magentrix-corp/magentrix-cli",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "CLI tool for synchronizing local files with Magentrix cloud platform",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -8,7 +8,7 @@
8
8
  "magentrix": "./bin/magentrix.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "echo \"Error: no test specified\" && exit 1"
11
+ "test": "node tests/all.js"
12
12
  },
13
13
  "keywords": [
14
14
  "magentrix",
@@ -40,6 +40,7 @@
40
40
  "chokidar": "^4.0.3",
41
41
  "commander": "^14.0.0",
42
42
  "diff": "^8.0.2",
43
+ "dotenv": "^17.2.3",
43
44
  "extract-zip": "^2.0.1",
44
45
  "fuzzy": "^0.1.3",
45
46
  "inquirer": "^12.7.0",
@@ -0,0 +1,138 @@
1
+ import path from 'path';
2
+ import { EXPORT_ROOT } from '../vars/global.js';
3
+
4
+ /**
5
+ * Asset path utilities for handling the difference between local and API paths.
6
+ *
7
+ * Local structure: src/Assets/...
8
+ * API structure: /contents/assets/...
9
+ *
10
+ * These helpers abstract away the "Contents" prefix requirement for the Magentrix API.
11
+ */
12
+
13
+ /**
14
+ * Convert a local asset path to an API path by adding the 'contents' prefix
15
+ * and normalizing to lowercase with forward slashes.
16
+ *
17
+ * @param {string} localPath - Local file path (e.g., "src/Assets/images/logo.png")
18
+ * @returns {string} API path (e.g., "/contents/assets/images")
19
+ *
20
+ * @example
21
+ * toApiPath("src/Assets/images/logo.png") // "/contents/assets/images"
22
+ * toApiPath("src/Assets") // "/contents/assets"
23
+ */
24
+ export const toApiPath = (localPath) => {
25
+ // Handle undefined or empty paths
26
+ if (!localPath) {
27
+ return '/contents/assets';
28
+ }
29
+
30
+ // Normalize the path and remove the EXPORT_ROOT
31
+ const normalized = path.normalize(localPath);
32
+ const relative = normalized.replace(new RegExp(`^${EXPORT_ROOT}[\\\\/]?`), '');
33
+
34
+ // Replace 'Assets' with 'contents/assets'
35
+ const apiPath = relative.replace(/^Assets/i, 'contents/assets');
36
+
37
+ // Normalize to forward slashes and lowercase, remove filename
38
+ let dirPath = path.dirname(apiPath).replace(/\\/g, '/').toLowerCase();
39
+
40
+ // Handle edge case where dirname returns '.' for root
41
+ if (dirPath === '.') {
42
+ dirPath = 'contents/assets';
43
+ }
44
+
45
+ // Ensure it starts with /
46
+ return dirPath.startsWith('/') ? dirPath : `/${dirPath}`;
47
+ };
48
+
49
+ /**
50
+ * Convert an API path to a local path by removing the 'contents' prefix.
51
+ * Returns a relative path (without EXPORT_ROOT) that can be joined with EXPORT_ROOT.
52
+ *
53
+ * @param {string} apiPath - API path (e.g., "/contents/assets/images/logo.png")
54
+ * @returns {string} Relative local path (e.g., "Assets/images/logo.png")
55
+ *
56
+ * @example
57
+ * toLocalPath("/contents/assets/images") // "Assets/images"
58
+ * toLocalPath("/contents/assets") // "Assets"
59
+ */
60
+ export const toLocalPath = (apiPath) => {
61
+ // Remove leading slash and 'contents/' prefix
62
+ const cleaned = apiPath.replace(/^\/+/, '').replace(/^contents\//i, '');
63
+
64
+ // Capitalize 'assets' to 'Assets'
65
+ const withCapitalAssets = cleaned.replace(/^assets/i, 'Assets');
66
+
67
+ return withCapitalAssets;
68
+ };
69
+
70
+ /**
71
+ * Convert a local folder path to an API path (keeps the folder, doesn't extract parent).
72
+ * Similar to toApiPath but for folders.
73
+ *
74
+ * @param {string} localFolderPath - Local folder path (e.g., "src/Assets/images")
75
+ * @returns {string} API path (e.g., "/contents/assets/images")
76
+ *
77
+ * @example
78
+ * toApiFolderPath("src/Assets/images") // "/contents/assets/images"
79
+ * toApiFolderPath("src/Assets") // "/contents/assets"
80
+ */
81
+ export const toApiFolderPath = (localFolderPath) => {
82
+ // Handle undefined or empty paths
83
+ if (!localFolderPath) {
84
+ return '/contents/assets';
85
+ }
86
+
87
+ // Normalize the path and remove the EXPORT_ROOT
88
+ const normalized = path.normalize(localFolderPath);
89
+ const relative = normalized.replace(new RegExp(`^${EXPORT_ROOT}[\\\\/]?`), '');
90
+
91
+ // Replace 'Assets' with 'contents/assets'
92
+ let apiPath = relative.replace(/^Assets/i, 'contents/assets');
93
+
94
+ // Normalize to forward slashes and lowercase (but don't get dirname!)
95
+ apiPath = apiPath.replace(/\\/g, '/').toLowerCase();
96
+
97
+ // Handle edge case where path is just 'Assets'
98
+ if (apiPath === 'contents/assets' || apiPath === '') {
99
+ return '/contents/assets';
100
+ }
101
+
102
+ // Ensure it starts with /
103
+ return apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
104
+ };
105
+
106
+ /**
107
+ * Check if a local path is within the Assets directory.
108
+ *
109
+ * @param {string} localPath - Local file path
110
+ * @returns {boolean} True if path is within Assets directory
111
+ *
112
+ * @example
113
+ * isAssetPath("src/Assets/images/logo.png") // true
114
+ * isAssetPath("src/Classes/MyClass.ac") // false
115
+ */
116
+ export const isAssetPath = (localPath) => {
117
+ const normalized = path.normalize(localPath);
118
+ const assetsDir = path.join(EXPORT_ROOT, 'Assets');
119
+ return normalized.startsWith(assetsDir);
120
+ };
121
+
122
+ /**
123
+ * Extract the folder path from a full file path (removes filename).
124
+ * Returns path relative to Assets root.
125
+ *
126
+ * @param {string} filePath - Full file path
127
+ * @returns {string} Folder path relative to EXPORT_ROOT
128
+ *
129
+ * @example
130
+ * getAssetFolder("src/Assets/images/logo.png") // "Assets/images"
131
+ * getAssetFolder("src/Assets/logo.png") // "Assets"
132
+ */
133
+ export const getAssetFolder = (filePath) => {
134
+ const normalized = path.normalize(filePath);
135
+ const relative = normalized.replace(new RegExp(`^${EXPORT_ROOT}[\\\\/]?`), '');
136
+ const dirPath = path.dirname(relative);
137
+ return dirPath === '.' ? 'Assets' : dirPath;
138
+ };
package/utils/cacher.js CHANGED
@@ -91,7 +91,7 @@ export const recacheFileIdIndex = async (dir) => {
91
91
  export async function walkFiles(dir, settings) {
92
92
  const ignore = settings?.ignore || [];
93
93
 
94
- if (!fs.existsSync(dir)) return;
94
+ if (!fs.existsSync(dir)) return [];
95
95
  let entries = fs.readdirSync(dir, { withFileTypes: true });
96
96
  let paths = [];
97
97
 
@@ -102,7 +102,10 @@ export async function walkFiles(dir, settings) {
102
102
 
103
103
  const fullPath = path.join(dir, entry.name);
104
104
  if (entry.isDirectory()) {
105
- paths.push(...await walkFiles(fullPath, settings));
105
+ const subPaths = await walkFiles(fullPath, settings);
106
+ if (subPaths && subPaths.length > 0) {
107
+ paths.push(...subPaths);
108
+ }
106
109
  } else if (entry.isFile()) {
107
110
  paths.push(fullPath);
108
111
  }
@@ -243,8 +243,10 @@ export async function showCurrentConflicts(rootDir, instanceUrl, token, forceCon
243
243
  export async function promptConflictResolution(fileIssues) {
244
244
  if (!fileIssues.length) return 'skip';
245
245
 
246
- // Clear for better UX
247
- console.clear();
246
+ // Clear for better UX (skip in test mode to avoid clearing test output)
247
+ if (!process.env.MAGENTRIX_TEST_MODE) {
248
+ console.clear();
249
+ }
248
250
  console.log(
249
251
  chalk.bold.yellow(
250
252
  `\n${fileIssues.length} file${fileIssues.length > 1 ? 's' : ''} require conflict resolution:\n`
@@ -5,6 +5,7 @@ import extract from 'extract-zip';
5
5
  import { v4 as uuidv4 } from 'uuid';
6
6
  import fspath from 'path';
7
7
  import { setFileTag } from "./filetag.js";
8
+ import { toLocalPath } from "./assetPaths.js";
8
9
 
9
10
  export const walkAssets = async (instanceUrl, token, assetPath) => {
10
11
  const assetResults = await listAssets(instanceUrl, token, assetPath);
@@ -14,15 +15,19 @@ export const walkAssets = async (instanceUrl, token, assetPath) => {
14
15
  if (asset.Type === 'Folder') {
15
16
  walkedAssets.push({
16
17
  ...asset,
17
- Path: asset.Path.replace('/contents/assets', '/Contents/Assets'),
18
+ ApiPath: asset.Path, // Keep original API path
19
+ Path: toLocalPath(asset.Path), // Convert for local file system
18
20
  Children: await walkAssets(instanceUrl, token, asset.Path),
19
- ParentFolder: assetResults.CurrentPath.replace("/contents/assets", "/Contents/Assets"),
21
+ ParentFolder: toLocalPath(assetResults.CurrentPath),
22
+ ParentApiPath: assetResults.CurrentPath, // Keep API path for downloads
20
23
  })
21
24
  } else {
22
25
  walkedAssets.push({
23
26
  ...asset,
24
- Path: asset.Path.replace("/contents/assets", "/Contents/Assets"),
25
- ParentFolder: assetResults.CurrentPath.replace("/contents/assets", "/Contents/Assets"),
27
+ ApiPath: asset.Path, // Keep original API path
28
+ Path: toLocalPath(asset.Path), // Convert for local file system
29
+ ParentFolder: toLocalPath(assetResults.CurrentPath),
30
+ ParentApiPath: assetResults.CurrentPath, // Keep API path for downloads
26
31
  });
27
32
  }
28
33
  }
@@ -34,7 +39,8 @@ export const downloadAssets = async (instanceUrl, token, path) => {
34
39
  const allAssets = await walkAssets(instanceUrl, token, path);
35
40
 
36
41
  const iterateDownload = async (assets) => {
37
- const parentOfAssets = assets?.[0]?.ParentFolder;
42
+ const parentApiPath = assets?.[0]?.ParentApiPath; // Use API path for API calls
43
+ const parentLocalFolder = assets?.[0]?.ParentFolder; // Use local path for file system
38
44
  const folders = assets.filter(asset => asset.Type === 'Folder');
39
45
  const files = assets.filter(asset => asset.Type === 'File');
40
46
 
@@ -47,13 +53,13 @@ export const downloadAssets = async (instanceUrl, token, path) => {
47
53
  const savedAs = await downloadAssetsZip({
48
54
  baseUrl: instanceUrl,
49
55
  token: token, // "Bearer" prefix added in code
50
- path: parentOfAssets,
56
+ path: parentApiPath, // Use API path for API call
51
57
  names: files.map(file => file.Name),
52
- outFile: fspath.join(EXPORT_ROOT, parentOfAssets, 'assets.zip'), // optional
58
+ outFile: fspath.join(EXPORT_ROOT, parentLocalFolder, 'assets.zip'), // Use local path for file system
53
59
  });
54
60
 
55
61
  await extract(savedAs, {
56
- dir: fspath.resolve(fspath.join(EXPORT_ROOT, parentOfAssets))
62
+ dir: fspath.resolve(fspath.join(EXPORT_ROOT, parentLocalFolder)) // Use local path for extraction
57
63
  });
58
64
 
59
65
  // for (const file of files) {
@@ -80,7 +80,7 @@ export const deleteAsset = async (instanceUrl, token, path = '/contents/assets',
80
80
  if (!path) throw new Error("Path is required when deleting assets.");
81
81
  if (!Array.isArray(names) || names?.length === 0) throw new Error("At least one file name is required when deleting static assets.");
82
82
 
83
- let reqPath = `/api/3.0/staticassets?path=${path}&names=${names.join(",")}`;
83
+ let reqPath = `/api/3.0/staticassets?path=${encodeURIComponent(path)}&names=${names.join(",")}`;
84
84
 
85
85
  const response = await fetchMagentrix({
86
86
  instanceUrl,
@@ -93,6 +93,26 @@ export const deleteAsset = async (instanceUrl, token, path = '/contents/assets',
93
93
  return response;
94
94
  }
95
95
 
96
+ export const createFolder = async (instanceUrl, token, path = '/contents/assets', name) => {
97
+ if (!instanceUrl || !token) {
98
+ throw new Error('Missing required Magentrix instanceUrl or token');
99
+ }
100
+
101
+ if (!name) throw new Error("Folder name is required when creating a folder.");
102
+
103
+ let reqPath = `/api/3.0/staticassets/folder?path=${encodeURIComponent(path)}&name=${encodeURIComponent(name)}`;
104
+
105
+ const response = await fetchMagentrix({
106
+ instanceUrl,
107
+ token,
108
+ path: reqPath,
109
+ method: "POST",
110
+ returnErrorObject: true
111
+ });
112
+
113
+ return response;
114
+ }
115
+
96
116
  /**
97
117
  * Download multiple static assets as a ZIP.
98
118
  * @param {object} opts
@@ -3,7 +3,7 @@ import { fetchMagentrix } from "../fetch.js";
3
3
  /**
4
4
  * Lists all entities available from the Magentrix API.
5
5
  *
6
- * Makes an authenticated GET request to `/api/3.0/entity` to retrieve metadata
6
+ * Makes an authenticated GET request to `/api/3.0/entity` to retrieve metadata
7
7
  * about all available entities (objects) in the Magentrix instance.
8
8
  *
9
9
  * Handles and throws both network errors and API-level errors with detailed messages.
@@ -30,3 +30,57 @@ export const listEntities = async (instanceUrl, token) => {
30
30
 
31
31
  return data;
32
32
  };
33
+
34
+ /**
35
+ * Retrieves a specific ActiveClass or ActivePage entity by ID from Magentrix via the REST API.
36
+ *
37
+ * @async
38
+ * @function retrieveEntity
39
+ * @param {string} instanceUrl - The base URL of the Magentrix instance (e.g. "https://your.magentrix.com").
40
+ * @param {string} token - The OAuth2 bearer token for authentication.
41
+ * @param {string} entityName - The Magentrix entity type. Allowed: "ActiveClass" or "ActivePage" (case-insensitive).
42
+ * @param {string} recordId - The unique Magentrix record ID to retrieve.
43
+ * @returns {Promise<Object>} The API response object containing the record data.
44
+ * @throws {Error} If required parameters are missing, entityName is invalid, or recordId is not provided.
45
+ *
46
+ * @example
47
+ * const record = await retrieveEntity(
48
+ * "https://your.magentrix.com",
49
+ * "yourToken",
50
+ * "ActiveClass",
51
+ * "06bdc45e-8222-44f5-9ed2-40f5a7bc6cb3"
52
+ * );
53
+ */
54
+ export const retrieveEntity = async (instanceUrl, token, entityName, recordId) => {
55
+ // --- Validate required parameters ---
56
+ if (!instanceUrl || typeof instanceUrl !== 'string') {
57
+ throw new Error('Missing or invalid Magentrix instanceUrl');
58
+ }
59
+ if (!token || typeof token !== 'string') {
60
+ throw new Error('Missing or invalid Magentrix token');
61
+ }
62
+ if (!entityName || typeof entityName !== 'string') {
63
+ throw new Error("Missing or invalid 'entityName' (must be 'ActiveClass' or 'ActivePage')");
64
+ }
65
+ if (!recordId || typeof recordId !== 'string') {
66
+ throw new Error("Missing or invalid 'recordId' (must be a Magentrix record GUID string)");
67
+ }
68
+
69
+ // --- Validate entity type ---
70
+ const allowedEntities = ['activeclass', 'activepage'];
71
+ const entity = entityName.trim().toLowerCase();
72
+ if (!allowedEntities.includes(entity)) {
73
+ throw new Error("Invalid 'entityName'. Allowed: 'ActiveClass' or 'ActivePage'");
74
+ }
75
+
76
+ // --- Make GET request to Magentrix API ---
77
+ const response = await fetchMagentrix({
78
+ instanceUrl,
79
+ token,
80
+ path: `/api/3.0/entity/${entity}/${recordId}`,
81
+ method: "GET",
82
+ returnErrorObject: true
83
+ });
84
+
85
+ return response;
86
+ };
@@ -59,10 +59,15 @@ export const updateBase = (filePath, record, actualPath = '', contentSnapshot =
59
59
 
60
60
  // Get file stats for mtime
61
61
  const fileStats = fs.statSync(fileSystemLocation);
62
+ const isDirectory = fileStats.isDirectory();
62
63
 
63
64
  // Use snapshot if provided (to avoid race conditions), otherwise read from disk
64
65
  let fileContent, contentHash;
65
- if (contentSnapshot && contentSnapshot.content) {
66
+ if (isDirectory) {
67
+ // Folders don't have content
68
+ fileContent = '';
69
+ contentHash = '';
70
+ } else if (contentSnapshot && contentSnapshot.content) {
66
71
  // Use the snapshot of what was actually published
67
72
  fileContent = contentSnapshot.content;
68
73
  contentHash = contentSnapshot.hash;
@@ -79,7 +84,7 @@ export const updateBase = (filePath, record, actualPath = '', contentSnapshot =
79
84
  const saveData = {
80
85
  lastModified: fileStats.mtimeMs,
81
86
  contentHash,
82
- compressedContent: compressString(fileContent),
87
+ compressedContent: isDirectory ? '' : compressString(fileContent),
83
88
  recordId: record.Id,
84
89
  type: record.Type,
85
90
  filePath,
@@ -87,7 +92,7 @@ export const updateBase = (filePath, record, actualPath = '', contentSnapshot =
87
92
  lastKnownPath: path.resolve(filePath)
88
93
  }
89
94
 
90
- if (saveData.type === 'File') delete saveData.compressedContent;
95
+ if (saveData.type === 'File' || saveData.type === 'Folder') delete saveData.compressedContent;
91
96
 
92
97
  config.save(
93
98
  record.Id,
package/vars/global.js CHANGED
@@ -3,6 +3,7 @@ import { sha256 } from '../utils/hash.js'; // Or wherever your hash function liv
3
3
  export const CWD = process.cwd();
4
4
  export const HASHED_CWD = sha256(CWD);
5
5
  export const EXPORT_ROOT = "src";
6
+ export const ASSETS_DIR = "Assets"; // Local directory name for static assets (API uses /contents/assets)
6
7
 
7
8
  /**
8
9
  * Maps Magentrix Type fields to local folder names and extensions.