@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.
- package/LICENSE +25 -25
- package/README.md +1166 -1166
- package/actions/autopublish.old.js +293 -293
- package/actions/config.js +182 -182
- package/actions/create.js +466 -466
- package/actions/help.js +164 -164
- package/actions/iris/buildStage.js +874 -874
- package/actions/iris/delete.js +256 -256
- package/actions/iris/dev.js +391 -391
- package/actions/iris/index.js +6 -6
- package/actions/iris/link.js +375 -375
- package/actions/iris/recover.js +268 -268
- package/actions/main.js +80 -80
- package/actions/publish.js +1420 -1420
- package/actions/pull.js +684 -684
- package/actions/setup.js +148 -148
- package/actions/status.js +17 -17
- package/actions/update.js +248 -248
- package/bin/magentrix.js +393 -393
- package/package.json +55 -55
- package/utils/assetPaths.js +158 -158
- package/utils/autopublishLock.js +77 -77
- package/utils/cacher.js +206 -206
- package/utils/cli/checkInstanceUrl.js +76 -74
- package/utils/cli/helpers/compare.js +282 -282
- package/utils/cli/helpers/ensureApiKey.js +63 -63
- package/utils/cli/helpers/ensureCredentials.js +68 -68
- package/utils/cli/helpers/ensureInstanceUrl.js +75 -75
- package/utils/cli/writeRecords.js +262 -262
- package/utils/compare.js +135 -135
- package/utils/compress.js +17 -17
- package/utils/config.js +527 -527
- package/utils/debug.js +144 -144
- package/utils/diagnostics/testPublishLogic.js +96 -96
- package/utils/diff.js +49 -49
- package/utils/downloadAssets.js +291 -291
- package/utils/filetag.js +115 -115
- package/utils/hash.js +14 -14
- package/utils/iris/backup.js +411 -411
- package/utils/iris/builder.js +541 -541
- package/utils/iris/config-reader.js +664 -664
- package/utils/iris/deleteHelper.js +150 -150
- package/utils/iris/errors.js +537 -537
- package/utils/iris/linker.js +601 -601
- package/utils/iris/lock.js +360 -360
- package/utils/iris/validation.js +360 -360
- package/utils/iris/validator.js +281 -281
- package/utils/iris/zipper.js +248 -248
- package/utils/logger.js +291 -291
- package/utils/magentrix/api/assets.js +220 -220
- package/utils/magentrix/api/auth.js +107 -107
- package/utils/magentrix/api/createEntity.js +61 -61
- package/utils/magentrix/api/deleteEntity.js +55 -55
- package/utils/magentrix/api/iris.js +251 -251
- package/utils/magentrix/api/meqlQuery.js +36 -36
- package/utils/magentrix/api/retrieveEntity.js +86 -86
- package/utils/magentrix/api/updateEntity.js +66 -66
- package/utils/magentrix/fetch.js +168 -168
- package/utils/merge.js +22 -22
- package/utils/permissionError.js +70 -70
- package/utils/preferences.js +40 -40
- package/utils/progress.js +469 -469
- package/utils/spinner.js +43 -43
- package/utils/template.js +52 -52
- package/utils/updateFileBase.js +121 -121
- package/utils/workspaces.js +108 -108
- package/vars/config.js +11 -11
- package/vars/global.js +50 -50
package/utils/iris/backup.js
CHANGED
|
@@ -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
|
+
}
|