@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.
@@ -2,9 +2,21 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { createIrisZip } from './zipper.js';
4
4
  import { extractIrisZip } from './zipper.js';
5
+ import {
6
+ detectErrorType,
7
+ ErrorTypes,
8
+ formatPermissionError,
9
+ formatDiskFullError,
10
+ formatFileLockError
11
+ } from './errors.js';
5
12
 
6
13
  const BACKUP_DIR = '.magentrix/iris-backups';
7
14
 
15
+ /**
16
+ * Maximum backup age in days. Older backups will be cleaned up.
17
+ */
18
+ const MAX_BACKUP_AGE_DAYS = 30;
19
+
8
20
  /**
9
21
  * Create a backup of an Iris app before deletion.
10
22
  *
@@ -16,20 +28,89 @@ const BACKUP_DIR = '.magentrix/iris-backups';
16
28
  * @returns {Promise<{success: boolean, backupPath: string | null, error: string | null}>}
17
29
  */
18
30
  export async function backupIrisApp(appPath, metadata) {
19
- try {
20
- const { slug } = metadata;
21
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
22
- const backupName = `${slug}-${timestamp}`;
23
- const backupPath = path.join(process.cwd(), BACKUP_DIR, backupName);
31
+ const { slug } = metadata;
32
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
33
+ const backupName = `${slug}-${timestamp}`;
34
+ const backupPath = path.join(process.cwd(), BACKUP_DIR, backupName);
24
35
 
25
- // Create backup directory
36
+ try {
37
+ // Ensure backup directory exists
26
38
  fs.mkdirSync(backupPath, { recursive: true });
39
+ } catch (err) {
40
+ const errorType = detectErrorType(err);
41
+ if (errorType === ErrorTypes.PERMISSION) {
42
+ return {
43
+ success: false,
44
+ backupPath: null,
45
+ error: formatPermissionError({
46
+ operation: 'create backup directory',
47
+ path: backupPath
48
+ }),
49
+ isPermissionError: true
50
+ };
51
+ }
52
+ if (errorType === ErrorTypes.DISK_FULL) {
53
+ return {
54
+ success: false,
55
+ backupPath: null,
56
+ error: formatDiskFullError({
57
+ operation: 'create backup',
58
+ path: backupPath
59
+ }),
60
+ isDiskFull: true
61
+ };
62
+ }
63
+ return {
64
+ success: false,
65
+ backupPath: null,
66
+ error: `Failed to create backup directory: ${err.message}`
67
+ };
68
+ }
27
69
 
70
+ try {
28
71
  // Create ZIP of app files
29
72
  const zipBuffer = await createIrisZip(appPath, slug);
30
73
  const zipPath = path.join(backupPath, `${slug}.zip`);
31
74
  fs.writeFileSync(zipPath, zipBuffer);
75
+ } catch (err) {
76
+ // Clean up partial backup
77
+ try {
78
+ fs.rmSync(backupPath, { recursive: true, force: true });
79
+ } catch {
80
+ // Ignore cleanup errors
81
+ }
82
+
83
+ const errorType = detectErrorType(err);
84
+ if (errorType === ErrorTypes.PERMISSION) {
85
+ return {
86
+ success: false,
87
+ backupPath: null,
88
+ error: formatPermissionError({
89
+ operation: 'write backup files',
90
+ path: backupPath
91
+ }),
92
+ isPermissionError: true
93
+ };
94
+ }
95
+ if (errorType === ErrorTypes.DISK_FULL) {
96
+ return {
97
+ success: false,
98
+ backupPath: null,
99
+ error: formatDiskFullError({
100
+ operation: 'create backup',
101
+ path: backupPath
102
+ }),
103
+ isDiskFull: true
104
+ };
105
+ }
106
+ return {
107
+ success: false,
108
+ backupPath: null,
109
+ error: `Failed to create backup ZIP: ${err.message}`
110
+ };
111
+ }
32
112
 
113
+ try {
33
114
  // Save metadata
34
115
  const metadataPath = path.join(backupPath, 'metadata.json');
35
116
  fs.writeFileSync(metadataPath, JSON.stringify({
@@ -38,19 +119,25 @@ export async function backupIrisApp(appPath, metadata) {
38
119
  backupName,
39
120
  originalPath: appPath
40
121
  }, null, 2));
41
-
42
- return {
43
- success: true,
44
- backupPath,
45
- error: null
46
- };
47
- } catch (error) {
122
+ } catch (err) {
123
+ // Clean up partial backup
124
+ try {
125
+ fs.rmSync(backupPath, { recursive: true, force: true });
126
+ } catch {
127
+ // Ignore cleanup errors
128
+ }
48
129
  return {
49
130
  success: false,
50
131
  backupPath: null,
51
- error: error.message
132
+ error: `Failed to save backup metadata: ${err.message}`
52
133
  };
53
134
  }
135
+
136
+ return {
137
+ success: true,
138
+ backupPath,
139
+ error: null
140
+ };
54
141
  }
55
142
 
56
143
  /**
@@ -128,74 +215,197 @@ export async function restoreIrisApp(backupPath, options = {}) {
128
215
  warnings: [],
129
216
  error: null,
130
217
  isPermissionError: false,
218
+ isFileLocked: false,
219
+ isCorrupted: false,
131
220
  targetPath: null
132
221
  };
133
222
 
223
+ // Read metadata
224
+ const metadataPath = path.join(backupPath, 'metadata.json');
225
+ if (!fs.existsSync(metadataPath)) {
226
+ result.error = 'Backup metadata not found. The backup may be incomplete or corrupted.';
227
+ result.isCorrupted = true;
228
+ return result;
229
+ }
230
+
231
+ let metadata;
134
232
  try {
135
- // Read metadata
136
- const metadataPath = path.join(backupPath, 'metadata.json');
137
- if (!fs.existsSync(metadataPath)) {
138
- result.error = 'Backup metadata not found';
139
- return result;
233
+ const metadataContent = fs.readFileSync(metadataPath, 'utf-8');
234
+ metadata = JSON.parse(metadataContent);
235
+ } catch (err) {
236
+ if (err instanceof SyntaxError) {
237
+ result.error = 'Backup metadata is corrupted (invalid JSON). The backup may be damaged.';
238
+ result.isCorrupted = true;
239
+ } else {
240
+ result.error = `Failed to read backup metadata: ${err.message}`;
140
241
  }
242
+ return result;
243
+ }
141
244
 
142
- const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
143
- const { slug, linkedProject } = metadata;
245
+ const { slug, linkedProject } = metadata;
144
246
 
145
- // Restore local files
146
- if (restoreLocal) {
147
- const zipPath = path.join(backupPath, `${slug}.zip`);
148
- if (!fs.existsSync(zipPath)) {
149
- result.error = 'Backup ZIP file not found';
150
- return result;
247
+ if (!slug) {
248
+ result.error = 'Backup metadata is missing required "slug" field. The backup may be corrupted.';
249
+ result.isCorrupted = true;
250
+ return result;
251
+ }
252
+
253
+ // Restore local files
254
+ if (restoreLocal) {
255
+ const zipPath = path.join(backupPath, `${slug}.zip`);
256
+ if (!fs.existsSync(zipPath)) {
257
+ result.error = 'Backup ZIP file not found. The backup may be incomplete.';
258
+ result.isCorrupted = true;
259
+ return result;
260
+ }
261
+
262
+ let zipBuffer;
263
+ try {
264
+ zipBuffer = fs.readFileSync(zipPath);
265
+ } catch (err) {
266
+ const errorType = detectErrorType(err);
267
+ if (errorType === ErrorTypes.PERMISSION) {
268
+ result.error = formatPermissionError({
269
+ operation: 'read backup file',
270
+ path: zipPath
271
+ });
272
+ result.isPermissionError = true;
273
+ } else if (errorType === ErrorTypes.FILE_LOCKED) {
274
+ result.error = formatFileLockError({ path: zipPath });
275
+ result.isFileLocked = true;
276
+ } else {
277
+ result.error = `Failed to read backup file: ${err.message}`;
151
278
  }
279
+ return result;
280
+ }
152
281
 
153
- const zipBuffer = fs.readFileSync(zipPath);
154
- const targetDir = path.join(process.cwd(), 'src', 'iris-apps');
155
- const appTargetDir = path.join(targetDir, slug);
156
- result.targetPath = appTargetDir;
282
+ const targetDir = path.join(process.cwd(), 'src', 'iris-apps');
283
+ const appTargetDir = path.join(targetDir, slug);
284
+ result.targetPath = appTargetDir;
157
285
 
158
- // Ensure target directory exists
286
+ // Ensure target directory exists
287
+ try {
159
288
  fs.mkdirSync(targetDir, { recursive: true });
289
+ } catch (err) {
290
+ const errorType = detectErrorType(err);
291
+ if (errorType === ErrorTypes.PERMISSION) {
292
+ result.error = formatPermissionError({
293
+ operation: 'create restore directory',
294
+ path: targetDir
295
+ });
296
+ result.isPermissionError = true;
297
+ } else {
298
+ result.error = `Failed to create restore directory: ${err.message}`;
299
+ }
300
+ return result;
301
+ }
160
302
 
161
- // Extract backup
303
+ // Extract backup
304
+ try {
162
305
  await extractIrisZip(zipBuffer, targetDir);
163
306
  result.restoredFiles = true;
164
- }
165
-
166
- // Restore linked project
167
- if (restoreLink && linkedProject) {
168
- // Check if the linked project path still exists
169
- if (!fs.existsSync(linkedProject.path)) {
170
- result.linkedProjectPathExists = false;
171
- result.warnings.push(
172
- `Linked Vue project no longer exists at: ${linkedProject.path}`
173
- );
307
+ } catch (err) {
308
+ const errorType = detectErrorType(err);
309
+ if (errorType === ErrorTypes.PERMISSION) {
310
+ result.error = formatPermissionError({
311
+ operation: 'extract backup files',
312
+ path: appTargetDir
313
+ });
314
+ result.isPermissionError = true;
315
+ } else if (errorType === ErrorTypes.FILE_LOCKED) {
316
+ result.error = formatFileLockError({ path: appTargetDir });
317
+ result.isFileLocked = true;
318
+ } else if (errorType === ErrorTypes.DISK_FULL) {
319
+ result.error = formatDiskFullError({
320
+ operation: 'restore backup',
321
+ path: appTargetDir
322
+ });
323
+ } else if (err.message?.includes('invalid') || err.message?.includes('corrupt')) {
324
+ result.error = 'Backup ZIP file appears to be corrupted. The backup cannot be restored.';
325
+ result.isCorrupted = true;
174
326
  } else {
175
- result.restoredLink = true;
327
+ result.error = `Failed to extract backup: ${err.message}`;
176
328
  }
329
+ return result;
177
330
  }
331
+ }
178
332
 
179
- result.success = true;
180
- return result;
181
- } catch (error) {
182
- result.error = error.message;
183
- result.isPermissionError = error.code === 'EACCES' || error.code === 'EPERM';
184
- return result;
333
+ // Restore linked project
334
+ if (restoreLink && linkedProject) {
335
+ // Check if the linked project path still exists
336
+ if (!fs.existsSync(linkedProject.path)) {
337
+ result.linkedProjectPathExists = false;
338
+ result.warnings.push(
339
+ `Linked Vue project no longer exists at: ${linkedProject.path}`
340
+ );
341
+ } else {
342
+ result.restoredLink = true;
343
+ }
185
344
  }
345
+
346
+ result.success = true;
347
+ return result;
186
348
  }
187
349
 
188
350
  /**
189
351
  * Delete a backup folder.
190
352
  *
191
353
  * @param {string} backupPath - Path to the backup folder
354
+ * @returns {{success: boolean, error: string | null}}
192
355
  */
193
356
  export function deleteBackup(backupPath) {
194
357
  try {
195
358
  if (fs.existsSync(backupPath)) {
196
359
  fs.rmSync(backupPath, { recursive: true, force: true });
197
360
  }
198
- } catch {
199
- // Ignore errors
361
+ return { success: true, error: null };
362
+ } catch (err) {
363
+ const errorType = detectErrorType(err);
364
+ if (errorType === ErrorTypes.PERMISSION) {
365
+ return {
366
+ success: false,
367
+ error: formatPermissionError({
368
+ operation: 'delete backup',
369
+ path: backupPath
370
+ })
371
+ };
372
+ }
373
+ if (errorType === ErrorTypes.FILE_LOCKED) {
374
+ return {
375
+ success: false,
376
+ error: formatFileLockError({ path: backupPath })
377
+ };
378
+ }
379
+ return {
380
+ success: false,
381
+ error: `Failed to delete backup: ${err.message}`
382
+ };
200
383
  }
201
384
  }
385
+
386
+ /**
387
+ * Clean up old backups (older than MAX_BACKUP_AGE_DAYS).
388
+ *
389
+ * @returns {{cleaned: number, errors: number}}
390
+ */
391
+ export function cleanupOldBackups() {
392
+ const result = { cleaned: 0, errors: 0 };
393
+ const backups = listBackups();
394
+
395
+ const now = Date.now();
396
+ const maxAge = MAX_BACKUP_AGE_DAYS * 24 * 60 * 60 * 1000;
397
+
398
+ for (const backup of backups) {
399
+ const age = now - new Date(backup.deletedAt).getTime();
400
+ if (age > maxAge) {
401
+ const deleteResult = deleteBackup(backup.backupPath);
402
+ if (deleteResult.success) {
403
+ result.cleaned++;
404
+ } else {
405
+ result.errors++;
406
+ }
407
+ }
408
+ }
409
+
410
+ return result;
411
+ }