@magentrix-corp/magentrix-cli 1.3.12 → 1.3.14

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
 
@@ -199,9 +199,14 @@ const handleDeleteStaticAssetAction = async (instanceUrl, apiKey, action) => {
199
199
 
200
200
  if (isNotFound) {
201
201
  // Clean up base.json since file doesn't exist on server
202
- for (const name of action.names) {
203
- const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
204
- 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
+ }
205
210
  }
206
211
  return { cleanedFromCache: true };
207
212
  }
@@ -253,7 +258,8 @@ const handleDeleteFolderAction = async (instanceUrl, apiKey, action) => {
253
258
 
254
259
  if (isNotFound) {
255
260
  // Clean up base.json since folder doesn't exist on server
256
- removeFromBase(action.folderPath);
261
+ // Use original base.json key if available (avoids path format mismatches)
262
+ removeFromBase(action.baseKey || action.folderPath);
257
263
 
258
264
  // Also remove all files and subfolders inside this folder from base
259
265
  const hits = await config.searchObject({}, { filename: "base.json", global: false });
@@ -289,14 +295,19 @@ const handlePublishIrisAppAction = async (instanceUrl, apiKey, action) => {
289
295
  // Create zip from the app folder
290
296
  const zipBuffer = await createIrisZip(action.appPath, action.slug);
291
297
 
292
- // 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
+
293
304
  const response = await publishApp(
294
305
  instanceUrl,
295
306
  apiKey,
296
307
  zipBuffer,
297
308
  `${action.slug}.zip`,
298
- action.appName,
299
- {
309
+ isUpdate ? null : action.appName,
310
+ isUpdate ? {} : {
300
311
  appDescription: action.appDescription,
301
312
  appIconId: action.appIconId
302
313
  }
@@ -425,9 +436,14 @@ const updateCacheAfterSuccess = async (action, operationResult) => {
425
436
  case "delete_static_asset":
426
437
  // Skip if already cleaned from cache during 404 handling
427
438
  if (!operationResult?.cleanedFromCache) {
428
- for (const name of action.names) {
429
- const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
430
- 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
+ }
431
447
  }
432
448
  }
433
449
  break;
@@ -443,8 +459,8 @@ const updateCacheAfterSuccess = async (action, operationResult) => {
443
459
  case "delete_folder": {
444
460
  // Skip if already cleaned from cache during 404 handling
445
461
  if (!operationResult?.cleanedFromCache) {
446
- // Remove the folder itself from base using recordId (which is the folderPath)
447
- removeFromBase(action.folderPath);
462
+ // Remove the folder itself from base using the original base.json key
463
+ removeFromBase(action.baseKey || action.folderPath);
448
464
 
449
465
  // Also remove all files and subfolders inside this folder from base
450
466
  const hits = await config.searchObject({}, { filename: "base.json", global: false });
@@ -1029,7 +1045,8 @@ export const runPublish = async (options = {}) => {
1029
1045
  action: "delete_folder",
1030
1046
  folderPath: cachedPath,
1031
1047
  parentPath: toApiFolderPath(parentDir),
1032
- folderName: path.basename(cachedPath)
1048
+ folderName: path.basename(cachedPath),
1049
+ baseKey: cachedFolder.tag // The original base.json key for correct cache cleanup
1033
1050
  });
1034
1051
  }
1035
1052
  }
@@ -1067,7 +1084,8 @@ export const runPublish = async (options = {}) => {
1067
1084
  action: 'delete_static_asset',
1068
1085
  folder: toApiPath(actualPath),
1069
1086
  names: [path.basename(actualPath)],
1070
- 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
1071
1089
  });
1072
1090
  continue;
1073
1091
  }
package/actions/pull.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ensureValidCredentials } from "../utils/cli/helpers/ensureCredentials.js";
2
+ import debug from '../utils/debug.js';
2
3
  import Config from "../utils/config.js";
3
4
  import { meqlQuery } from "../utils/magentrix/api/meqlQuery.js";
4
5
  import fs from "fs";
@@ -63,7 +64,9 @@ export const pull = async () => {
63
64
  try {
64
65
  // Step 1: Authenticate and retrieve instance URL and token
65
66
  progress.startStep('auth');
67
+ debug.log('PULL', 'Step 1: Authenticating...');
66
68
  const { instanceUrl, token } = await ensureValidCredentials();
69
+ debug.log('PULL', `Authenticated with instance: ${instanceUrl}`);
67
70
  progress.completeStep('auth', '✓ Authenticated');
68
71
 
69
72
  // Step 2: Check if instance URL has changed (credential switch detected)
@@ -128,13 +131,18 @@ export const pull = async () => {
128
131
  progress.startStep('load');
129
132
  const hits = await config.searchObject({}, { filename: "base.json", global: false });
130
133
  const cachedResults = hits?.[0]?.value || {};
131
- const cachedAssets = Object.values(cachedResults).filter(c =>
132
- c.type === 'File' || c.type === 'Folder'
133
- );
134
+ // Include the base.json key (_baseKey) alongside each entry so we can
135
+ // use the correct key for removeFromBaseBulk later. The keys may differ
136
+ // from lastKnownActualPath/filePath due to path format inconsistencies.
137
+ const cachedAssets = Object.entries(cachedResults)
138
+ .filter(([, c]) => c.type === 'File' || c.type === 'Folder')
139
+ .map(([key, c]) => ({ ...c, _baseKey: key }));
134
140
  progress.completeStep('load', `✓ Loaded ${Object.keys(cachedResults).length} cached entries`);
135
141
 
136
142
  // Step 4a: Download code entities (ActiveClass and ActivePage)
137
143
  progress.startStep('download-code');
144
+ debug.log('PULL', 'Step 4a: Downloading code entities...');
145
+ debug.log('PULL', `Queries: ${queries.map(q => q.query).join(' | ')}`);
138
146
 
139
147
  let activeClassResult, activePageResult;
140
148
  const codeDownloadErrors = [];
@@ -185,6 +193,7 @@ export const pull = async () => {
185
193
  let assets;
186
194
  const assetDownloadErrors = [];
187
195
 
196
+ debug.log('PULL', 'Step 4b: Downloading static assets...');
188
197
  try {
189
198
  logger.info('Starting static asset downloads');
190
199
  assets = await downloadAssets(instanceUrl, token.value, null, (current, total, message) => {
@@ -338,7 +347,7 @@ export const pull = async () => {
338
347
 
339
348
  // If this asset was in base.json but not returned from server, it was deleted
340
349
  if (!serverAssetPaths.has(cachedPath)) {
341
- assetsToDelete.push(cachedPath);
350
+ assetsToDelete.push({ path: cachedPath, baseKey: cached._baseKey });
342
351
  }
343
352
 
344
353
  processedCount++;
@@ -402,33 +411,35 @@ export const pull = async () => {
402
411
  // Delete local files/folders that were deleted on server
403
412
  logger.info(`Starting deletion of ${assetsToDelete.length} assets`);
404
413
  const deletionLogs = [];
405
- for (const assetPath of assetsToDelete) {
414
+ // Track base.json keys for entries that need removal
415
+ const assetBaseKeysToRemove = [];
416
+ for (const asset of assetsToDelete) {
406
417
  try {
407
- if (fs.existsSync(assetPath)) {
408
- const stats = fs.statSync(assetPath);
418
+ if (fs.existsSync(asset.path)) {
419
+ const stats = fs.statSync(asset.path);
409
420
  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 });
421
+ fs.rmSync(asset.path, { recursive: true, force: true });
422
+ deletionLogs.push({ type: 'folder', path: asset.path });
423
+ logger.info('Deleted folder', { path: asset.path });
413
424
  } else {
414
- fs.unlinkSync(assetPath);
415
- deletionLogs.push({ type: 'file', path: assetPath });
416
- logger.info('Deleted file', { path: assetPath });
425
+ fs.unlinkSync(asset.path);
426
+ deletionLogs.push({ type: 'file', path: asset.path });
427
+ logger.info('Deleted file', { path: asset.path });
417
428
  }
418
429
  }
430
+ // Always remove from base.json regardless of whether the local file
431
+ // existed — the server confirmed deletion, so the cache entry is stale.
432
+ assetBaseKeysToRemove.push(asset.baseKey);
419
433
  } catch (err) {
420
- deletionLogs.push({ type: 'error', path: assetPath, error: err.message });
421
- logger.error(`Failed to delete asset: ${assetPath}`, err);
434
+ deletionLogs.push({ type: 'error', path: asset.path, error: err.message });
435
+ logger.error(`Failed to delete asset: ${asset.path}`, err);
422
436
  }
423
437
  }
424
438
 
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);
439
+ // Bulk remove from base.json using the original base.json keys
440
+ // (not the normalized paths, which may have a different format)
441
+ if (assetBaseKeysToRemove.length > 0) {
442
+ removeFromBaseBulk(assetBaseKeysToRemove);
432
443
  }
433
444
 
434
445
  // Delete local code entity files that were deleted on server
@@ -637,8 +648,14 @@ export const pull = async () => {
637
648
  // Step 7: Finish progress tracker
638
649
  logger.info('Pull completed successfully');
639
650
  logger.close();
651
+ debug.log('PULL', 'Pull completed successfully');
652
+ debug.close();
640
653
  progress.finish('Pull completed successfully!');
641
654
 
655
+ // Clear any incomplete-pull marker (set by vue-run-build when a pull is
656
+ // started but cancelled/fails). A successful pull means we're fully synced.
657
+ config.removeKey('pullIncomplete', { filename: 'config.json' });
658
+
642
659
  // Summary
643
660
  console.log(chalk.bold(`Summary:`));
644
661
  console.log(` • ${activeClassResult.Records.length} ActiveClass records`);
@@ -654,6 +671,8 @@ export const pull = async () => {
654
671
  } catch (error) {
655
672
  logger.error('Pull operation failed', error);
656
673
  logger.close();
674
+ debug.log('PULL', `Pull failed: ${error.message}`, error.stack);
675
+ debug.close();
657
676
  progress.abort(error.message);
658
677
 
659
678
  // Display log summary even on failure
package/bin/magentrix.js CHANGED
@@ -21,9 +21,17 @@ import { irisLink, irisDev, irisDelete, irisRecover, vueBuildStage } from '../ac
21
21
  import Config from '../utils/config.js';
22
22
  import { registerWorkspace, getRegisteredWorkspaces } from '../utils/workspaces.js';
23
23
  import { ensureVSCodeFileAssociation } from '../utils/preferences.js';
24
+ import debug from '../utils/debug.js';
24
25
 
25
26
  const config = new Config();
26
27
 
28
+ // ── Debug Mode ───────────────────────────────
29
+ // Check early (before Commander parses) so debug logging covers the full lifecycle
30
+ if (process.argv.includes('--debug') || process.env.DEBUG === 'true') {
31
+ debug.enable();
32
+ debug.env();
33
+ }
34
+
27
35
  // ── Vue Project Detection ────────────────────────────────
28
36
  /**
29
37
  * Check if current directory is a Vue project (has config.ts)
@@ -134,6 +142,7 @@ program
134
142
  .name('magentrix')
135
143
  .description('Manage Magentrix assets and automation')
136
144
  .version(VERSION)
145
+ .option('--debug', 'Enable debug logging')
137
146
  .configureHelp({
138
147
  formatHelp: (_cmd, _helper) => {
139
148
  const divider = chalk.gray('━'.repeat(60));
@@ -173,6 +182,7 @@ program
173
182
 
174
183
  help += `\n${chalk.bold.yellow('OPTIONS')}\n`;
175
184
  help += ` ${chalk.cyan('-V, --version')} ${chalk.dim('Output the version number')}\n`;
185
+ help += ` ${chalk.cyan('--debug')} ${chalk.dim('Enable debug logging to .magentrix/logs/')}\n`;
176
186
  help += ` ${chalk.cyan('-h, --help')} ${chalk.dim('Display this help message')}\n`;
177
187
 
178
188
  help += `\n${chalk.bold.yellow('EXAMPLES')}\n`;
@@ -365,11 +375,15 @@ function handleFatal(err) {
365
375
  console.error(`\n${divider}\n${header}`);
366
376
  console.error(`${chalk.redBright(err?.message || 'An unexpected error occurred.')}\n`);
367
377
 
368
- if (process.env.DEBUG === 'true' && err?.stack) {
369
- console.error(chalk.dim(err.stack));
370
- console.error();
378
+ if (debug.enabled) {
379
+ debug.log('FATAL', err?.message, err?.stack);
380
+ debug.close();
381
+ if (err?.stack) {
382
+ console.error(chalk.dim(err.stack));
383
+ console.error();
384
+ }
371
385
  } else {
372
- console.log(`${chalk.yellow('💡 Run with')} ${chalk.cyan('DEBUG=true')} ${chalk.yellow('for full details.')}`);
386
+ console.log(`${chalk.yellow('💡 Run with')} ${chalk.cyan('--debug')} ${chalk.yellow('for full details.')}`);
373
387
  }
374
388
 
375
389
  console.log(divider + '\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magentrix-corp/magentrix-cli",
3
- "version": "1.3.12",
3
+ "version": "1.3.14",
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
@@ -8,6 +8,9 @@ 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
@@ -179,6 +182,11 @@ export async function walkFiles(dir, settings) {
179
182
  for (const entry of entries) {
180
183
  const fullPath = path.join(dir, entry.name);
181
184
 
185
+ // Skip OS-generated system files (.DS_Store, Thumbs.db, etc.)
186
+ if (IGNORED_FILES.has(entry.name)) {
187
+ continue;
188
+ }
189
+
182
190
  // Check if this path should be ignored
183
191
  if (ignore.find(p => fullPath.startsWith(p) || fullPath === p)) {
184
192
  continue;
@@ -2,6 +2,7 @@ import Config from "../../config.js";
2
2
  import { setup } from "../../../actions/setup.js";
3
3
  import { HASHED_CWD } from "../../../vars/global.js";
4
4
  import { tryAuthenticate } from "../../magentrix/api/auth.js";
5
+ import debug from '../../debug.js';
5
6
 
6
7
  /**
7
8
  * Returns true if the token is present and not expired (60 seconds buffer).
@@ -31,16 +32,22 @@ export async function ensureValidCredentials() {
31
32
  const instanceUrl = config.read('instanceUrl', { global: true, pathHash: HASHED_CWD });
32
33
  const token = config.read('token', { global: true, pathHash: HASHED_CWD });
33
34
 
35
+ debug.auth(`Credential check: apiKey=${apiKey ? 'present' : 'missing'}, instanceUrl=${instanceUrl || '(missing)'}`);
36
+
34
37
  // If missing API key or URL, prompt/setup immediately
35
38
  if (!apiKey || !instanceUrl) {
39
+ debug.auth('Missing credentials, falling back to setup wizard');
36
40
  return setup();
37
41
  }
38
42
 
39
43
  // If token is present and valid, return immediately
40
44
  if (isTokenValid(token)) {
45
+ debug.auth(`Existing token is valid (expires: ${token.validUntil})`);
41
46
  return { apiKey, instanceUrl, token };
42
47
  }
43
48
 
49
+ debug.auth(`Token ${token ? `expired (was valid until: ${token.validUntil})` : 'missing'}, refreshing...`);
50
+
44
51
  // If we have API key & URL but no valid token, try to refresh
45
52
  try {
46
53
  const result = await tryAuthenticate(apiKey, instanceUrl);
@@ -54,6 +61,7 @@ export async function ensureValidCredentials() {
54
61
  config.save('token', newToken, { global: true, pathHash: HASHED_CWD });
55
62
  return { apiKey, instanceUrl, token: newToken };
56
63
  } catch (err) {
64
+ debug.auth(`Token refresh failed: ${err.message}, falling back to setup wizard`);
57
65
  // Failed to refresh, fall back to prompting the user
58
66
  return setup();
59
67
  }
package/utils/debug.js ADDED
@@ -0,0 +1,144 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { VERSION } from '../vars/config.js';
5
+ import { CWD, HASHED_CWD } from '../vars/global.js';
6
+ import Config from './config.js';
7
+
8
+ /**
9
+ * Masks a sensitive string, showing only the first 4 and last 4 characters.
10
+ * @param {string} value
11
+ * @returns {string}
12
+ */
13
+ function mask(value) {
14
+ if (!value || typeof value !== 'string') return '(empty)';
15
+ if (value.length <= 12) return '****';
16
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
17
+ }
18
+
19
+ /**
20
+ * Centralized debug logger singleton.
21
+ * When enabled, writes timestamped lines to .magentrix/logs/debug-<timestamp>.log.
22
+ * All methods are no-ops when debug.enabled === false.
23
+ */
24
+ const debug = {
25
+ enabled: false,
26
+ _logFile: null,
27
+ _startTime: null,
28
+
29
+ /**
30
+ * Enable debug mode and open the log file.
31
+ */
32
+ enable() {
33
+ this.enabled = true;
34
+ this._startTime = Date.now();
35
+
36
+ const logsDir = path.join(CWD, '.magentrix', 'logs');
37
+ fs.mkdirSync(logsDir, { recursive: true, mode: 0o700 });
38
+
39
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
40
+ this._logFile = path.join(logsDir, `debug-${timestamp}.log`);
41
+
42
+ // Write header
43
+ fs.writeFileSync(this._logFile, `# MagentrixCLI Debug Log\n# Started: ${new Date().toISOString()}\n\n`, { mode: 0o600 });
44
+ console.log(`Debug log: ${path.relative(CWD, this._logFile)}`);
45
+ },
46
+
47
+ /**
48
+ * Write a timestamped line to the debug log.
49
+ * @param {string} label
50
+ * @param {...any} args
51
+ */
52
+ log(label, ...args) {
53
+ if (!this.enabled || !this._logFile) return;
54
+ const elapsed = Date.now() - this._startTime;
55
+ const ts = `[+${String(elapsed).padStart(6)}ms]`;
56
+ const detail = args.length
57
+ ? ' ' + args.map(a => (typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a))).join(' ')
58
+ : '';
59
+ fs.appendFileSync(this._logFile, `${ts} [${label}]${detail}\n`);
60
+ },
61
+
62
+ /**
63
+ * Log an outgoing HTTP request (sanitizes auth tokens/API keys).
64
+ */
65
+ request(method, url, headers, body) {
66
+ if (!this.enabled) return;
67
+ const safeHeaders = { ...headers };
68
+ if (safeHeaders.Authorization) {
69
+ safeHeaders.Authorization = `Bearer ${mask(safeHeaders.Authorization.replace('Bearer ', ''))}`;
70
+ }
71
+ this.log('HTTP-REQ', `${method} ${url}`);
72
+ this.log('HTTP-REQ', 'Headers:', JSON.stringify(safeHeaders, null, 2));
73
+ if (body !== undefined && body !== null) {
74
+ let bodyStr = typeof body === 'string' ? body : JSON.stringify(body, null, 2);
75
+ // Mask sensitive fields in body
76
+ bodyStr = bodyStr.replace(/"refresh_token"\s*:\s*"([^"]+)"/g, (_, val) => `"refresh_token": "${mask(val)}"`);
77
+ bodyStr = bodyStr.replace(/"apiKey"\s*:\s*"([^"]+)"/g, (_, val) => `"apiKey": "${mask(val)}"`);
78
+ this.log('HTTP-REQ', 'Body:', bodyStr);
79
+ }
80
+ },
81
+
82
+ /**
83
+ * Log an HTTP response including status, headers, and body.
84
+ */
85
+ response(status, statusText, headers, body) {
86
+ if (!this.enabled) return;
87
+ this.log('HTTP-RES', `${status} ${statusText}`);
88
+ if (headers) {
89
+ const headerObj = {};
90
+ if (typeof headers.forEach === 'function') {
91
+ headers.forEach((value, key) => { headerObj[key] = value; });
92
+ } else if (typeof headers === 'object') {
93
+ Object.assign(headerObj, headers);
94
+ }
95
+ this.log('HTTP-RES', 'Headers:', JSON.stringify(headerObj, null, 2));
96
+ }
97
+ if (body !== undefined && body !== null) {
98
+ let bodyStr = typeof body === 'string' ? body : JSON.stringify(body, null, 2);
99
+ // Mask tokens in response
100
+ bodyStr = bodyStr.replace(/"token"\s*:\s*"([^"]+)"/g, (_, val) => `"token": "${mask(val)}"`);
101
+ this.log('HTTP-RES', 'Body:', bodyStr);
102
+ }
103
+ },
104
+
105
+ /**
106
+ * Log environment information.
107
+ */
108
+ env() {
109
+ if (!this.enabled) return;
110
+ const config = new Config();
111
+ const instanceUrl = config.read('instanceUrl', { global: true, pathHash: HASHED_CWD }) || '(not set)';
112
+
113
+ this.log('ENV', 'Node version:', process.version);
114
+ this.log('ENV', 'OS:', `${os.type()} ${os.release()} (${os.arch()})`);
115
+ this.log('ENV', 'CLI version:', VERSION);
116
+ this.log('ENV', 'CWD:', CWD);
117
+ this.log('ENV', 'Hashed CWD:', HASHED_CWD);
118
+ this.log('ENV', 'Instance URL:', instanceUrl);
119
+ },
120
+
121
+ /**
122
+ * Auth-specific debug logging.
123
+ */
124
+ auth(message, details) {
125
+ if (!this.enabled) return;
126
+ if (details) {
127
+ this.log('AUTH', message, details);
128
+ } else {
129
+ this.log('AUTH', message);
130
+ }
131
+ },
132
+
133
+ /**
134
+ * Finalize the debug log and print its path.
135
+ */
136
+ close() {
137
+ if (!this.enabled || !this._logFile) return;
138
+ const elapsed = Date.now() - this._startTime;
139
+ this.log('END', `Debug session ended. Total time: ${elapsed}ms`);
140
+ console.log(`\nDebug log saved: ${path.relative(CWD, this._logFile)}`);
141
+ }
142
+ };
143
+
144
+ export default debug;
@@ -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()) {
@@ -1,5 +1,6 @@
1
1
  import { fetchMagentrix } from "../fetch.js";
2
2
  import chalk from "chalk";
3
+ import debug from '../../debug.js';
3
4
 
4
5
  /**
5
6
  * Authenticates with Magentrix and retrieves an access token using the API key as a refresh token.
@@ -11,6 +12,7 @@ import chalk from "chalk";
11
12
  * @returns {Promise<string>} Resolves to the Magentrix access token string.
12
13
  */
13
14
  export const getAccessToken = async (apiKey, instanceUrl) => {
15
+ debug.auth(`Requesting access token from ${instanceUrl}/api/3.0/token`);
14
16
  try {
15
17
  const data = await fetchMagentrix({
16
18
  instanceUrl,
@@ -22,12 +24,14 @@ export const getAccessToken = async (apiKey, instanceUrl) => {
22
24
  method: "POST"
23
25
  })
24
26
 
27
+ debug.auth('Token received successfully', { validUntil: data.validUntil });
25
28
  // Success
26
29
  return {
27
30
  token: data.token,
28
31
  validUntil: data.validUntil
29
32
  };
30
33
  } catch (error) {
34
+ debug.auth(`Token request failed: ${error.message}`);
31
35
  throw new Error(`Error retrieving Magentrix access token: ${error.message}`);
32
36
  }
33
37
  };
@@ -52,35 +56,43 @@ export const tryAuthenticate = async (apiKey, instanceUrl) => {
52
56
  let formattedMessage = '\n' + chalk.red.bold('✖ Authentication Failed') + '\n';
53
57
  formattedMessage += chalk.dim('─'.repeat(50)) + '\n\n';
54
58
 
59
+ debug.auth(`Authentication failed, categorizing error: ${errorMessage.substring(0, 100)}`);
60
+
55
61
  if (errorMessage.includes('Network error')) {
62
+ debug.auth('Error category: Network error (unable to reach instance)');
56
63
  formattedMessage += chalk.cyan.bold('🌐 Unable to reach the Magentrix instance') + '\n\n';
57
64
  formattedMessage += chalk.yellow(' Possible causes:') + '\n';
58
65
  formattedMessage += chalk.gray(' • Check your internet connection') + '\n';
59
66
  formattedMessage += chalk.gray(' • Verify the instance URL is correct') + '\n';
60
67
  formattedMessage += chalk.gray(' • Ensure the server is online and accessible') + '\n';
61
68
  } else if (errorMessage.includes('HTTP 401') || errorMessage.includes('HTTP 403') || errorMessage.includes('Unauthorized')) {
69
+ debug.auth('Error category: Invalid API key (401/403)');
62
70
  formattedMessage += chalk.cyan.bold('🔑 Invalid API Key') + '\n\n';
63
71
  formattedMessage += chalk.yellow(' What to do:') + '\n';
64
72
  formattedMessage += chalk.gray(' • The API key you entered is incorrect') + '\n';
65
73
  formattedMessage += chalk.gray(' • Verify your API key from the Magentrix admin panel') + '\n';
66
74
  } else if (errorMessage.includes('HTTP 404')) {
75
+ debug.auth('Error category: Invalid instance URL (404)');
67
76
  formattedMessage += chalk.cyan.bold('🔍 Invalid Magentrix Instance URL') + '\n\n';
68
77
  formattedMessage += chalk.yellow(' What to do:') + '\n';
69
78
  formattedMessage += chalk.gray(' • The URL does not appear to be a valid Magentrix server') + '\n';
70
79
  formattedMessage += chalk.gray(' • Verify the URL matches your Magentrix instance') + '\n';
71
80
  } else if (errorMessage.includes('HTTP 5')) {
81
+ debug.auth('Error category: Server error (5xx)');
72
82
  formattedMessage += chalk.cyan.bold('⚠️ Magentrix Server Error') + '\n\n';
73
83
  formattedMessage += chalk.yellow(' What to do:') + '\n';
74
84
  formattedMessage += chalk.gray(' • The server is experiencing issues') + '\n';
75
85
  formattedMessage += chalk.gray(' • Please try again in a few moments') + '\n';
76
86
  formattedMessage += chalk.gray(' • Contact support if the issue persists') + '\n';
77
87
  } else if (errorMessage.includes('timeout') || errorMessage.includes('ETIMEDOUT')) {
88
+ debug.auth('Error category: Connection timeout');
78
89
  formattedMessage += chalk.cyan.bold('⏱️ Connection Timeout') + '\n\n';
79
90
  formattedMessage += chalk.yellow(' What to do:') + '\n';
80
91
  formattedMessage += chalk.gray(' • The server took too long to respond') + '\n';
81
92
  formattedMessage += chalk.gray(' • Check your internet connection') + '\n';
82
93
  formattedMessage += chalk.gray(' • Try again in a moment') + '\n';
83
94
  } else {
95
+ debug.auth('Error category: Unknown error');
84
96
  formattedMessage += chalk.cyan.bold('❓ Unable to Authenticate') + '\n\n';
85
97
  formattedMessage += chalk.yellow(' What to do:') + '\n';
86
98
  formattedMessage += chalk.gray(' • Verify both your API key and instance URL are correct') + '\n';
@@ -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);
@@ -1,4 +1,5 @@
1
1
  import { fetchMagentrix } from "../fetch.js";
2
+ import debug from '../../debug.js';
2
3
 
3
4
  /**
4
5
  * Executes a Magentrix Entity Query Language (MEQL) query via the REST API v3.
@@ -18,6 +19,8 @@ export const meqlQuery = async (instanceUrl, token, query = '') => {
18
19
  throw new Error('MEQL query string is required');
19
20
  }
20
21
 
22
+ debug.log('MEQL', `Query: ${query}`);
23
+
21
24
  const data = await fetchMagentrix({
22
25
  instanceUrl,
23
26
  token,
@@ -26,6 +29,8 @@ export const meqlQuery = async (instanceUrl, token, query = '') => {
26
29
  body: query
27
30
  })
28
31
 
32
+ debug.log('MEQL', `Result: ${data?.Records?.length ?? 0} records returned`);
33
+
29
34
  // --- Success ---
30
35
  return data;
31
36
  };
@@ -1,3 +1,5 @@
1
+ import debug from '../debug.js';
2
+
1
3
  /**
2
4
  * Checks if a request body should be JSON-stringified.
3
5
  * Excludes FormData, Blob, ArrayBuffer, URLSearchParams, and typed arrays.
@@ -71,14 +73,18 @@ export const fetchMagentrix = async ({
71
73
  }
72
74
  if (!finalHeaders['Content-Type'] && !ignoreContentType) finalHeaders['Content-Type'] = 'application/json';
73
75
 
76
+ const fullUrl = `${instanceUrl.replace(/\/$/, '')}${path}`;
77
+ debug.request(method, fullUrl, finalHeaders, body);
78
+
74
79
  let response, responseData;
75
80
  try {
76
- response = await fetch(`${instanceUrl.replace(/\/$/, '')}${path}`, {
81
+ response = await fetch(fullUrl, {
77
82
  method,
78
83
  headers: finalHeaders,
79
84
  body: requestBody
80
85
  });
81
86
  } catch (err) {
87
+ debug.log('HTTP-ERR', `Network error: ${err.message}`, err.stack);
82
88
  const errorObj = {
83
89
  type: 'network',
84
90
  message: `Network error contacting Magentrix API: ${err.message}`,
@@ -94,6 +100,8 @@ export const fetchMagentrix = async ({
94
100
  responseData = null;
95
101
  }
96
102
 
103
+ debug.response(response.status, response.statusText, response.headers, responseData);
104
+
97
105
  if (!response.ok) {
98
106
  const errorObj = {
99
107
  type: 'http',
@@ -117,6 +125,7 @@ export const fetchMagentrix = async ({
117
125
  }
118
126
  if (errorConfig?.includeURL) msg += `\nURL: ${response.url}`;
119
127
  errorObj.message = msg;
128
+ debug.log('HTTP-ERR', `HTTP ${response.status}: ${msg}`);
120
129
  if (returnErrorObject) throw errorObj;
121
130
  throw new Error(msg);
122
131
  }
@@ -146,6 +155,7 @@ export const fetchMagentrix = async ({
146
155
  details = String(responseData);
147
156
  }
148
157
  errorObj.message = `Magentrix API error:\n${details}`;
158
+ debug.log('API-ERR', errorObj.message);
149
159
  if (returnErrorObject) throw errorObj;
150
160
  throw new Error(errorObj.message);
151
161
  }