@magentrix-corp/magentrix-cli 1.3.11 → 1.3.13

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.
@@ -435,11 +435,23 @@ async function buildFromVueProject(options) {
435
435
  // This ensures pull doesn't overwrite our staged files
436
436
  console.log();
437
437
  console.log(chalk.gray('Checking workspace sync status...'));
438
+
439
+ // Check for a previous incomplete pull first — `magentrix status` only checks
440
+ // code entities, so a partial pull that synced code but not assets would falsely
441
+ // report "in sync". The marker file catches this case.
442
+ const workspaceConfig = new Config({ projectDir: workspacePath });
443
+ const hadIncompletePull = workspaceConfig.read('pullIncomplete', { global: false, filename: 'config.json' });
444
+
438
445
  const syncStatus = await checkWorkspaceSyncStatus(workspacePath);
446
+ const needsPull = syncStatus.needsPull || !!hadIncompletePull;
439
447
 
440
- if (syncStatus.needsPull) {
448
+ if (needsPull) {
441
449
  console.log();
442
- console.log(chalk.yellow('⚠ Your workspace may be out of sync with the server.'));
450
+ if (hadIncompletePull && !syncStatus.needsPull) {
451
+ console.log(chalk.yellow('⚠ A previous pull did not complete. Your workspace may be out of sync.'));
452
+ } else {
453
+ console.log(chalk.yellow('⚠ Your workspace may be out of sync with the server.'));
454
+ }
443
455
 
444
456
  const shouldPull = await confirm({
445
457
  message: 'Would you like to pull latest changes first?',
@@ -451,9 +463,16 @@ async function buildFromVueProject(options) {
451
463
  console.log(chalk.blue('Running pull from workspace...'));
452
464
  console.log();
453
465
 
466
+ // Mark pull as in-progress before starting
467
+ workspaceConfig.save('pullIncomplete', true, { global: false, filename: 'config.json' });
468
+
454
469
  const pullSuccess = await runCommandFromWorkspace(workspacePath, 'pull');
455
470
 
456
- if (!pullSuccess) {
471
+ if (pullSuccess) {
472
+ // Pull completed successfully — clear the marker
473
+ workspaceConfig.removeKey('pullIncomplete', { filename: 'config.json' });
474
+ } else {
475
+ // Pull failed or was cancelled — marker stays for next run
457
476
  console.log();
458
477
  console.log(chalk.yellow('Pull encountered issues. You may want to resolve them manually.'));
459
478
 
@@ -12,6 +12,7 @@ import {
12
12
  EXPORT_ROOT,
13
13
  TYPE_DIR_MAP,
14
14
  IRIS_APPS_DIR,
15
+ ALLOWED_SRC_DIRS,
15
16
  } from "../vars/global.js";
16
17
  import { getFileTag, setFileTag } from "../utils/filetag.js";
17
18
  import { sha256 } from "../utils/hash.js";
@@ -198,9 +199,14 @@ const handleDeleteStaticAssetAction = async (instanceUrl, apiKey, action) => {
198
199
 
199
200
  if (isNotFound) {
200
201
  // Clean up base.json since file doesn't exist on server
201
- for (const name of action.names) {
202
- const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
203
- removeFromBase(filePath);
202
+ // Use the original base.json key if available (avoids path format mismatches)
203
+ if (action.baseKey) {
204
+ removeFromBase(action.baseKey);
205
+ } else {
206
+ for (const name of action.names) {
207
+ const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
208
+ removeFromBase(filePath);
209
+ }
204
210
  }
205
211
  return { cleanedFromCache: true };
206
212
  }
@@ -252,7 +258,8 @@ const handleDeleteFolderAction = async (instanceUrl, apiKey, action) => {
252
258
 
253
259
  if (isNotFound) {
254
260
  // Clean up base.json since folder doesn't exist on server
255
- removeFromBase(action.folderPath);
261
+ // Use original base.json key if available (avoids path format mismatches)
262
+ removeFromBase(action.baseKey || action.folderPath);
256
263
 
257
264
  // Also remove all files and subfolders inside this folder from base
258
265
  const hits = await config.searchObject({}, { filename: "base.json", global: false });
@@ -288,14 +295,19 @@ const handlePublishIrisAppAction = async (instanceUrl, apiKey, action) => {
288
295
  // Create zip from the app folder
289
296
  const zipBuffer = await createIrisZip(action.appPath, action.slug);
290
297
 
291
- // Publish via API with app metadata
298
+ // For updates, don't send app-name/description/icon to avoid triggering
299
+ // metadata modification that may require permissions the user doesn't have.
300
+ // The server already has the correct metadata from the original create.
301
+ // For creates, send all metadata so the app is properly registered.
302
+ const isUpdate = action.action === 'update_iris_app';
303
+
292
304
  const response = await publishApp(
293
305
  instanceUrl,
294
306
  apiKey,
295
307
  zipBuffer,
296
308
  `${action.slug}.zip`,
297
- action.appName,
298
- {
309
+ isUpdate ? null : action.appName,
310
+ isUpdate ? {} : {
299
311
  appDescription: action.appDescription,
300
312
  appIconId: action.appIconId
301
313
  }
@@ -424,9 +436,14 @@ const updateCacheAfterSuccess = async (action, operationResult) => {
424
436
  case "delete_static_asset":
425
437
  // Skip if already cleaned from cache during 404 handling
426
438
  if (!operationResult?.cleanedFromCache) {
427
- for (const name of action.names) {
428
- const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
429
- removeFromBase(filePath);
439
+ // Use the original base.json key if available (avoids path format mismatches)
440
+ if (action.baseKey) {
441
+ removeFromBase(action.baseKey);
442
+ } else {
443
+ for (const name of action.names) {
444
+ const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
445
+ removeFromBase(filePath);
446
+ }
430
447
  }
431
448
  }
432
449
  break;
@@ -442,8 +459,8 @@ const updateCacheAfterSuccess = async (action, operationResult) => {
442
459
  case "delete_folder": {
443
460
  // Skip if already cleaned from cache during 404 handling
444
461
  if (!operationResult?.cleanedFromCache) {
445
- // Remove the folder itself from base using recordId (which is the folderPath)
446
- removeFromBase(action.folderPath);
462
+ // Remove the folder itself from base using the original base.json key
463
+ removeFromBase(action.baseKey || action.folderPath);
447
464
 
448
465
  // Also remove all files and subfolders inside this folder from base
449
466
  const hits = await config.searchObject({}, { filename: "base.json", global: false });
@@ -870,16 +887,19 @@ export const runPublish = async (options = {}) => {
870
887
  progress.completeStep('load', `✓ Loaded ${cachedFiles.length} entries (${loadTime}ms load, ${mapTime}ms map)`);
871
888
  }
872
889
 
873
- // Step 3: Scan local workspace (excluding Assets and iris-apps folders)
890
+ // Step 3: Scan local workspace (only whitelisted code entity directories)
874
891
  if (progress) progress.startStep('scan');
875
892
 
876
893
  const walkStart = Date.now();
877
- const localPaths = await walkFiles(EXPORT_ROOT, {
878
- ignore: [
879
- path.join(EXPORT_ROOT, 'Assets'),
880
- path.join(EXPORT_ROOT, IRIS_APPS_DIR)
881
- ]
882
- });
894
+ // Only scan whitelisted directories for code entities (exclude Assets and iris-apps, handled separately)
895
+ const codeEntityDirs = ALLOWED_SRC_DIRS.filter(dir => dir !== 'Assets' && dir !== IRIS_APPS_DIR);
896
+ const localPathArrays = await Promise.all(
897
+ codeEntityDirs.map(dir => {
898
+ const dirPath = path.join(EXPORT_ROOT, dir);
899
+ return fs.existsSync(dirPath) ? walkFiles(dirPath) : Promise.resolve([]);
900
+ })
901
+ );
902
+ const localPaths = localPathArrays.flat();
883
903
  const walkTime = Date.now() - walkStart;
884
904
 
885
905
  const tagStart = Date.now();
@@ -1025,7 +1045,8 @@ export const runPublish = async (options = {}) => {
1025
1045
  action: "delete_folder",
1026
1046
  folderPath: cachedPath,
1027
1047
  parentPath: toApiFolderPath(parentDir),
1028
- folderName: path.basename(cachedPath)
1048
+ folderName: path.basename(cachedPath),
1049
+ baseKey: cachedFolder.tag // The original base.json key for correct cache cleanup
1029
1050
  });
1030
1051
  }
1031
1052
  }
@@ -1063,7 +1084,8 @@ export const runPublish = async (options = {}) => {
1063
1084
  action: 'delete_static_asset',
1064
1085
  folder: toApiPath(actualPath),
1065
1086
  names: [path.basename(actualPath)],
1066
- filePath: actualPath // Store actual file path for filtering
1087
+ filePath: actualPath, // Store actual file path for filtering
1088
+ baseKey: id // The original base.json key for correct cache cleanup
1067
1089
  });
1068
1090
  continue;
1069
1091
  }
package/actions/pull.js CHANGED
@@ -128,9 +128,12 @@ export const pull = async () => {
128
128
  progress.startStep('load');
129
129
  const hits = await config.searchObject({}, { filename: "base.json", global: false });
130
130
  const cachedResults = hits?.[0]?.value || {};
131
- const cachedAssets = Object.values(cachedResults).filter(c =>
132
- c.type === 'File' || c.type === 'Folder'
133
- );
131
+ // Include the base.json key (_baseKey) alongside each entry so we can
132
+ // use the correct key for removeFromBaseBulk later. The keys may differ
133
+ // from lastKnownActualPath/filePath due to path format inconsistencies.
134
+ const cachedAssets = Object.entries(cachedResults)
135
+ .filter(([, c]) => c.type === 'File' || c.type === 'Folder')
136
+ .map(([key, c]) => ({ ...c, _baseKey: key }));
134
137
  progress.completeStep('load', `✓ Loaded ${Object.keys(cachedResults).length} cached entries`);
135
138
 
136
139
  // Step 4a: Download code entities (ActiveClass and ActivePage)
@@ -338,7 +341,7 @@ export const pull = async () => {
338
341
 
339
342
  // If this asset was in base.json but not returned from server, it was deleted
340
343
  if (!serverAssetPaths.has(cachedPath)) {
341
- assetsToDelete.push(cachedPath);
344
+ assetsToDelete.push({ path: cachedPath, baseKey: cached._baseKey });
342
345
  }
343
346
 
344
347
  processedCount++;
@@ -402,33 +405,35 @@ export const pull = async () => {
402
405
  // Delete local files/folders that were deleted on server
403
406
  logger.info(`Starting deletion of ${assetsToDelete.length} assets`);
404
407
  const deletionLogs = [];
405
- for (const assetPath of assetsToDelete) {
408
+ // Track base.json keys for entries that need removal
409
+ const assetBaseKeysToRemove = [];
410
+ for (const asset of assetsToDelete) {
406
411
  try {
407
- if (fs.existsSync(assetPath)) {
408
- const stats = fs.statSync(assetPath);
412
+ if (fs.existsSync(asset.path)) {
413
+ const stats = fs.statSync(asset.path);
409
414
  if (stats.isDirectory()) {
410
- fs.rmSync(assetPath, { recursive: true, force: true });
411
- deletionLogs.push({ type: 'folder', path: assetPath });
412
- logger.info('Deleted folder', { path: assetPath });
415
+ fs.rmSync(asset.path, { recursive: true, force: true });
416
+ deletionLogs.push({ type: 'folder', path: asset.path });
417
+ logger.info('Deleted folder', { path: asset.path });
413
418
  } else {
414
- fs.unlinkSync(assetPath);
415
- deletionLogs.push({ type: 'file', path: assetPath });
416
- logger.info('Deleted file', { path: assetPath });
419
+ fs.unlinkSync(asset.path);
420
+ deletionLogs.push({ type: 'file', path: asset.path });
421
+ logger.info('Deleted file', { path: asset.path });
417
422
  }
418
423
  }
424
+ // Always remove from base.json regardless of whether the local file
425
+ // existed — the server confirmed deletion, so the cache entry is stale.
426
+ assetBaseKeysToRemove.push(asset.baseKey);
419
427
  } catch (err) {
420
- deletionLogs.push({ type: 'error', path: assetPath, error: err.message });
421
- logger.error(`Failed to delete asset: ${assetPath}`, err);
428
+ deletionLogs.push({ type: 'error', path: asset.path, error: err.message });
429
+ logger.error(`Failed to delete asset: ${asset.path}`, err);
422
430
  }
423
431
  }
424
432
 
425
- // Bulk remove from base.json
426
- const assetPathsToRemove = deletionLogs
427
- .filter(l => l.type === 'file' || l.type === 'folder')
428
- .map(l => l.path);
429
-
430
- if (assetPathsToRemove.length > 0) {
431
- removeFromBaseBulk(assetPathsToRemove);
433
+ // Bulk remove from base.json using the original base.json keys
434
+ // (not the normalized paths, which may have a different format)
435
+ if (assetBaseKeysToRemove.length > 0) {
436
+ removeFromBaseBulk(assetBaseKeysToRemove);
432
437
  }
433
438
 
434
439
  // Delete local code entity files that were deleted on server
@@ -639,6 +644,10 @@ export const pull = async () => {
639
644
  logger.close();
640
645
  progress.finish('Pull completed successfully!');
641
646
 
647
+ // Clear any incomplete-pull marker (set by vue-run-build when a pull is
648
+ // started but cancelled/fails). A successful pull means we're fully synced.
649
+ config.removeKey('pullIncomplete', { filename: 'config.json' });
650
+
642
651
  // Summary
643
652
  console.log(chalk.bold(`Summary:`));
644
653
  console.log(` • ${activeClassResult.Records.length} ActiveClass records`);
package/bin/magentrix.js CHANGED
@@ -20,6 +20,7 @@ import { configWizard } from '../actions/config.js';
20
20
  import { irisLink, irisDev, irisDelete, irisRecover, vueBuildStage } from '../actions/iris/index.js';
21
21
  import Config from '../utils/config.js';
22
22
  import { registerWorkspace, getRegisteredWorkspaces } from '../utils/workspaces.js';
23
+ import { ensureVSCodeFileAssociation } from '../utils/preferences.js';
23
24
 
24
25
  const config = new Config();
25
26
 
@@ -102,6 +103,13 @@ function ensureWorkspaceRegistered() {
102
103
 
103
104
  async function preMiddleware() {
104
105
  ensureWorkspaceRegistered();
106
+
107
+ // Ensure .vscode folder exists in project root (not in src/) for Magentrix projects
108
+ const magentrixDir = join(CWD, '.magentrix');
109
+ if (existsSync(magentrixDir)) {
110
+ await ensureVSCodeFileAssociation(CWD);
111
+ }
112
+
105
113
  await recacheFileIdIndex(EXPORT_ROOT);
106
114
  await cacheDir(EXPORT_ROOT);
107
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magentrix-corp/magentrix-cli",
3
- "version": "1.3.11",
3
+ "version": "1.3.13",
4
4
  "description": "CLI tool for synchronizing local files with Magentrix cloud platform",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/utils/cacher.js CHANGED
@@ -4,10 +4,13 @@ import Config from './config.js';
4
4
  import { findFileByTag, getFileTag, isPathLinkedToTagByLastKnownPath, setFileTag } from './filetag.js';
5
5
  import { compressString } from './compress.js';
6
6
  import { sha256 } from './hash.js';
7
- import { EXPORT_ROOT } from '../vars/global.js';
7
+ import { EXPORT_ROOT, ALLOWED_SRC_DIRS, IRIS_APPS_DIR } from '../vars/global.js';
8
8
 
9
9
  const config = new Config();
10
10
 
11
+ // System/hidden files that should never be synced
12
+ const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
13
+
11
14
  /**
12
15
  * Recursively caches all files in a directory with tagging and content snapshotting
13
16
  * OPTIMIZED: Parallel processing + mtime checks for maximum speed
@@ -19,8 +22,15 @@ export const cacheDir = async (dir) => {
19
22
 
20
23
  const absDir = path.resolve(dir);
21
24
 
22
- // Walk files but exclude Assets folder (tracked separately in base.json)
23
- const files = await walkFiles(absDir, { ignore: [path.join(absDir, 'Assets')] });
25
+ // Only walk whitelisted code entity directories (exclude Assets and iris-apps, handled separately)
26
+ const codeEntityDirs = ALLOWED_SRC_DIRS.filter(d => d !== 'Assets' && d !== IRIS_APPS_DIR);
27
+ const fileArrays = await Promise.all(
28
+ codeEntityDirs.map(d => {
29
+ const dirPath = path.join(absDir, d);
30
+ return fs.existsSync(dirPath) ? walkFiles(dirPath) : Promise.resolve([]);
31
+ })
32
+ );
33
+ const files = fileArrays.flat();
24
34
 
25
35
  const cache = config.read('cachedFiles', { global: false, filename: 'fileCache.json' }) || {};
26
36
 
@@ -108,10 +118,17 @@ export const cacheDir = async (dir) => {
108
118
  * @returns {Promise<void>}
109
119
  */
110
120
  export const recacheFileIdIndex = async (dir) => {
111
- // Exclude Assets folder - they don't use file tags, tracked in base.json instead
112
121
  const absDir = path.resolve(dir);
113
- const ignorePath = path.join(absDir, 'Assets');
114
- const files = await walkFiles(absDir, { ignore: [ignorePath] });
122
+
123
+ // Only walk whitelisted code entity directories (exclude Assets and iris-apps, handled separately)
124
+ const codeEntityDirs = ALLOWED_SRC_DIRS.filter(d => d !== 'Assets' && d !== IRIS_APPS_DIR);
125
+ const fileArrays = await Promise.all(
126
+ codeEntityDirs.map(d => {
127
+ const dirPath = path.join(absDir, d);
128
+ return fs.existsSync(dirPath) ? walkFiles(dirPath) : Promise.resolve([]);
129
+ })
130
+ );
131
+ const files = fileArrays.flat();
115
132
  if (!files || files?.length < 1) return;
116
133
 
117
134
  // Process files in parallel batches of 50 for speed
@@ -165,6 +182,11 @@ export async function walkFiles(dir, settings) {
165
182
  for (const entry of entries) {
166
183
  const fullPath = path.join(dir, entry.name);
167
184
 
185
+ // Skip OS-generated system files (.DS_Store, Thumbs.db, etc.)
186
+ if (IGNORED_FILES.has(entry.name)) {
187
+ continue;
188
+ }
189
+
168
190
  // Check if this path should be ignored
169
191
  if (ignore.find(p => fullPath.startsWith(p) || fullPath === p)) {
170
192
  continue;
@@ -245,7 +245,7 @@ export const writeRecords = async (records, resolutionMethod, progress = null, l
245
245
  updateBase(filePath, record, cachedFilePath);
246
246
  }
247
247
  } catch (err) {
248
- const msg = `Failed to write file ${cachedFilePath}: ${err.message}`;
248
+ const msg = `Failed to write file ${filePath}: ${err.message}`;
249
249
  if (logger) {
250
250
  logger.error(msg, err);
251
251
  } else {
@@ -5,6 +5,9 @@ import { randomUUID, createHash } from 'node:crypto';
5
5
  import archiver from 'archiver';
6
6
  import extractZip from 'extract-zip';
7
7
 
8
+ // System/hidden files that should never be included in IRIS app zips or hashes
9
+ const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
10
+
8
11
  /**
9
12
  * Recursively fix permissions on extracted files.
10
13
  * Sets directories to 0o755 and files to 0o644.
@@ -61,7 +64,10 @@ export async function createIrisZip(distPath, appSlug) {
61
64
 
62
65
  // Add the dist directory contents under the app slug folder
63
66
  // This creates the structure: appSlug/remoteEntry.js, appSlug/assets/...
64
- archive.directory(distPath, appSlug);
67
+ // Filter out OS-generated system files (.DS_Store, Thumbs.db, etc.)
68
+ archive.directory(distPath, appSlug, (entry) =>
69
+ IGNORED_FILES.has(basename(entry.name)) ? false : entry
70
+ );
65
71
 
66
72
  archive.finalize();
67
73
  });
@@ -195,6 +201,9 @@ export function getFilesRecursive(dir, basePath = dir) {
195
201
  function walk(currentDir) {
196
202
  const entries = readdirSync(currentDir);
197
203
  for (const entry of entries) {
204
+ // Skip OS-generated system files
205
+ if (IGNORED_FILES.has(entry)) continue;
206
+
198
207
  const fullPath = join(currentDir, entry);
199
208
  const stat = statSync(fullPath);
200
209
  if (stat.isDirectory()) {
@@ -48,10 +48,6 @@ export const publishApp = async (instanceUrl, token, zipBuffer, filename, appNam
48
48
  throw new Error('filename is required');
49
49
  }
50
50
 
51
- if (!appName) {
52
- throw new Error('appName is required for navigation menu updates');
53
- }
54
-
55
51
  // Create a File object from the buffer for FormData
56
52
  const file = new File([zipBuffer], filename, { type: 'application/zip' });
57
53
 
@@ -59,8 +55,14 @@ export const publishApp = async (instanceUrl, token, zipBuffer, filename, appNam
59
55
  formData.append('file', file);
60
56
 
61
57
  // Build query parameters
58
+ // Note: app-name is only sent when provided (required for creates, optional for updates)
59
+ // Sending app-name on updates can trigger a rename attempt that fails if the user
60
+ // lacks metadata-edit permissions on the server
62
61
  const params = new URLSearchParams();
63
- params.append('app-name', appName);
62
+
63
+ if (appName) {
64
+ params.append('app-name', appName);
65
+ }
64
66
 
65
67
  if (options.appDescription) {
66
68
  params.append('app-description', options.appDescription);
package/vars/global.js CHANGED
@@ -6,6 +6,22 @@ export const EXPORT_ROOT = "src";
6
6
  export const ASSETS_DIR = "Assets"; // Local directory name for static assets (API uses /contents/assets)
7
7
  export const IRIS_APPS_DIR = "iris-apps"; // Local directory for Iris Vue.js apps
8
8
 
9
+ /**
10
+ * Whitelist of allowed directories inside EXPORT_ROOT.
11
+ * Only these folders will be scanned during publish/cache operations.
12
+ * This prevents accidental processing of hidden folders (.magentrix, .vscode, etc.)
13
+ * or any other unexpected directories users might create.
14
+ */
15
+ export const ALLOWED_SRC_DIRS = [
16
+ "Assets",
17
+ "Classes",
18
+ "Controllers",
19
+ "iris-apps",
20
+ "Pages",
21
+ "Templates",
22
+ "Triggers"
23
+ ];
24
+
9
25
  /**
10
26
  * Maps Magentrix Type fields to local folder names and extensions.
11
27
  * Extensions chosen to avoid collisions and clearly indicate type.