@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.
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
+ import { validateSlug, validateUrl, validateAppName, detectExpression, MAX_LENGTHS } from './validation.js';
4
+ import { formatError, detectErrorType, ErrorTypes } from './errors.js';
3
5
 
4
6
  /**
5
7
  * Common locations where config.ts might be found in a Vue project.
@@ -17,6 +19,11 @@ const CONFIG_LOCATIONS = [
17
19
  */
18
20
  const ENV_FILE = '.env.development';
19
21
 
22
+ /**
23
+ * Maximum size of assets array before warning.
24
+ */
25
+ const MAX_ASSETS_WARNING_THRESHOLD = 500;
26
+
20
27
  /**
21
28
  * Parse a .env file and return key-value pairs.
22
29
  *
@@ -68,7 +75,9 @@ function parseEnvFile(envPath) {
68
75
  * siteUrl: string | null,
69
76
  * assets: string[],
70
77
  * refreshToken: string | null,
71
- * envFileUsed: string | null
78
+ * envFileUsed: string | null,
79
+ * warnings: string[],
80
+ * errors: string[]
72
81
  * }}
73
82
  */
74
83
  export function readEnvConfig(projectPath) {
@@ -76,7 +85,9 @@ export function readEnvConfig(projectPath) {
76
85
  siteUrl: null,
77
86
  assets: [],
78
87
  refreshToken: null,
79
- envFileUsed: null
88
+ envFileUsed: null,
89
+ warnings: [],
90
+ errors: []
80
91
  };
81
92
 
82
93
  const envPath = join(projectPath, ENV_FILE);
@@ -85,23 +96,59 @@ export function readEnvConfig(projectPath) {
85
96
  return result;
86
97
  }
87
98
 
88
- const envVars = parseEnvFile(envPath);
99
+ let envVars;
100
+ try {
101
+ envVars = parseEnvFile(envPath);
102
+ } catch (err) {
103
+ result.errors.push(`Failed to read ${ENV_FILE}: ${err.message}`);
104
+ return result;
105
+ }
106
+
89
107
  result.envFileUsed = ENV_FILE;
90
108
 
91
- // Read VITE_SITE_URL
109
+ // Read and validate VITE_SITE_URL
92
110
  if (envVars.VITE_SITE_URL) {
93
- result.siteUrl = envVars.VITE_SITE_URL;
111
+ const urlValue = envVars.VITE_SITE_URL;
112
+ const urlValidation = validateUrl(urlValue);
113
+
114
+ if (!urlValidation.valid) {
115
+ result.errors.push(`VITE_SITE_URL is invalid: ${urlValidation.error}`);
116
+ if (urlValidation.suggestion) {
117
+ result.warnings.push(` Suggestion: ${urlValidation.suggestion}`);
118
+ }
119
+ } else {
120
+ result.siteUrl = urlValidation.normalized || urlValue;
121
+ }
94
122
  }
95
123
 
96
- // Read VITE_ASSETS
124
+ // Read VITE_ASSETS with better error handling
97
125
  if (envVars.VITE_ASSETS) {
98
126
  try {
99
127
  const parsed = JSON.parse(envVars.VITE_ASSETS);
100
128
  if (Array.isArray(parsed)) {
101
129
  result.assets = parsed;
130
+
131
+ // Warn if assets array is very large
132
+ if (parsed.length > MAX_ASSETS_WARNING_THRESHOLD) {
133
+ result.warnings.push(
134
+ `VITE_ASSETS contains ${parsed.length} items. ` +
135
+ `Large arrays may cause performance issues.`
136
+ );
137
+ }
138
+ } else {
139
+ result.warnings.push('VITE_ASSETS is not an array, ignoring');
140
+ }
141
+ } catch (err) {
142
+ // Provide helpful error for JSON parsing issues
143
+ if (envVars.VITE_ASSETS.includes("'") && !envVars.VITE_ASSETS.includes('"')) {
144
+ result.errors.push(
145
+ 'VITE_ASSETS contains invalid JSON. ' +
146
+ 'JSON requires double quotes for strings, not single quotes. ' +
147
+ 'Example: VITE_ASSETS = \'["url1", "url2"]\''
148
+ );
149
+ } else {
150
+ result.errors.push(`VITE_ASSETS contains invalid JSON: ${err.message}`);
102
151
  }
103
- } catch {
104
- // If not valid JSON, skip
105
152
  }
106
153
  }
107
154
 
@@ -129,6 +176,24 @@ export function findConfigFile(projectPath) {
129
176
  return null;
130
177
  }
131
178
 
179
+ /**
180
+ * Strip comments from config file content to prevent parsing issues.
181
+ * Removes both single-line (//) and multi-line comments.
182
+ *
183
+ * @param {string} content - Raw config file content
184
+ * @returns {string} - Content with comments removed
185
+ */
186
+ function stripComments(content) {
187
+ // Remove single-line comments (but not URLs with //)
188
+ // Match // that's not preceded by : (to avoid breaking URLs)
189
+ let result = content.replace(/(?<!:)\/\/.*$/gm, '');
190
+
191
+ // Remove multi-line comments
192
+ result = result.replace(/\/\*[\s\S]*?\*\//g, '');
193
+
194
+ return result;
195
+ }
196
+
132
197
  /**
133
198
  * Parse a config.ts file and extract Iris configuration.
134
199
  * Uses regex parsing to avoid TypeScript compilation.
@@ -141,7 +206,8 @@ export function findConfigFile(projectPath) {
141
206
  * appIconId: string | null,
142
207
  * siteUrl: string | null,
143
208
  * assets: string[],
144
- * raw: string
209
+ * raw: string,
210
+ * warnings: string[]
145
211
  * }}
146
212
  */
147
213
  export function parseConfigFile(configPath) {
@@ -152,24 +218,46 @@ export function parseConfigFile(configPath) {
152
218
  appIconId: null,
153
219
  siteUrl: null,
154
220
  assets: [],
155
- raw: ''
221
+ raw: '',
222
+ warnings: []
156
223
  };
157
224
 
158
225
  if (!existsSync(configPath)) {
159
226
  return result;
160
227
  }
161
228
 
162
- const content = readFileSync(configPath, 'utf-8');
229
+ let content;
230
+ try {
231
+ content = readFileSync(configPath, 'utf-8');
232
+ } catch (err) {
233
+ result.warnings.push(`Failed to read config file: ${err.message}`);
234
+ return result;
235
+ }
236
+
163
237
  result.raw = content;
164
238
 
239
+ // Strip comments before parsing to avoid regex issues
240
+ const cleanContent = stripComments(content);
241
+
165
242
  // Extract slug (various patterns)
166
- // slug: "dashboard", appPath: "dashboard", app_path: "dashboard"
243
+ // slug: "dashboard", appSlug: "dashboard", app_slug: "dashboard"
167
244
  // Also handles: slug: env.slug || "fallback"
168
- const slugMatch = content.match(/(?:slug|appPath|app_path)\s*:\s*["'`]([^"'`]+)["'`]/);
245
+ const slugMatch = cleanContent.match(/(?:slug|appSlug|app_slug)\s*:\s*["'`]([^"'`]+)["'`]/);
169
246
  if (slugMatch) {
170
247
  result.slug = slugMatch[1];
248
+
249
+ // Check for template literal expressions
250
+ const exprCheck = detectExpression(result.slug);
251
+ if (exprCheck.isExpression) {
252
+ result.warnings.push(
253
+ `slug appears to contain a ${exprCheck.type} (${result.slug}). ` +
254
+ `The CLI cannot evaluate dynamic values. ` +
255
+ `Use a static string value instead.`
256
+ );
257
+ result.slug = null;
258
+ }
171
259
  } else {
172
- const slugFallbackMatch = content.match(/(?:slug|appPath|app_path)\s*:\s*[^,\n]+\|\|\s*["'`]([^"'`]+)["'`]/);
260
+ const slugFallbackMatch = cleanContent.match(/(?:slug|appSlug|app_slug)\s*:\s*[^,\n]+\|\|\s*["'`]([^"'`]+)["'`]/);
173
261
  if (slugFallbackMatch) {
174
262
  result.slug = slugFallbackMatch[1];
175
263
  }
@@ -178,11 +266,22 @@ export function parseConfigFile(configPath) {
178
266
  // Extract appName (various patterns)
179
267
  // appName: "Dashboard App", app_name: "Dashboard", name: "Dashboard"
180
268
  // Also handles: appName: env.appName || "fallback"
181
- const appNameMatch = content.match(/(?:appName|app_name)\s*:\s*["'`]([^"'`]+)["'`]/);
269
+ const appNameMatch = cleanContent.match(/(?:appName|app_name)\s*:\s*["'`]([^"'`]+)["'`]/);
182
270
  if (appNameMatch) {
183
271
  result.appName = appNameMatch[1];
272
+
273
+ // Check for template literal expressions
274
+ const exprCheck = detectExpression(result.appName);
275
+ if (exprCheck.isExpression) {
276
+ result.warnings.push(
277
+ `appName appears to contain a ${exprCheck.type} (${result.appName}). ` +
278
+ `The CLI cannot evaluate dynamic values. ` +
279
+ `Use a static string value instead.`
280
+ );
281
+ result.appName = null;
282
+ }
184
283
  } else {
185
- const appNameFallbackMatch = content.match(/(?:appName|app_name)\s*:\s*[^,\n]+\|\|\s*["'`]([^"'`]+)["'`]/);
284
+ const appNameFallbackMatch = cleanContent.match(/(?:appName|app_name)\s*:\s*[^,\n]+\|\|\s*["'`]([^"'`]+)["'`]/);
186
285
  if (appNameFallbackMatch) {
187
286
  result.appName = appNameFallbackMatch[1];
188
287
  }
@@ -190,14 +289,14 @@ export function parseConfigFile(configPath) {
190
289
 
191
290
  // Extract appDescription (optional)
192
291
  // appDescription: "Description text"
193
- const appDescriptionMatch = content.match(/(?:appDescription|app_description)\s*:\s*["'`]([^"'`]*)["'`]/);
292
+ const appDescriptionMatch = cleanContent.match(/(?:appDescription|app_description)\s*:\s*["'`]([^"'`]*)["'`]/);
194
293
  if (appDescriptionMatch) {
195
294
  result.appDescription = appDescriptionMatch[1];
196
295
  }
197
296
 
198
297
  // Extract appIconId (optional)
199
298
  // appIconId: "icon-id-here"
200
- const appIconIdMatch = content.match(/(?:appIconId|app_icon_id)\s*:\s*["'`]([^"'`]*)["'`]/);
299
+ const appIconIdMatch = cleanContent.match(/(?:appIconId|app_icon_id)\s*:\s*["'`]([^"'`]*)["'`]/);
201
300
  if (appIconIdMatch) {
202
301
  result.appIconId = appIconIdMatch[1];
203
302
  }
@@ -205,12 +304,12 @@ export function parseConfigFile(configPath) {
205
304
  // Extract siteUrl (various patterns)
206
305
  // siteUrl: "https://...", site_url: "https://...", baseUrl: "https://..."
207
306
  // Also handles: siteUrl: env.siteUrl || "https://..."
208
- const siteUrlMatch = content.match(/(?:siteUrl|site_url|baseUrl|base_url)\s*:\s*["'`]([^"'`]+)["'`]/);
307
+ const siteUrlMatch = cleanContent.match(/(?:siteUrl|site_url|baseUrl|base_url)\s*:\s*["'`]([^"'`]+)["'`]/);
209
308
  if (siteUrlMatch) {
210
309
  result.siteUrl = siteUrlMatch[1];
211
310
  } else {
212
311
  // Try to match fallback pattern: siteUrl: env.x || "fallback"
213
- const siteUrlFallbackMatch = content.match(/(?:siteUrl|site_url|baseUrl|base_url)\s*:\s*[^,\n]+\|\|\s*["'`]([^"'`]+)["'`]/);
312
+ const siteUrlFallbackMatch = cleanContent.match(/(?:siteUrl|site_url|baseUrl|base_url)\s*:\s*[^,\n]+\|\|\s*["'`]([^"'`]+)["'`]/);
214
313
  if (siteUrlFallbackMatch) {
215
314
  result.siteUrl = siteUrlFallbackMatch[1];
216
315
  }
@@ -218,7 +317,7 @@ export function parseConfigFile(configPath) {
218
317
 
219
318
  // Extract assets array
220
319
  // assets: ["url1", "url2"]
221
- const assetsMatch = content.match(/assets\s*:\s*\[([\s\S]*?)\]/);
320
+ const assetsMatch = cleanContent.match(/assets\s*:\s*\[([\s\S]*?)\]/);
222
321
  if (assetsMatch) {
223
322
  const assetsContent = assetsMatch[1];
224
323
  // Extract all quoted strings from the array
@@ -282,11 +381,37 @@ export function readVueConfig(projectPath) {
282
381
 
283
382
  // Parse config.ts for slug, appName, appDescription, appIconId (always from config.ts)
284
383
  const parsed = parseConfigFile(configPath);
384
+
385
+ // Merge warnings from parsing
386
+ if (parsed.warnings?.length > 0) {
387
+ result.warnings.push(...parsed.warnings);
388
+ }
389
+
285
390
  result.slug = parsed.slug;
286
391
  result.appName = parsed.appName;
287
392
  result.appDescription = parsed.appDescription;
288
393
  result.appIconId = parsed.appIconId;
289
394
 
395
+ // Validate slug format if present
396
+ if (result.slug) {
397
+ const slugValidation = validateSlug(result.slug);
398
+ if (!slugValidation.valid) {
399
+ result.errors.push(`Invalid slug format: ${slugValidation.error}`);
400
+ if (slugValidation.suggestion) {
401
+ result.warnings.push(` Suggestion: Use "${slugValidation.suggestion}" instead`);
402
+ }
403
+ // Keep the slug for reference but mark as invalid
404
+ }
405
+ }
406
+
407
+ // Validate appName if present
408
+ if (result.appName) {
409
+ const appNameValidation = validateAppName(result.appName);
410
+ if (!appNameValidation.valid) {
411
+ result.errors.push(`Invalid appName: ${appNameValidation.error}`);
412
+ }
413
+ }
414
+
290
415
  // Read .env.development for siteUrl, assets, and refreshToken (no fallback to config.ts)
291
416
  const envConfig = readEnvConfig(projectPath);
292
417
  result.envFileUsed = envConfig.envFileUsed;
@@ -294,9 +419,17 @@ export function readVueConfig(projectPath) {
294
419
  result.assets = envConfig.assets;
295
420
  result.refreshToken = envConfig.refreshToken;
296
421
 
422
+ // Merge env config errors and warnings
423
+ if (envConfig.errors?.length > 0) {
424
+ result.errors.push(...envConfig.errors);
425
+ }
426
+ if (envConfig.warnings?.length > 0) {
427
+ result.warnings.push(...envConfig.warnings);
428
+ }
429
+
297
430
  // Validate required fields in config.ts
298
431
  if (!result.slug) {
299
- result.errors.push('Missing required field in config.ts: slug (appPath)');
432
+ result.errors.push('Missing required field in config.ts: slug (appSlug)');
300
433
  }
301
434
  if (!result.appName) {
302
435
  result.errors.push('Missing required field in config.ts: appName');
@@ -306,7 +439,7 @@ export function readVueConfig(projectPath) {
306
439
  if (!envConfig.envFileUsed) {
307
440
  result.warnings.push('No .env.development file found');
308
441
  } else {
309
- if (!result.siteUrl) {
442
+ if (!result.siteUrl && !result.errors.some(e => e.includes('VITE_SITE_URL'))) {
310
443
  result.warnings.push('VITE_SITE_URL not set in .env.development');
311
444
  }
312
445
  }
@@ -364,17 +497,22 @@ export function getInjectionTarget(projectPath) {
364
497
  *
365
498
  * @param {string} projectPath - Path to the Vue project
366
499
  * @param {string[]} assets - Array of asset URLs to inject
367
- * @returns {{success: boolean, targetFile: string | null, targetName: string | null}}
500
+ * @returns {{success: boolean, targetFile: string | null, targetName: string | null, error: string | null}}
368
501
  */
369
502
  export function injectAssets(projectPath, assets) {
370
503
  const { targetFile, targetName } = getInjectionTarget(projectPath);
371
504
 
372
505
  if (!targetFile) {
373
- return { success: false, targetFile: null, targetName: null };
506
+ return { success: false, targetFile: null, targetName: null, error: 'No .env.development file found' };
374
507
  }
375
508
 
376
- const success = injectAssetsIntoEnv(targetFile, assets);
377
- return { success, targetFile, targetName };
509
+ const result = injectAssetsIntoEnv(targetFile, assets);
510
+ return {
511
+ success: result.success,
512
+ targetFile,
513
+ targetName,
514
+ error: result.error
515
+ };
378
516
  }
379
517
 
380
518
  /**
@@ -382,16 +520,34 @@ export function injectAssets(projectPath, assets) {
382
520
  *
383
521
  * @param {string} envPath - Path to the .env file
384
522
  * @param {string[]} assets - Array of asset URLs to inject
385
- * @returns {boolean} - True if successful
523
+ * @returns {{success: boolean, error: string | null}}
386
524
  */
387
525
  export function injectAssetsIntoEnv(envPath, assets) {
388
526
  if (!existsSync(envPath)) {
389
- return false;
527
+ return { success: false, error: 'File does not exist' };
528
+ }
529
+
530
+ let content;
531
+ try {
532
+ content = readFileSync(envPath, 'utf-8');
533
+ } catch (err) {
534
+ const errorType = detectErrorType(err);
535
+ if (errorType === ErrorTypes.PERMISSION) {
536
+ return {
537
+ success: false,
538
+ error: `Permission denied reading ${envPath}. Check file permissions.`
539
+ };
540
+ }
541
+ return { success: false, error: `Failed to read file: ${err.message}` };
390
542
  }
391
543
 
392
- let content = readFileSync(envPath, 'utf-8');
544
+ // JSON stringify handles escaping properly
393
545
  const assetsJson = JSON.stringify(assets);
394
- const newLine = `VITE_ASSETS = '${assetsJson}'`;
546
+
547
+ // Escape any single quotes in the JSON to prevent .env parsing issues
548
+ // This is extra safety - JSON.stringify shouldn't produce single quotes
549
+ const safeJson = assetsJson.replace(/'/g, "\\'");
550
+ const newLine = `VITE_ASSETS = '${safeJson}'`;
395
551
 
396
552
  // Check if VITE_ASSETS already exists
397
553
  const assetsPattern = /^VITE_ASSETS\s*=.*$/m;
@@ -403,8 +559,27 @@ export function injectAssetsIntoEnv(envPath, assets) {
403
559
  content = content.trimEnd() + '\n' + newLine + '\n';
404
560
  }
405
561
 
406
- writeFileSync(envPath, content, 'utf-8');
407
- return true;
562
+ try {
563
+ writeFileSync(envPath, content, 'utf-8');
564
+ } catch (err) {
565
+ const errorType = detectErrorType(err);
566
+ if (errorType === ErrorTypes.PERMISSION) {
567
+ return {
568
+ success: false,
569
+ error: `Permission denied writing to ${envPath}. ` +
570
+ `Check file permissions or close any programs using this file.`
571
+ };
572
+ }
573
+ if (errorType === ErrorTypes.DISK_FULL) {
574
+ return {
575
+ success: false,
576
+ error: `Disk full - cannot write to ${envPath}. Free up disk space and try again.`
577
+ };
578
+ }
579
+ return { success: false, error: `Failed to write file: ${err.message}` };
580
+ }
581
+
582
+ return { success: true, error: null };
408
583
  }
409
584
 
410
585
  /**
@@ -422,7 +597,7 @@ Could not find config.ts in the Vue project.
422
597
  Expected location: ${join(projectPath, 'src/config.ts')}
423
598
 
424
599
  Required fields in config.ts:
425
- - appPath (slug): App identifier (used as folder name)
600
+ - appSlug (slug): App identifier (used as folder name)
426
601
  - appName: Display name for navigation menu
427
602
 
428
603
  Required fields in .env.development:
@@ -432,7 +607,7 @@ Required fields in .env.development:
432
607
 
433
608
  Example config.ts:
434
609
  export const config = {
435
- appPath: "my-app",
610
+ appSlug: "my-app",
436
611
  appName: "My Application"
437
612
  }
438
613
 
@@ -2,6 +2,13 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import Config from '../config.js';
4
4
  import { deleteApp } from '../magentrix/api/iris.js';
5
+ import {
6
+ detectErrorType,
7
+ ErrorTypes,
8
+ formatPermissionError,
9
+ formatFileLockError,
10
+ formatNetworkError
11
+ } from './errors.js';
5
12
 
6
13
  const config = new Config();
7
14
 
@@ -53,14 +60,28 @@ export async function deleteIrisAppFromServer(instanceUrl, token, slug, options
53
60
  if (isNotFound) {
54
61
  // App doesn't exist on server, but clean up local cache anyway
55
62
  if (updateCache) {
56
- config.removeKey(`iris-app:${slug}`, { filename: 'base.json' });
57
- result.cleanedFromCache = true;
63
+ try {
64
+ config.removeKey(`iris-app:${slug}`, { filename: 'base.json' });
65
+ result.cleanedFromCache = true;
66
+ } catch {
67
+ // Ignore cache update errors
68
+ }
58
69
  }
59
70
  result.success = true;
60
71
  result.error = 'App not found on server (already deleted)';
61
72
  return result;
62
73
  }
63
74
 
75
+ // Check for network errors and provide helpful messages
76
+ if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
77
+ result.error = formatNetworkError({
78
+ operation: 'delete app',
79
+ url: instanceUrl,
80
+ error
81
+ });
82
+ return result;
83
+ }
84
+
64
85
  result.error = errorMessage;
65
86
  return result;
66
87
  }
@@ -70,7 +91,7 @@ export async function deleteIrisAppFromServer(instanceUrl, token, slug, options
70
91
  * Delete local Iris app files.
71
92
  *
72
93
  * @param {string} appPath - Absolute path to the app folder
73
- * @returns {{success: boolean, existed: boolean, error: string | null, isPermissionError: boolean}}
94
+ * @returns {{success: boolean, existed: boolean, error: string | null, isPermissionError: boolean, isFileLocked: boolean}}
74
95
  */
75
96
  export function deleteLocalIrisAppFiles(appPath) {
76
97
  if (!fs.existsSync(appPath)) {
@@ -78,7 +99,8 @@ export function deleteLocalIrisAppFiles(appPath) {
78
99
  success: true,
79
100
  existed: false,
80
101
  error: null,
81
- isPermissionError: false
102
+ isPermissionError: false,
103
+ isFileLocked: false
82
104
  };
83
105
  }
84
106
 
@@ -88,15 +110,41 @@ export function deleteLocalIrisAppFiles(appPath) {
88
110
  success: true,
89
111
  existed: true,
90
112
  error: null,
91
- isPermissionError: false
113
+ isPermissionError: false,
114
+ isFileLocked: false
92
115
  };
93
116
  } catch (error) {
94
- const isPermissionError = error.code === 'EACCES' || error.code === 'EPERM';
117
+ const errorType = detectErrorType(error);
118
+
119
+ if (errorType === ErrorTypes.PERMISSION) {
120
+ return {
121
+ success: false,
122
+ existed: true,
123
+ error: formatPermissionError({
124
+ operation: 'delete files',
125
+ path: appPath
126
+ }),
127
+ isPermissionError: true,
128
+ isFileLocked: false
129
+ };
130
+ }
131
+
132
+ if (errorType === ErrorTypes.FILE_LOCKED) {
133
+ return {
134
+ success: false,
135
+ existed: true,
136
+ error: formatFileLockError({ path: appPath }),
137
+ isPermissionError: false,
138
+ isFileLocked: true
139
+ };
140
+ }
141
+
95
142
  return {
96
143
  success: false,
97
144
  existed: true,
98
145
  error: error.message,
99
- isPermissionError
146
+ isPermissionError: false,
147
+ isFileLocked: false
100
148
  };
101
149
  }
102
150
  }