@magentrix-corp/magentrix-cli 1.2.1 → 1.3.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.
@@ -11,6 +11,7 @@ import {
11
11
  ENTITY_TYPE_MAP,
12
12
  EXPORT_ROOT,
13
13
  TYPE_DIR_MAP,
14
+ IRIS_APPS_DIR,
14
15
  } from "../vars/global.js";
15
16
  import { getFileTag, setFileTag } from "../utils/filetag.js";
16
17
  import { sha256 } from "../utils/hash.js";
@@ -20,6 +21,11 @@ import { deleteEntity } from "../utils/magentrix/api/deleteEntity.js";
20
21
  import { removeFromBase, updateBase } from "../utils/updateFileBase.js";
21
22
  import { deleteAsset, uploadAsset, createFolder } from "../utils/magentrix/api/assets.js";
22
23
  import { toApiPath, toApiFolderPath } from "../utils/assetPaths.js";
24
+ import { publishApp } from "../utils/magentrix/api/iris.js";
25
+ import { createIrisZip, hashIrisAppFolder } from "../utils/iris/zipper.js";
26
+ import { validateIrisAppFolder } from "../utils/iris/validator.js";
27
+ import { getLinkedProjects } from "../utils/iris/linker.js";
28
+ import { deleteIrisAppFromServer } from "../utils/iris/deleteHelper.js";
23
29
 
24
30
  const config = new Config();
25
31
 
@@ -120,6 +126,8 @@ const walkFolders = (dir) => {
120
126
  * Gets display name for an action in log messages.
121
127
  */
122
128
  const getActionDisplayName = (action) => {
129
+ // Iris app actions
130
+ if (action.slug) return `${action.appName || action.slug} (${action.slug})`;
123
131
  if (action.folderName) return action.folderName;
124
132
  if (action.names) return action.names.join(", ");
125
133
  if (action.type) return action.type;
@@ -273,6 +281,35 @@ const handleDeleteFolderAction = async (instanceUrl, apiKey, action) => {
273
281
  }
274
282
  };
275
283
 
284
+ /**
285
+ * Handles CREATE_IRIS_APP or UPDATE_IRIS_APP action.
286
+ */
287
+ const handlePublishIrisAppAction = async (instanceUrl, apiKey, action) => {
288
+ // Create zip from the app folder
289
+ const zipBuffer = await createIrisZip(action.appPath, action.slug);
290
+
291
+ // Publish via API with app-name parameter
292
+ const response = await publishApp(
293
+ instanceUrl,
294
+ apiKey,
295
+ zipBuffer,
296
+ `${action.slug}.zip`,
297
+ action.appName
298
+ );
299
+
300
+ return response;
301
+ };
302
+
303
+ /**
304
+ * Handles DELETE_IRIS_APP action.
305
+ */
306
+ const handleDeleteIrisAppAction = async (instanceUrl, apiKey, action) => {
307
+ // Use shared delete utility for consistency
308
+ return await deleteIrisAppFromServer(instanceUrl, apiKey, action.slug, {
309
+ updateCache: true // Cache will be updated by the utility
310
+ });
311
+ };
312
+
276
313
  /**
277
314
  * Synchronizes class name in content with filename, or vice versa.
278
315
  */
@@ -425,6 +462,28 @@ const updateCacheAfterSuccess = async (action, operationResult) => {
425
462
  }
426
463
  break;
427
464
  }
465
+
466
+ case "create_iris_app":
467
+ case "update_iris_app": {
468
+ // Update base.json with new/updated Iris app info including content hash
469
+ updateBase(action.appPath, {
470
+ Id: `iris-app:${action.slug}`,
471
+ Type: 'IrisApp',
472
+ folderName: action.slug,
473
+ appName: action.appName,
474
+ modifiedOn: new Date().toISOString(),
475
+ contentHash: action.contentHash // Store hash for change detection
476
+ }, action.appPath);
477
+ break;
478
+ }
479
+
480
+ case "delete_iris_app": {
481
+ // Skip if already cleaned from cache during 404 handling
482
+ if (!operationResult?.cleanedFromCache) {
483
+ removeFromBase(`iris-app:${action.slug}`);
484
+ }
485
+ break;
486
+ }
428
487
  }
429
488
  } catch (error) {
430
489
  console.warn(chalk.yellow(`Warning: Failed to update cache for ${action.action}: ${error.message}`));
@@ -444,8 +503,8 @@ const groupActionsByResource = (actionQueue) => {
444
503
  for (let i = 0; i < actionQueue.length; i++) {
445
504
  const action = { ...actionQueue[i], originalIndex: i };
446
505
 
447
- // Asset operations don't need sequencing
448
- if (['create_static_asset', 'delete_static_asset', 'create_folder', 'delete_folder'].includes(action.action)) {
506
+ // Asset and Iris app operations don't need sequencing
507
+ if (['create_static_asset', 'delete_static_asset', 'create_folder', 'delete_folder', 'create_iris_app', 'update_iris_app', 'delete_iris_app'].includes(action.action)) {
449
508
  assets.push(action);
450
509
  continue;
451
510
  }
@@ -544,6 +603,13 @@ const executeAction = async (instanceUrl, token, action) => {
544
603
  case "delete_folder":
545
604
  result = await handleDeleteFolderAction(instanceUrl, token, action);
546
605
  break;
606
+ case "create_iris_app":
607
+ case "update_iris_app":
608
+ result = await handlePublishIrisAppAction(instanceUrl, token, action);
609
+ break;
610
+ case "delete_iris_app":
611
+ result = await handleDeleteIrisAppAction(instanceUrl, token, action);
612
+ break;
547
613
  default:
548
614
  throw new Error(`Unknown action: ${action.action}`);
549
615
  }
@@ -587,7 +653,8 @@ const performNetworkRequestSequential = async (actionQueue) => {
587
653
  console.log();
588
654
  console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
589
655
  console.log(chalk.redBright('─'.repeat(48)));
590
- console.log(chalk.red.bold(`[${i + 1}] ${action.action.toUpperCase()} ${displayName} (${action.filePath || action.folderPath || action.folder}):`));
656
+ const actionPath = action.filePath || action.folderPath || action.folder || action.appPath || action.slug || 'unknown';
657
+ console.log(chalk.red.bold(`[${i + 1}] ${action.action.toUpperCase()} ${displayName} (${actionPath}):`));
591
658
  console.log(formatMultilineError(error.message));
592
659
  console.log(chalk.redBright('─'.repeat(48)));
593
660
  }
@@ -693,7 +760,8 @@ const performNetworkRequestParallel = async (actionQueue) => {
693
760
  console.log();
694
761
  console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
695
762
  console.log(chalk.redBright('─'.repeat(48)));
696
- console.log(chalk.red.bold(`[${index + 1}] ${action.action.toUpperCase()} ${getActionDisplayName(action)} (${action.filePath || action.folderPath || action.folder}):`));
763
+ const actionPath = action.filePath || action.folderPath || action.folder || action.appPath || action.slug || 'unknown';
764
+ console.log(chalk.red.bold(`[${index + 1}] ${action.action.toUpperCase()} ${getActionDisplayName(action)} (${actionPath}):`));
697
765
  console.log(formatMultilineError(error));
698
766
  console.log(chalk.redBright('─'.repeat(48)));
699
767
  }
@@ -790,6 +858,7 @@ export const runPublish = async (options = {}) => {
790
858
  ...c,
791
859
  tag: c.recordId,
792
860
  filePath: c.filePath || c.lastKnownPath,
861
+ type: c.type || c.Type, // Normalize Type/type property
793
862
  }));
794
863
  const mapTime = Date.now() - mapStart;
795
864
 
@@ -797,11 +866,16 @@ export const runPublish = async (options = {}) => {
797
866
  progress.completeStep('load', `✓ Loaded ${cachedFiles.length} entries (${loadTime}ms load, ${mapTime}ms map)`);
798
867
  }
799
868
 
800
- // Step 3: Scan local workspace (excluding Assets folder)
869
+ // Step 3: Scan local workspace (excluding Assets and iris-apps folders)
801
870
  if (progress) progress.startStep('scan');
802
871
 
803
872
  const walkStart = Date.now();
804
- const localPaths = await walkFiles(EXPORT_ROOT, { ignore: [path.join(EXPORT_ROOT, 'Assets')] });
873
+ const localPaths = await walkFiles(EXPORT_ROOT, {
874
+ ignore: [
875
+ path.join(EXPORT_ROOT, 'Assets'),
876
+ path.join(EXPORT_ROOT, IRIS_APPS_DIR)
877
+ ]
878
+ });
805
879
  const walkTime = Date.now() - walkStart;
806
880
 
807
881
  const tagStart = Date.now();
@@ -971,6 +1045,9 @@ export const runPublish = async (options = {}) => {
971
1045
  // Skip folders - they're handled separately
972
1046
  if (cacheFile?.type === 'Folder') continue;
973
1047
 
1048
+ // Skip Iris apps - they're handled separately (check both type and ID prefix)
1049
+ if (cacheFile?.type === 'IrisApp' || id.startsWith('iris-app:')) continue;
1050
+
974
1051
  // Handle static asset files
975
1052
  if (cacheFile?.type === 'File') {
976
1053
  // Use lastKnownActualPath which has the correct path (e.g., "src/Assets/...")
@@ -1066,6 +1143,94 @@ export const runPublish = async (options = {}) => {
1066
1143
  progress.completeStep('compare-code', `✓ Compared ${allIdsArray.length} code entities`);
1067
1144
  }
1068
1145
 
1146
+ // Step 7b: Scan and compare Iris apps
1147
+ const irisAppsPath = path.join(EXPORT_ROOT, IRIS_APPS_DIR);
1148
+
1149
+ // Get cached Iris apps (always check, even if local folder doesn't exist)
1150
+ // Also include entries with iris-app: prefix in case type wasn't properly set
1151
+ const cachedIrisApps = cachedFiles
1152
+ .filter(cf => cf.type === 'IrisApp' || (cf.tag && cf.tag.startsWith('iris-app:')))
1153
+ .map(cf => {
1154
+ // Extract slug from folderName, or from the ID (iris-app:<slug>)
1155
+ const slug = cf.folderName || (cf.tag && cf.tag.startsWith('iris-app:') ? cf.tag.replace('iris-app:', '') : null);
1156
+ return {
1157
+ slug,
1158
+ appName: cf.appName || slug,
1159
+ modifiedOn: cf.modifiedOn,
1160
+ contentHash: cf.contentHash || null // Track content hash for change detection
1161
+ };
1162
+ })
1163
+ .filter(app => app.slug); // Filter out any entries without a valid slug
1164
+
1165
+ // Get local Iris apps (empty if folder doesn't exist)
1166
+ const localIrisApps = fs.existsSync(irisAppsPath)
1167
+ ? fs.readdirSync(irisAppsPath, { withFileTypes: true })
1168
+ .filter(d => d.isDirectory())
1169
+ .map(d => d.name)
1170
+ : [];
1171
+
1172
+ const cachedIrisSlugs = new Set(cachedIrisApps.map(a => a.slug));
1173
+ const localIrisSlugs = new Set(localIrisApps);
1174
+
1175
+ // Detect new and modified Iris apps
1176
+ for (const slug of localIrisApps) {
1177
+ const appPath = path.join(irisAppsPath, slug);
1178
+
1179
+ // Validate the app folder has required files
1180
+ const validation = validateIrisAppFolder(appPath);
1181
+ if (!validation.valid) {
1182
+ if (!silent) {
1183
+ console.log(chalk.yellow(`⚠ Skipping invalid Iris app '${slug}': ${validation.errors.join(', ')}`));
1184
+ }
1185
+ continue;
1186
+ }
1187
+
1188
+ // Get app name from linked project config (stored globally) or use slug
1189
+ const linkedProjects = getLinkedProjects();
1190
+ const linkedProject = linkedProjects.find(p => p.slug === slug);
1191
+ const appName = linkedProject?.appName || slug;
1192
+
1193
+ // Calculate content hash for change detection
1194
+ const currentHash = hashIrisAppFolder(appPath);
1195
+
1196
+ // Check if this is a new or existing app
1197
+ const isNew = !cachedIrisSlugs.has(slug);
1198
+
1199
+ if (isNew) {
1200
+ actionQueue.push({
1201
+ action: 'create_iris_app',
1202
+ slug,
1203
+ appName,
1204
+ appPath,
1205
+ contentHash: currentHash
1206
+ });
1207
+ } else {
1208
+ // Only update if content has changed
1209
+ const cachedApp = cachedIrisApps.find(a => a.slug === slug);
1210
+ const contentChanged = !cachedApp?.contentHash || cachedApp.contentHash !== currentHash;
1211
+
1212
+ if (contentChanged) {
1213
+ actionQueue.push({
1214
+ action: 'update_iris_app',
1215
+ slug,
1216
+ appName: linkedProject?.appName || cachedApp?.appName || slug,
1217
+ appPath,
1218
+ contentHash: currentHash
1219
+ });
1220
+ }
1221
+ }
1222
+ }
1223
+
1224
+ // Detect deleted Iris apps (works even if iris-apps folder doesn't exist)
1225
+ for (const cachedApp of cachedIrisApps) {
1226
+ if (!localIrisSlugs.has(cachedApp.slug)) {
1227
+ actionQueue.push({
1228
+ action: 'delete_iris_app',
1229
+ slug: cachedApp.slug
1230
+ });
1231
+ }
1232
+ }
1233
+
1069
1234
  // Step 8: Handle brand-new, tag-less files
1070
1235
  if (progress) progress.startStep('prepare');
1071
1236
 
@@ -1146,19 +1311,28 @@ export const runPublish = async (options = {}) => {
1146
1311
  const num = chalk.green(`[${i + 1}]`);
1147
1312
  const act = chalk.yellow(a.action.toUpperCase());
1148
1313
 
1149
- let type, displayPath;
1150
- if (a.folderName) {
1314
+ let type, displayPath, label;
1315
+ if (a.slug) {
1316
+ // Iris app action
1317
+ type = chalk.cyan(`${a.appName || a.slug}`);
1318
+ displayPath = a.appPath || a.slug;
1319
+ label = "App";
1320
+ } else if (a.folderName) {
1151
1321
  type = chalk.cyan(a.folderName);
1152
1322
  displayPath = a.folderPath;
1323
+ label = "Folder";
1153
1324
  } else if (a.names) {
1154
1325
  type = chalk.cyan(a.names.join(", "));
1155
1326
  displayPath = a.folder;
1327
+ label = "File";
1156
1328
  } else if (a.filePath) {
1157
1329
  type = chalk.cyan(a.type || path.basename(a.filePath));
1158
1330
  displayPath = a.filePath;
1331
+ label = a.type ? "Type" : "File";
1159
1332
  } else {
1160
1333
  type = chalk.cyan(a.type || "Unknown");
1161
1334
  displayPath = a.folder || "Unknown";
1335
+ label = "File";
1162
1336
  }
1163
1337
 
1164
1338
  const idInfo = a.recordId ? ` ${chalk.magenta(a.recordId)}` : "";
@@ -1166,7 +1340,7 @@ export const runPublish = async (options = {}) => {
1166
1340
  ? ` → ${chalk.gray(a.oldPath)} ${chalk.white("→")} ${chalk.gray(a.filePath)}`
1167
1341
  : "";
1168
1342
 
1169
- console.log(`${num} ${act} | ${a.type ? "Type" : (a.folderName ? "Folder" : "File")}: ${type}${idInfo}${renameInfo} (${displayPath})`);
1343
+ console.log(`${num} ${act} | ${label}: ${type}${idInfo}${renameInfo} (${displayPath})`);
1170
1344
  });
1171
1345
 
1172
1346
  console.log(chalk.blue("\n--- Publishing Changes ---"));
package/actions/pull.js CHANGED
@@ -5,7 +5,7 @@ import fs from "fs";
5
5
  import { withSpinner } from "../utils/spinner.js";
6
6
  import { ProgressTracker } from "../utils/progress.js";
7
7
  import { createLogger, Logger } from "../utils/logger.js";
8
- import { EXPORT_ROOT, TYPE_DIR_MAP } from "../vars/global.js";
8
+ import { EXPORT_ROOT, TYPE_DIR_MAP, IRIS_APPS_DIR } from "../vars/global.js";
9
9
  import { mapRecordToFile, writeRecords } from "../utils/cli/writeRecords.js";
10
10
  import { updateBase, removeFromBase, removeFromBaseBulk } from "../utils/updateFileBase.js";
11
11
  import { compareAllFilesAndLogStatus, promptConflictResolution, showCurrentConflicts } from "../utils/cli/helpers/compare.js";
@@ -15,6 +15,8 @@ import chalk from 'chalk';
15
15
  import { getFileTag, setFileTag } from "../utils/filetag.js";
16
16
  import { downloadAssetsZip, listAssets } from "../utils/magentrix/api/assets.js";
17
17
  import { downloadAssets, walkAssets } from "../utils/downloadAssets.js";
18
+ import { listApps, downloadApp } from "../utils/magentrix/api/iris.js";
19
+ import { extractIrisZip, hashIrisAppFolder } from "../utils/iris/zipper.js";
18
20
  import { v4 as uuidv4 } from 'uuid';
19
21
  import readlineSync from 'readline-sync';
20
22
 
@@ -55,6 +57,7 @@ export const pull = async () => {
55
57
  progress.addStep('load', 'Loading cached data...');
56
58
  progress.addStep('download-code', 'Downloading code entities...', { hasProgress: true });
57
59
  progress.addStep('download-assets', 'Downloading static assets...', { hasProgress: true });
60
+ progress.addStep('download-iris', 'Downloading Iris apps...', { hasProgress: true });
58
61
  progress.addStep('process', 'Processing files...', { hasProgress: true });
59
62
  progress.addStep('conflicts', 'Checking for conflicts...');
60
63
  progress.addStep('write', 'Writing files...', { hasProgress: true });
@@ -213,6 +216,92 @@ export const pull = async () => {
213
216
  progress.start();
214
217
  }
215
218
 
219
+ // Step 4c: Download Iris apps
220
+ progress.startStep('download-iris');
221
+ let irisApps = [];
222
+ const irisDownloadErrors = [];
223
+
224
+ try {
225
+ logger.info('Fetching Iris apps list');
226
+ const irisListResult = await listApps(instanceUrl, token.value);
227
+
228
+ if (irisListResult.success && irisListResult.apps && irisListResult.apps.length > 0) {
229
+ irisApps = irisListResult.apps;
230
+ const irisAppsDir = path.resolve(EXPORT_ROOT, IRIS_APPS_DIR);
231
+
232
+ // Ensure iris-apps directory exists
233
+ if (!fs.existsSync(irisAppsDir)) {
234
+ fs.mkdirSync(irisAppsDir, { recursive: true });
235
+ }
236
+
237
+ logger.info(`Found ${irisApps.length} Iris apps to download`);
238
+
239
+ for (let i = 0; i < irisApps.length; i++) {
240
+ const app = irisApps[i];
241
+ progress.updateProgress('download-iris', i + 1, irisApps.length, `Downloading ${app.folderName}...`);
242
+
243
+ try {
244
+ // Download the app as a buffer
245
+ const { buffer } = await downloadApp(instanceUrl, token.value, app.folderName);
246
+
247
+ // Extract to iris-apps directory
248
+ const appDir = path.join(irisAppsDir, app.folderName);
249
+
250
+ // Remove existing app directory if it exists
251
+ if (fs.existsSync(appDir)) {
252
+ fs.rmSync(appDir, { recursive: true, force: true });
253
+ }
254
+
255
+ // Extract the zip (it contains a folder with the app name)
256
+ await extractIrisZip(buffer, irisAppsDir);
257
+
258
+ // Calculate content hash for change detection
259
+ const contentHash = hashIrisAppFolder(appDir);
260
+
261
+ // Update base.json for this Iris app
262
+ updateBase(appDir, {
263
+ Id: `iris-app:${app.folderName}`,
264
+ Type: 'IrisApp',
265
+ folderName: app.folderName,
266
+ uploadedOn: app.uploadedOn,
267
+ modifiedOn: app.modifiedOn,
268
+ size: app.size,
269
+ contentHash // Store hash for change detection
270
+ }, appDir);
271
+
272
+ logger.info(`Downloaded Iris app: ${app.folderName}`);
273
+ } catch (appError) {
274
+ // Detect permission errors and provide helpful hint
275
+ const isPermissionError = appError.code === 'EACCES' || appError.code === 'EPERM' ||
276
+ appError.message?.includes('permission denied') || appError.message?.includes('EACCES');
277
+
278
+ // Create a more informative error message
279
+ const errorDetail = appError.message || String(appError);
280
+ const errorMsg = `Failed to download Iris app ${app.folderName}: ${errorDetail}`;
281
+
282
+ if (isPermissionError) {
283
+ const hint = `Try: sudo chown -R $(whoami):staff "${irisAppsDir}"`;
284
+ logger.error(errorMsg, appError, null, hint);
285
+ irisDownloadErrors.push({ app: app.folderName, error: appError.message, isPermissionError: true, hint });
286
+ progress.addIssue('error', errorMsg, hint);
287
+ } else {
288
+ logger.error(errorMsg, appError);
289
+ irisDownloadErrors.push({ app: app.folderName, error: appError.message });
290
+ progress.addIssue('error', errorMsg);
291
+ }
292
+ }
293
+ }
294
+
295
+ progress.completeStep('download-iris', `✓ Downloaded ${irisApps.length} Iris apps`);
296
+ } else {
297
+ progress.completeStep('download-iris', '✓ No Iris apps found');
298
+ }
299
+ } catch (error) {
300
+ logger.error('Error fetching Iris apps', error);
301
+ irisDownloadErrors.push({ stage: 'iris-list', error: error.message });
302
+ progress.completeStep('download-iris', '⚠ Iris apps skipped (API may not be available)');
303
+ }
304
+
216
305
  // Check if we have any data to continue with
217
306
  if (!activeClassResult?.Records?.length && !activePageResult?.Records?.length && !assets?.tree?.length) {
218
307
  throw new Error('Download failed completely. No data available to continue.');
@@ -300,8 +389,9 @@ export const pull = async () => {
300
389
  ]);
301
390
 
302
391
  // Find code entities in base.json that are no longer on server
392
+ // Exclude Files, Folders, and IrisApps - only ActiveClass and ActivePage
303
393
  const cachedCodeEntities = Object.values(cachedResults).filter(c =>
304
- c.type !== 'File' && c.type !== 'Folder' && c.recordId
394
+ c.type !== 'File' && c.type !== 'Folder' && c.type !== 'IrisApp' && c.recordId
305
395
  );
306
396
 
307
397
  const codeEntitiesToDelete = [];
@@ -355,8 +445,18 @@ export const pull = async () => {
355
445
  logger.info('Deleted code entity', { path: entityPath, type: entity.type });
356
446
  }
357
447
  } catch (err) {
358
- deletionLogs.push({ type: 'error', path: entityPath, error: err.message });
359
- logger.error(`Failed to delete code entity: ${entityPath}`, err);
448
+ // Detect permission errors
449
+ const isPermissionError = err.code === 'EACCES' || err.code === 'EPERM';
450
+ deletionLogs.push({ type: 'error', path: entityPath, error: err.message, isPermissionError });
451
+
452
+ if (isPermissionError) {
453
+ const hint = `Try: sudo chown -R $(whoami):staff "${path.dirname(entityPath)}"`;
454
+ logger.error(`Failed to delete code entity: ${entityPath}`, err, null, hint);
455
+ progress.addIssue('error', `Failed to delete code entity: ${entityPath}`, hint);
456
+ } else {
457
+ logger.error(`Failed to delete code entity: ${entityPath}`, err);
458
+ progress.addIssue('error', `Failed to delete code entity: ${entityPath}`);
459
+ }
360
460
  }
361
461
  }
362
462
 
@@ -546,6 +646,9 @@ export const pull = async () => {
546
646
  console.log(chalk.bold(`Summary:`));
547
647
  console.log(` • ${activeClassResult.Records.length} ActiveClass records`);
548
648
  console.log(` • ${activePageResult.Records.length} ActivePage records`);
649
+ if (irisApps.length > 0) {
650
+ console.log(` • ${irisApps.length} Iris apps`);
651
+ }
549
652
  console.log(` • ${deletionLogs.length} deletions`);
550
653
  console.log(`📁 Saved to: ./${EXPORT_ROOT}/`);
551
654
 
package/bin/magentrix.js CHANGED
@@ -15,6 +15,7 @@ import { EXPORT_ROOT } from '../vars/global.js';
15
15
  import { publish } from '../actions/publish.js';
16
16
  import { update } from '../actions/update.js';
17
17
  import { configWizard } from '../actions/config.js';
18
+ import { irisLink, irisDev, irisDelete, irisRecover, vueBuildStage } from '../actions/iris/index.js';
18
19
 
19
20
  // ── Middleware ────────────────────────────────
20
21
  async function preMiddleware() {
@@ -60,7 +61,12 @@ program
60
61
  { name: 'status', desc: 'Show file conflicts and sync status', icon: '📊 ' },
61
62
  { name: 'publish', desc: 'Publish pending changes to the remote server', icon: '📤 ' },
62
63
  { name: 'autopublish', desc: 'Watch & sync changes in real time', icon: '🔄 ' },
63
- { name: 'update', desc: 'Update MagentrixCLI to the latest version', icon: '⬆️ ' }
64
+ { name: 'update', desc: 'Update MagentrixCLI to the latest version', icon: '⬆️ ' },
65
+ { name: 'iris-link', desc: 'Link a Vue project to the CLI', icon: '🔗 ' },
66
+ { name: 'vue-build-stage', desc: 'Build Vue project and stage for publish', icon: '🏗️ ' },
67
+ { name: 'iris-dev', desc: 'Start Vue dev server with platform assets', icon: '🌐 ' },
68
+ { name: 'iris-delete', desc: 'Delete an Iris app with backup', icon: '🗑️ ' },
69
+ { name: 'iris-recover', desc: 'Recover a deleted Iris app from backup', icon: '♻️ ' }
64
70
  ];
65
71
 
66
72
  const maxNameLen = Math.max(...commands.map(c => c.name.length));
@@ -191,6 +197,42 @@ program
191
197
  .description('Configure CLI settings')
192
198
  .action(configWizard);
193
199
 
200
+ // Iris commands for Vue.js app management
201
+ program
202
+ .command('iris-link')
203
+ .description('Link a Vue project to the CLI for deployment')
204
+ .option('--path <path>', 'Path to the Vue project')
205
+ .option('--unlink', 'Unlink a project instead of linking')
206
+ .option('--list', 'List all linked projects')
207
+ .option('--cleanup', 'Remove invalid (non-existent) linked projects')
208
+ .action(irisLink);
209
+
210
+ program
211
+ .command('vue-build-stage')
212
+ .description('Build a Vue project and stage it for publishing')
213
+ .option('--path <path>', 'Path to the Vue project')
214
+ .option('--skip-build', 'Skip build step and use existing dist/')
215
+ .action(vueBuildStage);
216
+
217
+ program
218
+ .command('iris-dev')
219
+ .description('Start Vue dev server with platform assets injected')
220
+ .option('--path <path>', 'Path to the Vue project')
221
+ .option('--no-inject', 'Skip asset injection')
222
+ .option('--restore', 'Restore config.ts from backup')
223
+ .action(irisDev);
224
+
225
+ program
226
+ .command('iris-delete')
227
+ .description('Delete a published Iris app with recovery backup')
228
+ .action(irisDelete);
229
+
230
+ program
231
+ .command('iris-recover')
232
+ .description('Recover a deleted Iris app from backup')
233
+ .option('--list', 'List available recovery backups')
234
+ .action(irisRecover);
235
+
194
236
  // ── Unknown Command Handler ──────────────────
195
237
  program.argument('[command]', 'command to run').action((cmd) => {
196
238
  const runMain = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magentrix-corp/magentrix-cli",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "CLI tool for synchronizing local files with Magentrix cloud platform",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -36,6 +36,7 @@
36
36
  ],
37
37
  "dependencies": {
38
38
  "@inquirer/prompts": "^7.6.0",
39
+ "archiver": "^7.0.1",
39
40
  "chalk": "^5.4.1",
40
41
  "chokidar": "^4.0.3",
41
42
  "commander": "^14.0.0",
@@ -0,0 +1,77 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const LOCK_FILE_NAME = 'autopublish.lock';
5
+ const LOCK_EXPIRY_MS = 3600000; // 1 hour
6
+
7
+ /**
8
+ * Get the path to the autopublish lock file.
9
+ * @returns {string} - Path to lock file
10
+ */
11
+ export function getLockFilePath() {
12
+ return path.join(process.cwd(), '.magentrix', LOCK_FILE_NAME);
13
+ }
14
+
15
+ /**
16
+ * Check if autopublish is currently running.
17
+ * @returns {boolean} - True if autopublish is running (lock file exists and is not stale)
18
+ */
19
+ export function isAutopublishRunning() {
20
+ const lockFile = getLockFilePath();
21
+
22
+ if (!fs.existsSync(lockFile)) return false;
23
+
24
+ try {
25
+ const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf-8'));
26
+ const lockAge = Date.now() - lockData.timestamp;
27
+
28
+ // If lock is older than 1 hour, consider it stale
29
+ return lockAge < LOCK_EXPIRY_MS;
30
+ } catch {
31
+ // If we can't read the lock file, assume it's not running
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Create a lock file to prevent multiple autopublish instances.
38
+ * @returns {boolean} - True if lock was acquired, false if already locked
39
+ */
40
+ export function acquireAutopublishLock() {
41
+ const lockFile = getLockFilePath();
42
+
43
+ try {
44
+ if (fs.existsSync(lockFile)) {
45
+ // Check if the lock is stale (process might have crashed)
46
+ if (isAutopublishRunning()) {
47
+ return false; // Lock is active
48
+ }
49
+ }
50
+
51
+ // Create lock file
52
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true });
53
+ fs.writeFileSync(lockFile, JSON.stringify({
54
+ pid: process.pid,
55
+ timestamp: Date.now()
56
+ }));
57
+
58
+ return true;
59
+ } catch (err) {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Release the autopublish lock file.
66
+ */
67
+ export function releaseAutopublishLock() {
68
+ const lockFile = getLockFilePath();
69
+
70
+ try {
71
+ if (fs.existsSync(lockFile)) {
72
+ fs.unlinkSync(lockFile);
73
+ }
74
+ } catch {
75
+ // Ignore errors during cleanup
76
+ }
77
+ }
@@ -243,13 +243,12 @@ 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 (skip in test mode to avoid clearing test output)
247
- if (!process.env.MAGENTRIX_TEST_MODE) {
248
- console.clear();
249
- }
246
+ // Add spacing instead of clearing (clearing causes flickering with progress tracker)
247
+ console.log('\n');
248
+ console.log(chalk.gray('─'.repeat(48)));
250
249
  console.log(
251
250
  chalk.bold.yellow(
252
- `\n${fileIssues.length} file${fileIssues.length > 1 ? 's' : ''} require conflict resolution:\n`
251
+ `${fileIssues.length} file${fileIssues.length > 1 ? 's' : ''} require conflict resolution:\n`
253
252
  )
254
253
  );
255
254