@magentrix-corp/magentrix-cli 1.3.9 → 1.3.11

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/README.md CHANGED
@@ -573,7 +573,7 @@ Your Vue project needs two configuration files:
573
573
  ```typescript
574
574
  // src/config.ts
575
575
  export const config = {
576
- appPath: "my-app", // Required: App identifier (folder name on server)
576
+ appSlug: "my-app", // Required: App identifier (folder name on server)
577
577
  appName: "My Application", // Required: Display name in navigation menu
578
578
  appDescription: "", // Optional: App description
579
579
  appIconId: "", // Optional: App icon ID
@@ -589,11 +589,43 @@ VITE_ASSETS = '[]' # Injected automatically by vue-run-dev
589
589
  ```
590
590
 
591
591
  **Accepted field names in config.ts:**
592
- - Slug: `appPath`, `slug`, or `app_path`
592
+ - Slug: `appSlug`, `slug`, or `app_slug`
593
593
  - Name: `appName`, `app_name`, or `name`
594
594
  - Description: `appDescription` or `app_description`
595
595
  - Icon: `appIconId` or `app_icon_id`
596
596
 
597
+ #### Changing an App Slug
598
+
599
+ > **⚠️ Important:** Changing the `appSlug`/`slug` in `config.ts` creates a **new** Iris app on Magentrix. The old app is **not** automatically deleted.
600
+
601
+ If you simply change the slug and run `vue-run-build`, you'll end up with two apps on the server - the old one becomes orphaned.
602
+
603
+ **Recommended workflow for changing an app slug:**
604
+
605
+ ```bash
606
+ # 1. Delete the old app first (from Magentrix workspace)
607
+ cd ~/magentrix-workspace
608
+ magentrix iris-app-delete # Select the old app, confirm deletion
609
+ # This removes it from server, local files, and cache
610
+ # Optionally unlink the Vue project when prompted
611
+
612
+ # 2. Change the slug in your Vue project
613
+ cd ~/my-vue-app
614
+ # Edit config.ts: change appSlug from "old-slug" to "new-slug"
615
+
616
+ # 3. Re-link the project with the new slug
617
+ magentrix iris-app-link # Updates the linked project with new slug
618
+
619
+ # 4. Build, stage, and publish
620
+ magentrix vue-run-build # Stages to new folder: src/iris-apps/new-slug/
621
+ # When prompted, choose to publish
622
+ ```
623
+
624
+ **What happens if you don't follow this workflow:**
625
+ - Both `old-slug` and `new-slug` apps will exist on the server
626
+ - The old app remains in `src/iris-apps/old-slug/` locally
627
+ - You'll need to manually delete the old app using `magentrix iris-app-delete`
628
+
597
629
  ### Command Availability
598
630
 
599
631
  **In Vue project directories** (detected by presence of `config.ts`):
@@ -693,8 +725,8 @@ When running `vue-run-build` from a Vue project, the CLI checks if the target wo
693
725
  2. Pull latest changes: `magentrix pull`
694
726
  3. Re-run `vue-run-build` from your Vue project
695
727
 
696
- #### "Missing required field in config.ts: slug (appPath)"
697
- Your Vue project's `config.ts` is missing the app identifier. Add an `appPath` or `slug` field.
728
+ #### "Missing required field in config.ts: slug (appSlug)"
729
+ Your Vue project's `config.ts` is missing the app identifier. Add an `appSlug` or `slug` field.
698
730
 
699
731
  #### "Missing required field in config.ts: appName"
700
732
  Your Vue project's `config.ts` is missing the display name. Add an `appName` field.
@@ -743,6 +775,12 @@ magentrix publish # Sync to server
743
775
  #### Changes not detected after vue-run-build
744
776
  The CLI uses content hash tracking to detect changes. If you rebuild without changes, `magentrix publish` will show "All files are in sync — nothing to publish!" This is expected behavior and saves unnecessary uploads.
745
777
 
778
+ #### Duplicate apps after changing slug
779
+ If you changed the `appSlug`/`slug` in `config.ts` and now have two apps on the server:
780
+ 1. The old app is orphaned and needs manual cleanup
781
+ 2. Run `magentrix iris-app-delete` and select the old app to remove it
782
+ 3. See [Changing an App Slug](#changing-an-app-slug) for the proper workflow
783
+
746
784
  ---
747
785
 
748
786
  ## Handling Conflicts
@@ -27,9 +27,33 @@ import {
27
27
  } from '../../utils/iris/linker.js';
28
28
  import { EXPORT_ROOT, IRIS_APPS_DIR, HASHED_CWD } from '../../vars/global.js';
29
29
  import { getValidWorkspaces } from '../../utils/workspaces.js';
30
+ import { formatTimeoutError } from '../../utils/iris/errors.js';
30
31
 
31
32
  const config = new Config();
32
33
 
34
+ /**
35
+ * Timeout for status check operations (30 seconds).
36
+ */
37
+ const STATUS_CHECK_TIMEOUT = 30000;
38
+
39
+ /**
40
+ * Timeout for command execution (5 minutes).
41
+ */
42
+ const COMMAND_TIMEOUT = 5 * 60 * 1000;
43
+
44
+ /**
45
+ * Display warnings from build/stage operations.
46
+ *
47
+ * @param {string[]} warnings - Array of warning messages
48
+ */
49
+ function displayWarnings(warnings) {
50
+ if (!warnings || warnings.length === 0) return;
51
+
52
+ for (const warning of warnings) {
53
+ console.log(chalk.yellow(`⚠ ${warning}`));
54
+ }
55
+ }
56
+
33
57
  /**
34
58
  * Check if the current directory is a Vue project (has config.ts).
35
59
  * @returns {boolean}
@@ -221,6 +245,9 @@ export const vueBuildStage = async (options = {}) => {
221
245
  console.log(chalk.green(`\u2713 Build completed successfully`));
222
246
  console.log(chalk.gray(` Output: ${distPath}`));
223
247
 
248
+ // Display any build warnings
249
+ displayWarnings(buildResult.warnings);
250
+
224
251
  // Validate build output
225
252
  const validation = validateIrisBuild(distPath);
226
253
  if (!validation.valid) {
@@ -245,6 +272,9 @@ export const vueBuildStage = async (options = {}) => {
245
272
 
246
273
  console.log(chalk.green(`\u2713 Staged ${stageResult.fileCount} files to ${stageResult.stagedPath}`));
247
274
 
275
+ // Display any staging warnings
276
+ displayWarnings(stageResult.warnings);
277
+
248
278
  // Summary
249
279
  console.log();
250
280
  console.log(chalk.green('─'.repeat(48)));
@@ -387,6 +417,9 @@ async function buildFromVueProject(options) {
387
417
  console.log(chalk.green(`\u2713 Build completed successfully`));
388
418
  console.log(chalk.gray(` Output: ${distPath}`));
389
419
 
420
+ // Display any build warnings
421
+ displayWarnings(buildResult.warnings);
422
+
390
423
  // Validate build output
391
424
  const validation = validateIrisBuild(distPath);
392
425
  if (!validation.valid) {
@@ -469,6 +502,9 @@ async function buildFromVueProject(options) {
469
502
 
470
503
  console.log(chalk.green(`\u2713 Staged ${stageResult.fileCount} files to ${stageResult.stagedPath}`));
471
504
 
505
+ // Display any staging warnings
506
+ displayWarnings(stageResult.warnings);
507
+
472
508
  // Summary
473
509
  console.log();
474
510
  console.log(chalk.green('─'.repeat(48)));
@@ -509,7 +545,7 @@ async function buildFromVueProject(options) {
509
545
  * Check if a workspace needs to pull (has remote changes or conflicts).
510
546
  *
511
547
  * @param {string} workspacePath - Path to the Magentrix workspace
512
- * @returns {Promise<{checked: boolean, needsPull: boolean}>}
548
+ * @returns {Promise<{checked: boolean, needsPull: boolean, timedOut: boolean}>}
513
549
  */
514
550
  async function checkWorkspaceSyncStatus(workspacePath) {
515
551
  return new Promise((resolvePromise) => {
@@ -517,6 +553,7 @@ async function checkWorkspaceSyncStatus(workspacePath) {
517
553
  const npmCmd = isWindows ? 'npx.cmd' : 'npx';
518
554
 
519
555
  let output = '';
556
+ let resolved = false;
520
557
 
521
558
  const child = spawn(npmCmd, ['magentrix', 'status'], {
522
559
  cwd: workspacePath,
@@ -524,6 +561,20 @@ async function checkWorkspaceSyncStatus(workspacePath) {
524
561
  shell: isWindows
525
562
  });
526
563
 
564
+ // Set timeout for status check
565
+ const timeout = setTimeout(() => {
566
+ if (!resolved) {
567
+ resolved = true;
568
+ try {
569
+ child.kill('SIGTERM');
570
+ } catch {
571
+ // Ignore kill errors
572
+ }
573
+ console.log(chalk.yellow(`\n⚠ Status check timed out after ${STATUS_CHECK_TIMEOUT / 1000} seconds`));
574
+ resolvePromise({ checked: false, needsPull: false, timedOut: true });
575
+ }
576
+ }, STATUS_CHECK_TIMEOUT);
577
+
527
578
  child.stdout.on('data', (data) => {
528
579
  output += data.toString();
529
580
  });
@@ -533,6 +584,10 @@ async function checkWorkspaceSyncStatus(workspacePath) {
533
584
  });
534
585
 
535
586
  child.on('close', (code) => {
587
+ if (resolved) return;
588
+ resolved = true;
589
+ clearTimeout(timeout);
590
+
536
591
  // Check output for specific issue indicators from status command
537
592
  // Note: We avoid checking for "remote" as it appears in normal output
538
593
  // ("Checking local files vs remote Magentrix...")
@@ -552,12 +607,17 @@ async function checkWorkspaceSyncStatus(workspacePath) {
552
607
 
553
608
  const needsPull = !isInSync && (hasConflict || isOutdated || isAhead || isMissing || hasContentMismatch || hasWarnings);
554
609
 
555
- resolvePromise({ checked: code === 0, needsPull });
610
+ resolvePromise({ checked: code === 0, needsPull, timedOut: false });
556
611
  });
557
612
 
558
- child.on('error', () => {
613
+ child.on('error', (err) => {
614
+ if (resolved) return;
615
+ resolved = true;
616
+ clearTimeout(timeout);
617
+
559
618
  // If we can't check, assume it's fine and let them proceed
560
- resolvePromise({ checked: false, needsPull: false });
619
+ console.log(chalk.yellow(`\n⚠ Could not check sync status: ${err.message}`));
620
+ resolvePromise({ checked: false, needsPull: false, timedOut: false });
561
621
  });
562
622
  });
563
623
  }
@@ -567,24 +627,52 @@ async function checkWorkspaceSyncStatus(workspacePath) {
567
627
  *
568
628
  * @param {string} workspacePath - Path to the Magentrix workspace
569
629
  * @param {string} command - The magentrix command to run (e.g., 'pull', 'publish')
630
+ * @param {number} timeout - Optional timeout in milliseconds (defaults to COMMAND_TIMEOUT)
570
631
  * @returns {Promise<boolean>} - True if command succeeded
571
632
  */
572
- async function runCommandFromWorkspace(workspacePath, command) {
633
+ async function runCommandFromWorkspace(workspacePath, command, timeout = COMMAND_TIMEOUT) {
573
634
  return new Promise((resolvePromise) => {
574
635
  const isWindows = process.platform === 'win32';
575
636
  const npmCmd = isWindows ? 'npx.cmd' : 'npx';
576
637
 
638
+ let resolved = false;
639
+
577
640
  const child = spawn(npmCmd, ['magentrix', command], {
578
641
  cwd: workspacePath,
579
642
  stdio: 'inherit',
580
643
  shell: isWindows
581
644
  });
582
645
 
646
+ // Set timeout for command execution
647
+ const timeoutId = setTimeout(() => {
648
+ if (!resolved) {
649
+ resolved = true;
650
+ try {
651
+ child.kill('SIGTERM');
652
+ } catch {
653
+ // Ignore kill errors
654
+ }
655
+ console.log();
656
+ console.log(chalk.red(formatTimeoutError({
657
+ operation: `magentrix ${command}`,
658
+ timeout: timeout,
659
+ suggestion: `The ${command} operation is taking too long. Check your network connection or try again later.`
660
+ })));
661
+ resolvePromise(false);
662
+ }
663
+ }, timeout);
664
+
583
665
  child.on('close', (code) => {
666
+ if (resolved) return;
667
+ resolved = true;
668
+ clearTimeout(timeoutId);
584
669
  resolvePromise(code === 0);
585
670
  });
586
671
 
587
672
  child.on('error', (err) => {
673
+ if (resolved) return;
674
+ resolved = true;
675
+ clearTimeout(timeoutId);
588
676
  console.log(chalk.yellow(`Warning: Could not run ${command}: ${err.message}`));
589
677
  resolvePromise(false);
590
678
  });
@@ -8,6 +8,8 @@ import { getLinkedProjects, unlinkVueProject } from '../../utils/iris/linker.js'
8
8
  import { deleteIrisAppFromServer, deleteLocalIrisAppFiles } from '../../utils/iris/deleteHelper.js';
9
9
  import { showPermissionError } from '../../utils/permissionError.js';
10
10
  import { EXPORT_ROOT, IRIS_APPS_DIR } from '../../vars/global.js';
11
+ import { acquireLock, releaseLock, LockTypes } from '../../utils/iris/lock.js';
12
+ import { formatFileLockError } from '../../utils/iris/errors.js';
11
13
 
12
14
  const config = new Config();
13
15
 
@@ -99,6 +101,42 @@ export const irisDelete = async () => {
99
101
  return;
100
102
  }
101
103
 
104
+ // Acquire delete lock to prevent concurrent deletions
105
+ const lockBasePath = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.magentrix-locks');
106
+ const lockResult = acquireLock(LockTypes.DELETE, {
107
+ context: slug,
108
+ operation: `deleting ${slug}`,
109
+ basePath: lockBasePath
110
+ });
111
+
112
+ // Track if lock was acquired (permission errors are non-fatal for delete)
113
+ let lockAcquired = lockResult.acquired;
114
+ if (!lockResult.acquired) {
115
+ if (lockResult.error?.includes('permission') || lockResult.error?.includes('EACCES')) {
116
+ console.log(chalk.yellow('Warning: Could not create delete lock (permission issue). Proceeding without lock.'));
117
+ lockAcquired = false;
118
+ } else {
119
+ console.log(chalk.red('Cannot delete app:'));
120
+ console.log(chalk.yellow(lockResult.error));
121
+ return;
122
+ }
123
+ }
124
+
125
+ try {
126
+ await performDelete(slug, appName);
127
+ } finally {
128
+ if (lockAcquired) {
129
+ releaseLock(LockTypes.DELETE, { context: slug, basePath: lockBasePath });
130
+ }
131
+ }
132
+ };
133
+
134
+ /**
135
+ * Perform the actual delete operation.
136
+ * @param {string} slug - App slug
137
+ * @param {string} appName - App display name
138
+ */
139
+ async function performDelete(slug, appName) {
102
140
  // Check if app is linked
103
141
  const linkedProjects = getLinkedProjects();
104
142
  const linkedProject = linkedProjects.find(p => p.slug === slug);
@@ -176,6 +214,13 @@ export const irisDelete = async () => {
176
214
  console.log(chalk.gray('Note: The app was deleted from the server and cache.'));
177
215
  console.log(chalk.gray('Only local file cleanup failed.'));
178
216
  console.log();
217
+ } else if (localDeleteResult.isFileLocked) {
218
+ console.log(chalk.yellow('⚠ Could not delete local files - files are in use'));
219
+ console.log(chalk.gray(localDeleteResult.error));
220
+ console.log();
221
+ console.log(chalk.gray('Note: The app was deleted from the server and cache.'));
222
+ console.log(chalk.gray('Close any programs using these files and delete manually.'));
223
+ console.log();
179
224
  } else {
180
225
  console.log(chalk.yellow(`⚠ Failed to delete local files: ${localDeleteResult.error}`));
181
226
  console.log(chalk.gray(`Path: ${appPath}`));
@@ -206,6 +251,6 @@ export const irisDelete = async () => {
206
251
  console.log(chalk.white(` Backup saved to: ${chalk.gray(backupResult.backupPath)}`));
207
252
  console.log(chalk.white(` To restore, run: ${chalk.cyan('magentrix iris-app-recover')}`));
208
253
  console.log();
209
- };
254
+ }
210
255
 
211
256
  export default irisDelete;
@@ -2,7 +2,7 @@ import chalk from 'chalk';
2
2
  import { select, input } from '@inquirer/prompts';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { existsSync } from 'node:fs';
5
- import { resolve } from 'node:path';
5
+ import { resolve, basename, join } from 'node:path';
6
6
  import {
7
7
  readVueConfig,
8
8
  formatMissingConfigError,
@@ -16,6 +16,21 @@ import {
16
16
  getLinkedProjectsWithStatus,
17
17
  buildProjectChoices
18
18
  } from '../../utils/iris/linker.js';
19
+ import {
20
+ acquireLock,
21
+ releaseLock,
22
+ LockTypes,
23
+ createProjectContext
24
+ } from '../../utils/iris/lock.js';
25
+ import {
26
+ formatNetworkError,
27
+ formatError
28
+ } from '../../utils/iris/errors.js';
29
+
30
+ /**
31
+ * Timeout for fetching assets (30 seconds).
32
+ */
33
+ const ASSET_FETCH_TIMEOUT = 30000;
19
34
 
20
35
  /**
21
36
  * vue-run-dev command - Start Vue dev server with platform assets injected.
@@ -95,8 +110,17 @@ export const irisDev = async (options = {}) => {
95
110
 
96
111
  try {
97
112
  // Get access token using the refresh token from .env.development
98
- const tokenData = await getAccessToken(vueConfig.refreshToken, siteUrl);
99
- const assetsResult = await getIrisAssets(siteUrl, tokenData.token);
113
+ // Use Promise.race to implement timeout
114
+ const tokenPromise = getAccessToken(vueConfig.refreshToken, siteUrl);
115
+ const timeoutPromise = new Promise((_, reject) =>
116
+ setTimeout(() => reject(new Error('Request timed out')), ASSET_FETCH_TIMEOUT)
117
+ );
118
+
119
+ const tokenData = await Promise.race([tokenPromise, timeoutPromise]);
120
+
121
+ // Fetch assets with timeout
122
+ const assetsPromise = getIrisAssets(siteUrl, tokenData.token);
123
+ const assetsResult = await Promise.race([assetsPromise, timeoutPromise]);
100
124
 
101
125
  if (assetsResult.success && assetsResult.assets?.length > 0) {
102
126
  console.log(chalk.green(`\u2713 Found ${assetsResult.assets.length} platform assets`));
@@ -115,7 +139,12 @@ export const irisDev = async (options = {}) => {
115
139
  if (injectResult.success) {
116
140
  console.log(chalk.green(`\u2713 Assets updated in ${injectResult.targetName}`));
117
141
  } else {
118
- console.log(chalk.yellow('Warning: Could not inject assets. Continuing without injection.'));
142
+ // Show specific error if available
143
+ if (injectResult.error) {
144
+ console.log(chalk.yellow(`Warning: Could not inject assets: ${injectResult.error}`));
145
+ } else {
146
+ console.log(chalk.yellow('Warning: Could not inject assets. Continuing without injection.'));
147
+ }
119
148
  }
120
149
  }
121
150
  } else if (assetsResult.error) {
@@ -125,7 +154,23 @@ export const irisDev = async (options = {}) => {
125
154
  console.log(chalk.yellow('No platform assets found.'));
126
155
  }
127
156
  } catch (err) {
128
- console.log(chalk.yellow(`Warning: Error fetching assets: ${err.message}`));
157
+ // Provide more helpful error messages based on error type
158
+ if (err.message === 'Request timed out') {
159
+ console.log(chalk.yellow('Warning: Asset fetch timed out.'));
160
+ console.log(chalk.gray('Check your network connection or try again later.'));
161
+ } else if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT') {
162
+ console.log(chalk.yellow(formatNetworkError({
163
+ operation: 'fetch assets',
164
+ url: siteUrl,
165
+ error: err
166
+ })));
167
+ } else if (err.message?.includes('401') || err.message?.includes('Unauthorized')) {
168
+ console.log(chalk.yellow('Warning: Authentication failed.'));
169
+ console.log(chalk.gray('Your VITE_REFRESH_TOKEN may be invalid or expired.'));
170
+ console.log(chalk.gray('Get a new API key from your Magentrix platform.'));
171
+ } else {
172
+ console.log(chalk.yellow(`Warning: Error fetching assets: ${err.message}`));
173
+ }
129
174
  console.log(chalk.gray('Continuing without asset injection.'));
130
175
  }
131
176
  }
@@ -147,8 +192,34 @@ export const irisDev = async (options = {}) => {
147
192
 
148
193
  /**
149
194
  * Run the Vue development server.
195
+ *
196
+ * @param {string} projectPath - Path to the Vue project
197
+ * @returns {Promise<number>} - Exit code
150
198
  */
151
199
  async function runDevServer(projectPath) {
200
+ // Acquire dev server lock to prevent multiple instances
201
+ // Use user home for consistent lock location across projects
202
+ const lockContext = createProjectContext(projectPath);
203
+ const lockBasePath = join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.magentrix-locks');
204
+ const lockResult = acquireLock(LockTypes.DEV_SERVER, {
205
+ context: lockContext,
206
+ operation: `dev server for ${basename(projectPath)}`,
207
+ basePath: lockBasePath
208
+ });
209
+
210
+ // Track if lock was acquired (permission errors are non-fatal)
211
+ let lockAcquired = lockResult.acquired;
212
+ if (!lockResult.acquired) {
213
+ if (lockResult.error?.includes('permission') || lockResult.error?.includes('EACCES')) {
214
+ console.log(chalk.yellow('Warning: Could not create dev server lock (permission issue). Proceeding without lock.'));
215
+ lockAcquired = false;
216
+ } else {
217
+ console.log(chalk.red('Cannot start dev server:'));
218
+ console.log(chalk.yellow(lockResult.error));
219
+ return 1;
220
+ }
221
+ }
222
+
152
223
  return new Promise((resolvePromise) => {
153
224
  const isWindows = process.platform === 'win32';
154
225
  const npmCmd = isWindows ? 'npm.cmd' : 'npm';
@@ -159,16 +230,38 @@ async function runDevServer(projectPath) {
159
230
  shell: isWindows // Windows requires shell: true for .cmd files
160
231
  });
161
232
 
162
- // Handle process signals
163
- process.on('SIGINT', () => {
164
- child.kill('SIGINT');
165
- });
166
-
167
- process.on('SIGTERM', () => {
168
- child.kill('SIGTERM');
233
+ // Cleanup function to release lock
234
+ const cleanup = () => {
235
+ if (lockAcquired) {
236
+ releaseLock(LockTypes.DEV_SERVER, { context: lockContext, basePath: lockBasePath });
237
+ }
238
+ };
239
+
240
+ // Handle process signals - use once() to avoid memory leaks
241
+ const handleSignal = (signal) => {
242
+ cleanup();
243
+ child.kill(signal);
244
+ // Give child process a moment to exit, then force exit
245
+ setTimeout(() => {
246
+ process.exit(0);
247
+ }, 500);
248
+ };
249
+
250
+ const sigintHandler = () => handleSignal('SIGINT');
251
+ const sigtermHandler = () => handleSignal('SIGTERM');
252
+
253
+ process.once('SIGINT', sigintHandler);
254
+ process.once('SIGTERM', sigtermHandler);
255
+
256
+ // Remove handlers when child exits to avoid memory leaks
257
+ child.on('exit', () => {
258
+ process.removeListener('SIGINT', sigintHandler);
259
+ process.removeListener('SIGTERM', sigtermHandler);
169
260
  });
170
261
 
171
262
  child.on('close', (code) => {
263
+ cleanup();
264
+
172
265
  // If dev server exited with error, show helpful context
173
266
  if (code !== 0 && code !== null) {
174
267
  console.log();
@@ -184,7 +277,13 @@ async function runDevServer(projectPath) {
184
277
  console.log(chalk.gray(' • Syntax errors in project files'));
185
278
  console.log();
186
279
  console.log(chalk.white('If you see EACCES/permission errors, try:'));
187
- console.log(chalk.cyan(` sudo chown -R $(whoami) "${projectPath}/node_modules"`));
280
+ if (isWindows) {
281
+ console.log(chalk.cyan(' Run terminal as Administrator'));
282
+ console.log(chalk.cyan(` rd /s /q "${projectPath}\\node_modules"`));
283
+ console.log(chalk.cyan(' npm install'));
284
+ } else {
285
+ console.log(chalk.cyan(` sudo chown -R $(whoami) "${projectPath}/node_modules"`));
286
+ }
188
287
  console.log(chalk.yellow('─'.repeat(48)));
189
288
  }
190
289
 
@@ -192,7 +291,24 @@ async function runDevServer(projectPath) {
192
291
  });
193
292
 
194
293
  child.on('error', (err) => {
294
+ cleanup();
195
295
  console.log(chalk.red(`Failed to start dev server: ${err.message}`));
296
+
297
+ // Provide more specific error messages
298
+ if (err.code === 'ENOENT') {
299
+ console.log();
300
+ console.log(chalk.yellow('npm was not found on your system.'));
301
+ console.log(chalk.gray('Make sure Node.js is installed and npm is in your PATH.'));
302
+ } else if (err.code === 'EACCES' || err.code === 'EPERM') {
303
+ console.log();
304
+ console.log(chalk.yellow('Permission denied.'));
305
+ if (isWindows) {
306
+ console.log(chalk.gray('Try running the terminal as Administrator.'));
307
+ } else {
308
+ console.log(chalk.gray('Check file permissions for the project directory.'));
309
+ }
310
+ }
311
+
196
312
  resolvePromise(1);
197
313
  });
198
314
  });
@@ -6,6 +6,7 @@ import { listBackups, restoreIrisApp, deleteBackup } from '../../utils/iris/back
6
6
  import { linkVueProject } from '../../utils/iris/linker.js';
7
7
  import { showPermissionError } from '../../utils/permissionError.js';
8
8
  import { EXPORT_ROOT, IRIS_APPS_DIR } from '../../vars/global.js';
9
+ import { acquireLock, releaseLock, LockTypes } from '../../utils/iris/lock.js';
9
10
 
10
11
  /**
11
12
  * iris-app-recover command - Restore a deleted Iris app from backup.
@@ -135,15 +136,43 @@ export const irisRecover = async (options = {}) => {
135
136
  return;
136
137
  }
137
138
 
138
- // Restore files
139
- console.log();
140
- console.log(chalk.blue('Restoring files...'));
141
-
142
- const restoreResult = await restoreIrisApp(backupPath, {
143
- restoreLocal: true,
144
- restoreLink: true
139
+ // Acquire recover lock to prevent concurrent recoveries
140
+ const lockBasePath = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.magentrix-locks');
141
+ const lockResult = acquireLock(LockTypes.RECOVER, {
142
+ context: slug,
143
+ operation: `recovering ${slug}`,
144
+ basePath: lockBasePath
145
145
  });
146
146
 
147
+ // Track if lock was acquired (permission errors are non-fatal)
148
+ let lockAcquired = lockResult.acquired;
149
+ if (!lockResult.acquired) {
150
+ if (lockResult.error?.includes('permission') || lockResult.error?.includes('EACCES')) {
151
+ console.log(chalk.yellow('Warning: Could not create recover lock (permission issue). Proceeding without lock.'));
152
+ lockAcquired = false;
153
+ } else {
154
+ console.log(chalk.red('Cannot recover app:'));
155
+ console.log(chalk.yellow(lockResult.error));
156
+ return;
157
+ }
158
+ }
159
+
160
+ let restoreResult;
161
+ try {
162
+ // Restore files
163
+ console.log();
164
+ console.log(chalk.blue('Restoring files...'));
165
+
166
+ restoreResult = await restoreIrisApp(backupPath, {
167
+ restoreLocal: true,
168
+ restoreLink: true
169
+ });
170
+ } finally {
171
+ if (lockAcquired) {
172
+ releaseLock(LockTypes.RECOVER, { context: slug, basePath: lockBasePath });
173
+ }
174
+ }
175
+
147
176
  if (!restoreResult.success) {
148
177
  if (restoreResult.isPermissionError) {
149
178
  const targetDir = path.join(process.cwd(), EXPORT_ROOT, IRIS_APPS_DIR);
@@ -153,6 +182,17 @@ export const irisRecover = async (options = {}) => {
153
182
  backupPath,
154
183
  slug
155
184
  });
185
+ } else if (restoreResult.isFileLocked) {
186
+ console.log(chalk.red('Cannot restore - files are in use'));
187
+ console.log(chalk.yellow(restoreResult.error));
188
+ console.log();
189
+ console.log(chalk.gray('Close any programs that may be using these files and try again.'));
190
+ } else if (restoreResult.isCorrupted) {
191
+ console.log(chalk.red('Backup appears to be corrupted'));
192
+ console.log(chalk.yellow(restoreResult.error));
193
+ console.log();
194
+ console.log(chalk.gray('The backup file may have been damaged. You may need to delete it.'));
195
+ console.log(chalk.gray(`Backup path: ${backupPath}`));
156
196
  } else {
157
197
  console.log(chalk.red(`Failed to restore: ${restoreResult.error}`));
158
198
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magentrix-corp/magentrix-cli",
3
- "version": "1.3.9",
3
+ "version": "1.3.11",
4
4
  "description": "CLI tool for synchronizing local files with Magentrix cloud platform",
5
5
  "main": "index.js",
6
6
  "type": "module",