@magentrix-corp/magentrix-cli 1.3.16 → 1.3.17

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.
Files changed (68) hide show
  1. package/LICENSE +25 -25
  2. package/README.md +1166 -1166
  3. package/actions/autopublish.old.js +293 -293
  4. package/actions/config.js +182 -182
  5. package/actions/create.js +466 -466
  6. package/actions/help.js +164 -164
  7. package/actions/iris/buildStage.js +874 -874
  8. package/actions/iris/delete.js +256 -256
  9. package/actions/iris/dev.js +391 -391
  10. package/actions/iris/index.js +6 -6
  11. package/actions/iris/link.js +375 -375
  12. package/actions/iris/recover.js +268 -268
  13. package/actions/main.js +80 -80
  14. package/actions/publish.js +1420 -1420
  15. package/actions/pull.js +684 -684
  16. package/actions/setup.js +148 -148
  17. package/actions/status.js +17 -17
  18. package/actions/update.js +248 -248
  19. package/bin/magentrix.js +393 -393
  20. package/package.json +55 -55
  21. package/utils/assetPaths.js +158 -158
  22. package/utils/autopublishLock.js +77 -77
  23. package/utils/cacher.js +206 -206
  24. package/utils/cli/checkInstanceUrl.js +76 -74
  25. package/utils/cli/helpers/compare.js +282 -282
  26. package/utils/cli/helpers/ensureApiKey.js +63 -63
  27. package/utils/cli/helpers/ensureCredentials.js +68 -68
  28. package/utils/cli/helpers/ensureInstanceUrl.js +75 -75
  29. package/utils/cli/writeRecords.js +262 -262
  30. package/utils/compare.js +135 -135
  31. package/utils/compress.js +17 -17
  32. package/utils/config.js +527 -527
  33. package/utils/debug.js +144 -144
  34. package/utils/diagnostics/testPublishLogic.js +96 -96
  35. package/utils/diff.js +49 -49
  36. package/utils/downloadAssets.js +291 -291
  37. package/utils/filetag.js +115 -115
  38. package/utils/hash.js +14 -14
  39. package/utils/iris/backup.js +411 -411
  40. package/utils/iris/builder.js +541 -541
  41. package/utils/iris/config-reader.js +664 -664
  42. package/utils/iris/deleteHelper.js +150 -150
  43. package/utils/iris/errors.js +537 -537
  44. package/utils/iris/linker.js +601 -601
  45. package/utils/iris/lock.js +360 -360
  46. package/utils/iris/validation.js +360 -360
  47. package/utils/iris/validator.js +281 -281
  48. package/utils/iris/zipper.js +248 -248
  49. package/utils/logger.js +291 -291
  50. package/utils/magentrix/api/assets.js +220 -220
  51. package/utils/magentrix/api/auth.js +107 -107
  52. package/utils/magentrix/api/createEntity.js +61 -61
  53. package/utils/magentrix/api/deleteEntity.js +55 -55
  54. package/utils/magentrix/api/iris.js +251 -251
  55. package/utils/magentrix/api/meqlQuery.js +36 -36
  56. package/utils/magentrix/api/retrieveEntity.js +86 -86
  57. package/utils/magentrix/api/updateEntity.js +66 -66
  58. package/utils/magentrix/fetch.js +168 -168
  59. package/utils/merge.js +22 -22
  60. package/utils/permissionError.js +70 -70
  61. package/utils/preferences.js +40 -40
  62. package/utils/progress.js +469 -469
  63. package/utils/spinner.js +43 -43
  64. package/utils/template.js +52 -52
  65. package/utils/updateFileBase.js +121 -121
  66. package/utils/workspaces.js +108 -108
  67. package/vars/config.js +11 -11
  68. package/vars/global.js +50 -50
@@ -1,411 +1,411 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import { createIrisZip } from './zipper.js';
4
- import { extractIrisZip } from './zipper.js';
5
- import {
6
- detectErrorType,
7
- ErrorTypes,
8
- formatPermissionError,
9
- formatDiskFullError,
10
- formatFileLockError
11
- } from './errors.js';
12
-
13
- const BACKUP_DIR = '.magentrix/iris-backups';
14
-
15
- /**
16
- * Maximum backup age in days. Older backups will be cleaned up.
17
- */
18
- const MAX_BACKUP_AGE_DAYS = 30;
19
-
20
- /**
21
- * Create a backup of an Iris app before deletion.
22
- *
23
- * @param {string} appPath - Path to the Iris app folder (e.g., src/iris-apps/my-app)
24
- * @param {object} metadata - Additional metadata to save
25
- * @param {string} metadata.slug - App slug
26
- * @param {string} metadata.appName - App display name
27
- * @param {object} [metadata.linkedProject] - Linked Vue project info (if any)
28
- * @returns {Promise<{success: boolean, backupPath: string | null, error: string | null}>}
29
- */
30
- export async function backupIrisApp(appPath, metadata) {
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);
35
-
36
- try {
37
- // Ensure backup directory exists
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
- }
69
-
70
- try {
71
- // Create ZIP of app files
72
- const zipBuffer = await createIrisZip(appPath, slug);
73
- const zipPath = path.join(backupPath, `${slug}.zip`);
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
- }
112
-
113
- try {
114
- // Save metadata
115
- const metadataPath = path.join(backupPath, 'metadata.json');
116
- fs.writeFileSync(metadataPath, JSON.stringify({
117
- ...metadata,
118
- deletedAt: new Date().toISOString(),
119
- backupName,
120
- originalPath: appPath
121
- }, null, 2));
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
- }
129
- return {
130
- success: false,
131
- backupPath: null,
132
- error: `Failed to save backup metadata: ${err.message}`
133
- };
134
- }
135
-
136
- return {
137
- success: true,
138
- backupPath,
139
- error: null
140
- };
141
- }
142
-
143
- /**
144
- * List all available Iris app backups.
145
- *
146
- * @returns {Array<{
147
- * backupName: string,
148
- * slug: string,
149
- * appName: string,
150
- * deletedAt: string,
151
- * linkedProject: object | null,
152
- * backupPath: string
153
- * }>}
154
- */
155
- export function listBackups() {
156
- const backupsDir = path.join(process.cwd(), BACKUP_DIR);
157
-
158
- if (!fs.existsSync(backupsDir)) {
159
- return [];
160
- }
161
-
162
- const backups = [];
163
- const entries = fs.readdirSync(backupsDir, { withFileTypes: true });
164
-
165
- for (const entry of entries) {
166
- if (!entry.isDirectory()) continue;
167
-
168
- const backupPath = path.join(backupsDir, entry.name);
169
- const metadataPath = path.join(backupPath, 'metadata.json');
170
-
171
- if (!fs.existsSync(metadataPath)) continue;
172
-
173
- try {
174
- const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
175
- backups.push({
176
- ...metadata,
177
- backupPath
178
- });
179
- } catch {
180
- // Skip invalid backups
181
- }
182
- }
183
-
184
- // Sort by deletedAt (newest first)
185
- return backups.sort((a, b) =>
186
- new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime()
187
- );
188
- }
189
-
190
- /**
191
- * Restore an Iris app from backup.
192
- *
193
- * @param {string} backupPath - Path to the backup folder
194
- * @param {object} options - Recovery options
195
- * @param {boolean} options.restoreLink - Whether to restore the linked project (default: true)
196
- * @param {boolean} options.restoreLocal - Whether to restore local files (default: true)
197
- * @returns {Promise<{
198
- * success: boolean,
199
- * restoredFiles: boolean,
200
- * restoredLink: boolean,
201
- * linkedProjectPathExists: boolean,
202
- * warnings: string[],
203
- * error: string | null,
204
- * isPermissionError: boolean,
205
- * targetPath: string | null
206
- * }>}
207
- */
208
- export async function restoreIrisApp(backupPath, options = {}) {
209
- const { restoreLink = true, restoreLocal = true } = options;
210
- const result = {
211
- success: false,
212
- restoredFiles: false,
213
- restoredLink: false,
214
- linkedProjectPathExists: true,
215
- warnings: [],
216
- error: null,
217
- isPermissionError: false,
218
- isFileLocked: false,
219
- isCorrupted: false,
220
- targetPath: null
221
- };
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;
232
- try {
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}`;
241
- }
242
- return result;
243
- }
244
-
245
- const { slug, linkedProject } = metadata;
246
-
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}`;
278
- }
279
- return result;
280
- }
281
-
282
- const targetDir = path.join(process.cwd(), 'src', 'iris-apps');
283
- const appTargetDir = path.join(targetDir, slug);
284
- result.targetPath = appTargetDir;
285
-
286
- // Ensure target directory exists
287
- try {
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
- }
302
-
303
- // Extract backup
304
- try {
305
- await extractIrisZip(zipBuffer, targetDir);
306
- result.restoredFiles = true;
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;
326
- } else {
327
- result.error = `Failed to extract backup: ${err.message}`;
328
- }
329
- return result;
330
- }
331
- }
332
-
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
- }
344
- }
345
-
346
- result.success = true;
347
- return result;
348
- }
349
-
350
- /**
351
- * Delete a backup folder.
352
- *
353
- * @param {string} backupPath - Path to the backup folder
354
- * @returns {{success: boolean, error: string | null}}
355
- */
356
- export function deleteBackup(backupPath) {
357
- try {
358
- if (fs.existsSync(backupPath)) {
359
- fs.rmSync(backupPath, { recursive: true, force: true });
360
- }
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
- };
383
- }
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
- }
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { createIrisZip } from './zipper.js';
4
+ import { extractIrisZip } from './zipper.js';
5
+ import {
6
+ detectErrorType,
7
+ ErrorTypes,
8
+ formatPermissionError,
9
+ formatDiskFullError,
10
+ formatFileLockError
11
+ } from './errors.js';
12
+
13
+ const BACKUP_DIR = '.magentrix/iris-backups';
14
+
15
+ /**
16
+ * Maximum backup age in days. Older backups will be cleaned up.
17
+ */
18
+ const MAX_BACKUP_AGE_DAYS = 30;
19
+
20
+ /**
21
+ * Create a backup of an Iris app before deletion.
22
+ *
23
+ * @param {string} appPath - Path to the Iris app folder (e.g., src/iris-apps/my-app)
24
+ * @param {object} metadata - Additional metadata to save
25
+ * @param {string} metadata.slug - App slug
26
+ * @param {string} metadata.appName - App display name
27
+ * @param {object} [metadata.linkedProject] - Linked Vue project info (if any)
28
+ * @returns {Promise<{success: boolean, backupPath: string | null, error: string | null}>}
29
+ */
30
+ export async function backupIrisApp(appPath, metadata) {
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);
35
+
36
+ try {
37
+ // Ensure backup directory exists
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
+ }
69
+
70
+ try {
71
+ // Create ZIP of app files
72
+ const zipBuffer = await createIrisZip(appPath, slug);
73
+ const zipPath = path.join(backupPath, `${slug}.zip`);
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
+ }
112
+
113
+ try {
114
+ // Save metadata
115
+ const metadataPath = path.join(backupPath, 'metadata.json');
116
+ fs.writeFileSync(metadataPath, JSON.stringify({
117
+ ...metadata,
118
+ deletedAt: new Date().toISOString(),
119
+ backupName,
120
+ originalPath: appPath
121
+ }, null, 2));
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
+ }
129
+ return {
130
+ success: false,
131
+ backupPath: null,
132
+ error: `Failed to save backup metadata: ${err.message}`
133
+ };
134
+ }
135
+
136
+ return {
137
+ success: true,
138
+ backupPath,
139
+ error: null
140
+ };
141
+ }
142
+
143
+ /**
144
+ * List all available Iris app backups.
145
+ *
146
+ * @returns {Array<{
147
+ * backupName: string,
148
+ * slug: string,
149
+ * appName: string,
150
+ * deletedAt: string,
151
+ * linkedProject: object | null,
152
+ * backupPath: string
153
+ * }>}
154
+ */
155
+ export function listBackups() {
156
+ const backupsDir = path.join(process.cwd(), BACKUP_DIR);
157
+
158
+ if (!fs.existsSync(backupsDir)) {
159
+ return [];
160
+ }
161
+
162
+ const backups = [];
163
+ const entries = fs.readdirSync(backupsDir, { withFileTypes: true });
164
+
165
+ for (const entry of entries) {
166
+ if (!entry.isDirectory()) continue;
167
+
168
+ const backupPath = path.join(backupsDir, entry.name);
169
+ const metadataPath = path.join(backupPath, 'metadata.json');
170
+
171
+ if (!fs.existsSync(metadataPath)) continue;
172
+
173
+ try {
174
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
175
+ backups.push({
176
+ ...metadata,
177
+ backupPath
178
+ });
179
+ } catch {
180
+ // Skip invalid backups
181
+ }
182
+ }
183
+
184
+ // Sort by deletedAt (newest first)
185
+ return backups.sort((a, b) =>
186
+ new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime()
187
+ );
188
+ }
189
+
190
+ /**
191
+ * Restore an Iris app from backup.
192
+ *
193
+ * @param {string} backupPath - Path to the backup folder
194
+ * @param {object} options - Recovery options
195
+ * @param {boolean} options.restoreLink - Whether to restore the linked project (default: true)
196
+ * @param {boolean} options.restoreLocal - Whether to restore local files (default: true)
197
+ * @returns {Promise<{
198
+ * success: boolean,
199
+ * restoredFiles: boolean,
200
+ * restoredLink: boolean,
201
+ * linkedProjectPathExists: boolean,
202
+ * warnings: string[],
203
+ * error: string | null,
204
+ * isPermissionError: boolean,
205
+ * targetPath: string | null
206
+ * }>}
207
+ */
208
+ export async function restoreIrisApp(backupPath, options = {}) {
209
+ const { restoreLink = true, restoreLocal = true } = options;
210
+ const result = {
211
+ success: false,
212
+ restoredFiles: false,
213
+ restoredLink: false,
214
+ linkedProjectPathExists: true,
215
+ warnings: [],
216
+ error: null,
217
+ isPermissionError: false,
218
+ isFileLocked: false,
219
+ isCorrupted: false,
220
+ targetPath: null
221
+ };
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;
232
+ try {
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}`;
241
+ }
242
+ return result;
243
+ }
244
+
245
+ const { slug, linkedProject } = metadata;
246
+
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}`;
278
+ }
279
+ return result;
280
+ }
281
+
282
+ const targetDir = path.join(process.cwd(), 'src', 'iris-apps');
283
+ const appTargetDir = path.join(targetDir, slug);
284
+ result.targetPath = appTargetDir;
285
+
286
+ // Ensure target directory exists
287
+ try {
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
+ }
302
+
303
+ // Extract backup
304
+ try {
305
+ await extractIrisZip(zipBuffer, targetDir);
306
+ result.restoredFiles = true;
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;
326
+ } else {
327
+ result.error = `Failed to extract backup: ${err.message}`;
328
+ }
329
+ return result;
330
+ }
331
+ }
332
+
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
+ }
344
+ }
345
+
346
+ result.success = true;
347
+ return result;
348
+ }
349
+
350
+ /**
351
+ * Delete a backup folder.
352
+ *
353
+ * @param {string} backupPath - Path to the backup folder
354
+ * @returns {{success: boolean, error: string | null}}
355
+ */
356
+ export function deleteBackup(backupPath) {
357
+ try {
358
+ if (fs.existsSync(backupPath)) {
359
+ fs.rmSync(backupPath, { recursive: true, force: true });
360
+ }
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
+ };
383
+ }
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
+ }