@magentrix-corp/magentrix-cli 1.3.10 → 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.
@@ -2,9 +2,68 @@ import { existsSync } from 'node:fs';
2
2
  import { resolve, basename } from 'node:path';
3
3
  import Config from '../config.js';
4
4
  import { readVueConfig } from './config-reader.js';
5
+ import { validateSlug } from './validation.js';
6
+ import { formatError, formatCorruptedConfigError, detectErrorType, ErrorTypes } from './errors.js';
5
7
 
6
8
  const config = new Config();
7
9
 
10
+ /**
11
+ * Safe wrapper for reading config that handles corruption.
12
+ *
13
+ * @param {string} key - Config key to read
14
+ * @param {Object} options - Config options
15
+ * @returns {{success: boolean, data: any, error: string | null}}
16
+ */
17
+ function safeConfigRead(key, options) {
18
+ try {
19
+ const data = config.read(key, options);
20
+ return { success: true, data, error: null };
21
+ } catch (err) {
22
+ const errorType = detectErrorType(err);
23
+ if (errorType === ErrorTypes.CORRUPTED) {
24
+ return {
25
+ success: false,
26
+ data: null,
27
+ error: formatCorruptedConfigError({
28
+ configPath: config.getGlobalConfigPath?.() || '~/.config/magentrix/config.json',
29
+ error: err
30
+ })
31
+ };
32
+ }
33
+ return { success: false, data: null, error: err.message };
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Safe wrapper for saving config that handles errors.
39
+ *
40
+ * @param {string} key - Config key to save
41
+ * @param {any} value - Value to save
42
+ * @param {Object} options - Config options
43
+ * @returns {{success: boolean, error: string | null}}
44
+ */
45
+ function safeConfigSave(key, value, options) {
46
+ try {
47
+ config.save(key, value, options);
48
+ return { success: true, error: null };
49
+ } catch (err) {
50
+ const errorType = detectErrorType(err);
51
+ if (errorType === ErrorTypes.PERMISSION) {
52
+ return {
53
+ success: false,
54
+ error: `Permission denied saving config. Check file permissions for the global config directory.`
55
+ };
56
+ }
57
+ if (errorType === ErrorTypes.DISK_FULL) {
58
+ return {
59
+ success: false,
60
+ error: `Disk full - cannot save config. Free up disk space and try again.`
61
+ };
62
+ }
63
+ return { success: false, error: `Failed to save config: ${err.message}` };
64
+ }
65
+ }
66
+
8
67
  /**
9
68
  * Get all linked Vue projects from global config.
10
69
  *
@@ -17,19 +76,27 @@ const config = new Config();
17
76
  * }>}
18
77
  */
19
78
  export function getLinkedProjects() {
20
- const linkedProjects = config.read('linkedVueProjects', {
79
+ const result = safeConfigRead('linkedVueProjects', {
21
80
  global: true // Store globally so projects persist across Magentrix workspaces
22
81
  });
23
- return linkedProjects || [];
82
+
83
+ if (!result.success) {
84
+ // Log error but return empty array to allow graceful degradation
85
+ console.error(result.error);
86
+ return [];
87
+ }
88
+
89
+ return result.data || [];
24
90
  }
25
91
 
26
92
  /**
27
93
  * Save linked projects to global config.
28
94
  *
29
95
  * @param {Array} projects - Array of linked projects
96
+ * @returns {{success: boolean, error: string | null}}
30
97
  */
31
98
  function saveLinkedProjects(projects) {
32
- config.save('linkedVueProjects', projects, {
99
+ return safeConfigSave('linkedVueProjects', projects, {
33
100
  global: true
34
101
  });
35
102
  }
@@ -230,7 +297,16 @@ export function linkVueProject(projectPath) {
230
297
  appIconId: vueConfig.appIconId,
231
298
  siteUrl: vueConfig.siteUrl
232
299
  };
233
- saveLinkedProjects(projects);
300
+
301
+ const saveResult = saveLinkedProjects(projects);
302
+ if (!saveResult.success) {
303
+ return {
304
+ success: false,
305
+ project: null,
306
+ error: saveResult.error,
307
+ updated: false
308
+ };
309
+ }
234
310
 
235
311
  return {
236
312
  success: true,
@@ -265,7 +341,16 @@ export function linkVueProject(projectPath) {
265
341
  // Add to linked projects
266
342
  const projects = getLinkedProjects();
267
343
  projects.push(newProject);
268
- saveLinkedProjects(projects);
344
+
345
+ const saveResult = saveLinkedProjects(projects);
346
+ if (!saveResult.success) {
347
+ return {
348
+ success: false,
349
+ project: null,
350
+ error: saveResult.error,
351
+ updated: false
352
+ };
353
+ }
269
354
 
270
355
  return {
271
356
  success: true,
@@ -310,7 +395,15 @@ export function unlinkVueProject(projectPathOrSlug) {
310
395
 
311
396
  // Remove from list
312
397
  const [removed] = projects.splice(index, 1);
313
- saveLinkedProjects(projects);
398
+ const saveResult = saveLinkedProjects(projects);
399
+
400
+ if (!saveResult.success) {
401
+ return {
402
+ success: false,
403
+ project: null,
404
+ error: saveResult.error
405
+ };
406
+ }
314
407
 
315
408
  return {
316
409
  success: true,
@@ -323,8 +416,10 @@ export function unlinkVueProject(projectPathOrSlug) {
323
416
  * Remove all invalid (non-existent paths) linked projects.
324
417
  *
325
418
  * @returns {{
419
+ * success: boolean,
326
420
  * removed: number,
327
- * projects: Array<{path: string, slug: string, appName: string}>
421
+ * projects: Array<{path: string, slug: string, appName: string}>,
422
+ * error: string | null
328
423
  * }}
329
424
  */
330
425
  export function cleanupInvalidProjects() {
@@ -341,12 +436,22 @@ export function cleanupInvalidProjects() {
341
436
  }
342
437
 
343
438
  if (removedProjects.length > 0) {
344
- saveLinkedProjects(validProjects);
439
+ const saveResult = saveLinkedProjects(validProjects);
440
+ if (!saveResult.success) {
441
+ return {
442
+ success: false,
443
+ removed: 0,
444
+ projects: [],
445
+ error: saveResult.error
446
+ };
447
+ }
345
448
  }
346
449
 
347
450
  return {
451
+ success: true,
348
452
  removed: removedProjects.length,
349
- projects: removedProjects
453
+ projects: removedProjects,
454
+ error: null
350
455
  };
351
456
  }
352
457
 
@@ -354,20 +459,20 @@ export function cleanupInvalidProjects() {
354
459
  * Update the last build timestamp for a linked project.
355
460
  *
356
461
  * @param {string} slug - The app slug
357
- * @returns {boolean} - True if updated, false if not found
462
+ * @returns {{success: boolean, error: string | null}}
358
463
  */
359
464
  export function updateLastBuild(slug) {
360
465
  const projects = getLinkedProjects();
361
466
  const index = projects.findIndex(p => p.slug === slug);
362
467
 
363
468
  if (index === -1) {
364
- return false;
469
+ return { success: false, error: 'Project not found' };
365
470
  }
366
471
 
367
472
  projects[index].lastBuild = new Date().toISOString();
368
- saveLinkedProjects(projects);
473
+ const saveResult = saveLinkedProjects(projects);
369
474
 
370
- return true;
475
+ return saveResult;
371
476
  }
372
477
 
373
478
  /**
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Operation locking system for Iris Vue integration.
3
+ * Prevents concurrent operations that could cause data corruption.
4
+ */
5
+
6
+ import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
7
+ import { join, dirname } from 'node:path';
8
+ import { formatConcurrentOperationError } from './errors.js';
9
+
10
+ /**
11
+ * Lock directory relative to workspace.
12
+ */
13
+ const LOCK_DIR = '.magentrix/locks';
14
+
15
+ /**
16
+ * Lock types for different operations.
17
+ */
18
+ export const LockTypes = {
19
+ BUILD: 'build',
20
+ STAGE: 'stage',
21
+ PUBLISH: 'publish',
22
+ DELETE: 'delete',
23
+ RECOVER: 'recover',
24
+ DEV_SERVER: 'dev-server'
25
+ };
26
+
27
+ /**
28
+ * Default lock timeout in milliseconds (5 minutes).
29
+ * Locks older than this are considered stale and can be overwritten.
30
+ */
31
+ const DEFAULT_LOCK_TIMEOUT = 5 * 60 * 1000;
32
+
33
+ /**
34
+ * Get the lock file path for a given lock type and context.
35
+ *
36
+ * @param {string} lockType - One of LockTypes
37
+ * @param {string} context - Context identifier (e.g., workspace path, project slug)
38
+ * @param {string} basePath - Base path for lock files (defaults to cwd)
39
+ * @returns {string} - Path to the lock file
40
+ */
41
+ function getLockPath(lockType, context = 'global', basePath = process.cwd()) {
42
+ const lockDir = join(basePath, LOCK_DIR);
43
+ const safeContext = context.replace(/[^a-zA-Z0-9-_]/g, '_');
44
+ return join(lockDir, `${lockType}-${safeContext}.lock`);
45
+ }
46
+
47
+ /**
48
+ * Create lock file data.
49
+ *
50
+ * @param {string} operation - Description of the operation
51
+ * @returns {Object} - Lock data
52
+ */
53
+ function createLockData(operation) {
54
+ return {
55
+ pid: process.pid,
56
+ operation,
57
+ startedAt: new Date().toISOString(),
58
+ hostname: process.env.HOSTNAME || process.env.COMPUTERNAME || 'unknown'
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Check if a lock is stale (older than timeout).
64
+ *
65
+ * @param {Object} lockData - The lock file data
66
+ * @param {number} timeout - Timeout in milliseconds
67
+ * @returns {boolean} - True if lock is stale
68
+ */
69
+ function isLockStale(lockData, timeout = DEFAULT_LOCK_TIMEOUT) {
70
+ if (!lockData.startedAt) {
71
+ return true;
72
+ }
73
+
74
+ const startTime = new Date(lockData.startedAt).getTime();
75
+ const now = Date.now();
76
+
77
+ return (now - startTime) > timeout;
78
+ }
79
+
80
+ /**
81
+ * Check if the process that created the lock is still running.
82
+ *
83
+ * @param {number} pid - Process ID to check
84
+ * @returns {boolean} - True if process is running
85
+ */
86
+ function isProcessRunning(pid) {
87
+ try {
88
+ // Sending signal 0 checks if process exists without killing it
89
+ process.kill(pid, 0);
90
+ return true;
91
+ } catch (err) {
92
+ // ESRCH means process doesn't exist
93
+ // EPERM means process exists but we don't have permission
94
+ return err.code === 'EPERM';
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Acquire a lock for an operation.
100
+ *
101
+ * @param {string} lockType - One of LockTypes
102
+ * @param {Object} options - Lock options
103
+ * @param {string} options.context - Context identifier
104
+ * @param {string} options.operation - Description of the operation
105
+ * @param {string} options.basePath - Base path for lock files
106
+ * @param {number} options.timeout - Lock timeout in milliseconds
107
+ * @param {boolean} options.force - Force acquire even if locked
108
+ * @returns {{
109
+ * acquired: boolean,
110
+ * lockPath: string,
111
+ * error: string | null,
112
+ * existingLock: Object | null
113
+ * }}
114
+ */
115
+ export function acquireLock(lockType, options = {}) {
116
+ const {
117
+ context = 'global',
118
+ operation = lockType,
119
+ basePath = process.cwd(),
120
+ timeout = DEFAULT_LOCK_TIMEOUT,
121
+ force = false
122
+ } = options;
123
+
124
+ const lockPath = getLockPath(lockType, context, basePath);
125
+ const lockDir = dirname(lockPath);
126
+
127
+ const result = {
128
+ acquired: false,
129
+ lockPath,
130
+ error: null,
131
+ existingLock: null
132
+ };
133
+
134
+ // Ensure lock directory exists
135
+ try {
136
+ if (!existsSync(lockDir)) {
137
+ mkdirSync(lockDir, { recursive: true });
138
+ }
139
+ } catch (err) {
140
+ result.error = `Failed to create lock directory: ${err.message}`;
141
+ return result;
142
+ }
143
+
144
+ // Check for existing lock
145
+ if (existsSync(lockPath)) {
146
+ try {
147
+ const existingData = JSON.parse(readFileSync(lockPath, 'utf-8'));
148
+ result.existingLock = existingData;
149
+
150
+ // Check if lock is stale or process is dead
151
+ const stale = isLockStale(existingData, timeout);
152
+ const processGone = existingData.pid && !isProcessRunning(existingData.pid);
153
+
154
+ if (!force && !stale && !processGone) {
155
+ // Lock is valid and held by another process
156
+ result.error = formatConcurrentOperationError({
157
+ operation,
158
+ lockHolder: `${existingData.operation} (PID: ${existingData.pid})`,
159
+ lockFile: lockPath
160
+ });
161
+ return result;
162
+ }
163
+
164
+ // Lock is stale or orphaned, we can take over
165
+ console.log(`Note: Removed stale lock from ${existingData.operation}`);
166
+ } catch (err) {
167
+ // Invalid lock file, we can overwrite it
168
+ console.log(`Note: Removed invalid lock file`);
169
+ }
170
+ }
171
+
172
+ // Create new lock
173
+ try {
174
+ const lockData = createLockData(operation);
175
+ writeFileSync(lockPath, JSON.stringify(lockData, null, 2), 'utf-8');
176
+ result.acquired = true;
177
+ } catch (err) {
178
+ result.error = `Failed to create lock: ${err.message}`;
179
+ return result;
180
+ }
181
+
182
+ return result;
183
+ }
184
+
185
+ /**
186
+ * Release a lock.
187
+ *
188
+ * @param {string} lockType - One of LockTypes
189
+ * @param {Object} options - Options
190
+ * @param {string} options.context - Context identifier
191
+ * @param {string} options.basePath - Base path for lock files
192
+ * @returns {boolean} - True if lock was released
193
+ */
194
+ export function releaseLock(lockType, options = {}) {
195
+ const {
196
+ context = 'global',
197
+ basePath = process.cwd()
198
+ } = options;
199
+
200
+ const lockPath = getLockPath(lockType, context, basePath);
201
+
202
+ try {
203
+ if (existsSync(lockPath)) {
204
+ // Verify this is our lock before deleting
205
+ try {
206
+ const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
207
+ if (lockData.pid !== process.pid) {
208
+ // Not our lock, don't delete
209
+ return false;
210
+ }
211
+ } catch {
212
+ // Can't read lock, assume it's okay to delete
213
+ }
214
+
215
+ unlinkSync(lockPath);
216
+ }
217
+ return true;
218
+ } catch (err) {
219
+ // Failed to release, but don't crash
220
+ console.log(`Warning: Could not release lock: ${err.message}`);
221
+ return false;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Check if an operation is locked.
227
+ *
228
+ * @param {string} lockType - One of LockTypes
229
+ * @param {Object} options - Options
230
+ * @param {string} options.context - Context identifier
231
+ * @param {string} options.basePath - Base path for lock files
232
+ * @param {number} options.timeout - Lock timeout in milliseconds
233
+ * @returns {{
234
+ * locked: boolean,
235
+ * lockData: Object | null,
236
+ * stale: boolean
237
+ * }}
238
+ */
239
+ export function checkLock(lockType, options = {}) {
240
+ const {
241
+ context = 'global',
242
+ basePath = process.cwd(),
243
+ timeout = DEFAULT_LOCK_TIMEOUT
244
+ } = options;
245
+
246
+ const lockPath = getLockPath(lockType, context, basePath);
247
+
248
+ const result = {
249
+ locked: false,
250
+ lockData: null,
251
+ stale: false
252
+ };
253
+
254
+ if (!existsSync(lockPath)) {
255
+ return result;
256
+ }
257
+
258
+ try {
259
+ const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
260
+ result.lockData = lockData;
261
+
262
+ const stale = isLockStale(lockData, timeout);
263
+ const processGone = lockData.pid && !isProcessRunning(lockData.pid);
264
+
265
+ result.stale = stale || processGone;
266
+ result.locked = !result.stale;
267
+ } catch {
268
+ // Invalid lock file
269
+ result.stale = true;
270
+ }
271
+
272
+ return result;
273
+ }
274
+
275
+ /**
276
+ * Execute a function with a lock.
277
+ * Automatically acquires lock before execution and releases after.
278
+ *
279
+ * @param {string} lockType - One of LockTypes
280
+ * @param {Function} fn - Async function to execute
281
+ * @param {Object} options - Lock options
282
+ * @returns {Promise<*>} - Result of the function
283
+ */
284
+ export async function withLock(lockType, fn, options = {}) {
285
+ const lockResult = acquireLock(lockType, options);
286
+
287
+ if (!lockResult.acquired) {
288
+ throw new Error(lockResult.error || 'Failed to acquire lock');
289
+ }
290
+
291
+ try {
292
+ return await fn();
293
+ } finally {
294
+ releaseLock(lockType, options);
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Clean up all stale locks in a directory.
300
+ *
301
+ * @param {string} basePath - Base path for lock files
302
+ * @param {number} timeout - Lock timeout in milliseconds
303
+ * @returns {number} - Number of locks cleaned up
304
+ */
305
+ export function cleanupStaleLocks(basePath = process.cwd(), timeout = DEFAULT_LOCK_TIMEOUT) {
306
+ const lockDir = join(basePath, LOCK_DIR);
307
+ let cleaned = 0;
308
+
309
+ if (!existsSync(lockDir)) {
310
+ return 0;
311
+ }
312
+
313
+ try {
314
+ const { readdirSync } = require('node:fs');
315
+ const files = readdirSync(lockDir);
316
+
317
+ for (const file of files) {
318
+ if (!file.endsWith('.lock')) continue;
319
+
320
+ const lockPath = join(lockDir, file);
321
+ try {
322
+ const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
323
+
324
+ if (isLockStale(lockData, timeout) || !isProcessRunning(lockData.pid)) {
325
+ unlinkSync(lockPath);
326
+ cleaned++;
327
+ }
328
+ } catch {
329
+ // Invalid lock file, remove it
330
+ try {
331
+ unlinkSync(lockPath);
332
+ cleaned++;
333
+ } catch {
334
+ // Couldn't delete, skip
335
+ }
336
+ }
337
+ }
338
+ } catch {
339
+ // Can't read directory, skip cleanup
340
+ }
341
+
342
+ return cleaned;
343
+ }
344
+
345
+ /**
346
+ * Create a project-specific lock context from a path.
347
+ *
348
+ * @param {string} projectPath - Path to the project
349
+ * @returns {string} - Lock context identifier
350
+ */
351
+ export function createProjectContext(projectPath) {
352
+ // Create a short hash-like identifier from the path
353
+ let hash = 0;
354
+ for (let i = 0; i < projectPath.length; i++) {
355
+ const char = projectPath.charCodeAt(i);
356
+ hash = ((hash << 5) - hash) + char;
357
+ hash = hash & hash; // Convert to 32bit integer
358
+ }
359
+ return `project-${Math.abs(hash).toString(36)}`;
360
+ }