@yunsoft/yuncms 0.1.3 → 0.1.6

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.
@@ -0,0 +1,564 @@
1
+ import {
2
+ constants as fsConstants,
3
+ access,
4
+ copyFile,
5
+ cp,
6
+ lstat,
7
+ mkdir,
8
+ readFile,
9
+ realpath,
10
+ rm,
11
+ stat,
12
+ writeFile,
13
+ } from 'node:fs/promises';
14
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
15
+
16
+ import { loadConfig } from '@yunsoft/yuncms-core';
17
+
18
+ import {
19
+ assertBackupAssetType,
20
+ hashDirectory,
21
+ hashFile,
22
+ verifyAssetDigest,
23
+ } from './backup-integrity.js';
24
+ import {
25
+ dumpDatabase,
26
+ restoreDatabase,
27
+ verifyDatabaseDump,
28
+ } from './database-backup.js';
29
+ import { resetDatabaseObjects } from './database-reset.js';
30
+
31
+ const LEGACY_BACKUP_FORMAT_VERSION = 1;
32
+ export const BACKUP_FORMAT_VERSION = 2;
33
+ const SUPPORTED_BACKUP_FORMATS = new Set([LEGACY_BACKUP_FORMAT_VERSION, BACKUP_FORMAT_VERSION]);
34
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
35
+
36
+ async function exists(path) {
37
+ try {
38
+ await access(path, fsConstants.F_OK);
39
+ return true;
40
+ } catch (error) {
41
+ if (error?.code === 'ENOENT') return false;
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ function pathInside(parent, candidate) {
47
+ const rel = relative(resolve(parent), resolve(candidate));
48
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
49
+ }
50
+
51
+ function safeTimestamp(date) {
52
+ return date.toISOString().replace(/[:.]/g, '-');
53
+ }
54
+
55
+ export function defaultBackupPath(cwd, date = new Date()) {
56
+ return join(cwd, '.yuncms', 'backups', safeTimestamp(date));
57
+ }
58
+
59
+ function resolveProjectPath(cwd, value) {
60
+ return isAbsolute(value) ? value : resolve(cwd, value);
61
+ }
62
+
63
+ function manifestError(manifestPath, message) {
64
+ const error = new Error(`Invalid YunCMS backup manifest ${manifestPath}: ${message}`);
65
+ error.code = 'BACKUP_MANIFEST_INVALID';
66
+ error.manifestPath = manifestPath;
67
+ return error;
68
+ }
69
+
70
+ function isObject(value) {
71
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
72
+ }
73
+
74
+ function assertManifestString(value, name, manifestPath, { nullable = false } = {}) {
75
+ if (nullable && value === null) return;
76
+ if (typeof value !== 'string' || !value.trim()) {
77
+ throw manifestError(manifestPath, `${name} must be a non-empty string${nullable ? ' or null' : ''}`);
78
+ }
79
+ }
80
+
81
+ function assertManifestBoolean(value, name, manifestPath) {
82
+ if (typeof value !== 'boolean') throw manifestError(manifestPath, `${name} must be boolean`);
83
+ }
84
+
85
+ function assertDigest(value, name, manifestPath, { nullable = false } = {}) {
86
+ if (nullable && value === null) return;
87
+ if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) {
88
+ throw manifestError(manifestPath, `${name} must be a SHA-256 digest${nullable ? ' or null' : ''}`);
89
+ }
90
+ }
91
+
92
+ function validateBackupManifest(manifest, manifestPath) {
93
+ if (!isObject(manifest)) throw manifestError(manifestPath, 'root must be an object');
94
+ if (!SUPPORTED_BACKUP_FORMATS.has(manifest.format)) {
95
+ throw manifestError(manifestPath, `unsupported backup format ${manifest.format}`);
96
+ }
97
+ if (manifest.complete !== true) throw manifestError(manifestPath, 'backup is incomplete');
98
+ if (typeof manifest.createdAt !== 'string' || Number.isNaN(Date.parse(manifest.createdAt))) {
99
+ throw manifestError(manifestPath, 'createdAt must be a valid timestamp');
100
+ }
101
+
102
+ if (!isObject(manifest.database)) throw manifestError(manifestPath, 'database metadata is required');
103
+ assertManifestString(manifest.database.host, 'database.host', manifestPath);
104
+ if (!Number.isInteger(Number(manifest.database.port)) || Number(manifest.database.port) < 1 || Number(manifest.database.port) > 65535) {
105
+ throw manifestError(manifestPath, 'database.port must be between 1 and 65535');
106
+ }
107
+ assertManifestString(manifest.database.database, 'database.database', manifestPath);
108
+ assertManifestString(manifest.database.user, 'database.user', manifestPath);
109
+ assertManifestBoolean(manifest.database.ssl, 'database.ssl', manifestPath);
110
+ if (!Number.isFinite(Number(manifest.database.verifiedDecompressedBytes)) || Number(manifest.database.verifiedDecompressedBytes) <= 0) {
111
+ throw manifestError(manifestPath, 'database.verifiedDecompressedBytes must be greater than zero');
112
+ }
113
+
114
+ if (!isObject(manifest.project)) throw manifestError(manifestPath, 'project metadata is required');
115
+ for (const key of ['env', 'packageJson', 'packageLock', 'extensions', 'localFiles']) {
116
+ assertManifestBoolean(manifest.project[key], `project.${key}`, manifestPath);
117
+ }
118
+ assertManifestString(manifest.project.localFilesRoot, 'project.localFilesRoot', manifestPath);
119
+
120
+ if (!isObject(manifest.s3)) throw manifestError(manifestPath, 's3 metadata is required');
121
+ assertManifestBoolean(manifest.s3.configured, 's3.configured', manifestPath);
122
+ assertManifestString(manifest.s3.bucket, 's3.bucket', manifestPath, { nullable: true });
123
+ assertManifestBoolean(manifest.s3.objectsBackedUp, 's3.objectsBackedUp', manifestPath);
124
+ if (manifest.s3.configured && !manifest.s3.bucket) {
125
+ throw manifestError(manifestPath, 's3.bucket is required when S3 is configured');
126
+ }
127
+
128
+ if (manifest.format === BACKUP_FORMAT_VERSION) {
129
+ if (!isObject(manifest.integrity) || manifest.integrity.algorithm !== 'sha256') {
130
+ throw manifestError(manifestPath, 'integrity.algorithm must be sha256');
131
+ }
132
+ assertDigest(manifest.integrity.database, 'integrity.database', manifestPath);
133
+ if (!isObject(manifest.integrity.project)) {
134
+ throw manifestError(manifestPath, 'integrity.project is required');
135
+ }
136
+ for (const [key, present] of [
137
+ ['env', manifest.project.env],
138
+ ['packageJson', manifest.project.packageJson],
139
+ ['packageLock', manifest.project.packageLock],
140
+ ['extensions', manifest.project.extensions],
141
+ ['localFiles', manifest.project.localFiles],
142
+ ]) {
143
+ const digest = manifest.integrity.project[key];
144
+ if (present) assertDigest(digest, `integrity.project.${key}`, manifestPath);
145
+ else if (digest !== null) throw manifestError(manifestPath, `integrity.project.${key} must be null when the asset is absent`);
146
+ }
147
+ }
148
+
149
+ return manifest;
150
+ }
151
+
152
+ async function copyOptionalFile(source, target) {
153
+ const present = await assertBackupAssetType(source, 'file', { optional: true });
154
+ if (!present) return false;
155
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
156
+ await copyFile(source, target);
157
+ return true;
158
+ }
159
+
160
+ async function copyOptionalDirectory(source, target) {
161
+ const present = await assertBackupAssetType(source, 'directory', { optional: true });
162
+ if (!present) return false;
163
+ await cp(source, target, { recursive: true, force: false, errorOnExist: true });
164
+ return true;
165
+ }
166
+
167
+ function backupPathConflict(source, destination) {
168
+ const error = new Error(`Backup destination cannot be inside a snapshotted directory: ${source}`);
169
+ error.code = 'BACKUP_PATH_CONFLICT';
170
+ error.sourcePath = source;
171
+ error.backupPath = destination;
172
+ return error;
173
+ }
174
+
175
+ function restorePathConflict(target, backupPath) {
176
+ const error = new Error(`Backup source cannot be inside a directory that restore will replace: ${target}`);
177
+ error.code = 'BACKUP_RESTORE_PATH_CONFLICT';
178
+ error.targetPath = target;
179
+ error.backupPath = backupPath;
180
+ return error;
181
+ }
182
+
183
+ async function assertDirectory(path, code) {
184
+ const info = await stat(path).catch((error) => {
185
+ if (error?.code === 'ENOENT') {
186
+ const missing = new Error(`Backup directory does not exist: ${path}`);
187
+ missing.code = code;
188
+ throw missing;
189
+ }
190
+ throw error;
191
+ });
192
+ if (!info.isDirectory()) {
193
+ const error = new Error(`Expected a backup directory: ${path}`);
194
+ error.code = code;
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ function missingBackupAsset(path) {
200
+ const error = new Error(`Backup is missing an asset declared by its manifest: ${path}`);
201
+ error.code = 'BACKUP_ASSET_MISSING';
202
+ error.assetPath = path;
203
+ return error;
204
+ }
205
+
206
+ async function assertExpectedBackupAssets(backupPath, manifest) {
207
+ const expected = [
208
+ [manifest.project.env, join(backupPath, 'project', '.env'), 'file'],
209
+ [manifest.project.packageJson, join(backupPath, 'project', 'package.json'), 'file'],
210
+ [manifest.project.packageLock, join(backupPath, 'project', 'package-lock.json'), 'file'],
211
+ [manifest.project.extensions, join(backupPath, 'extensions'), 'directory'],
212
+ [manifest.project.localFiles, join(backupPath, 'files'), 'directory'],
213
+ ];
214
+
215
+ for (const [required, path, kind] of expected) {
216
+ if (!required) continue;
217
+ const present = await assertBackupAssetType(path, kind, { optional: true });
218
+ if (!present) throw missingBackupAsset(path);
219
+ }
220
+ }
221
+
222
+ async function calculateBackupIntegrity(destination, presence) {
223
+ return {
224
+ algorithm: 'sha256',
225
+ database: await hashFile(join(destination, 'database.sql.gz')),
226
+ project: {
227
+ env: presence.env ? await hashFile(join(destination, 'project', '.env')) : null,
228
+ packageJson: presence.packageJson ? await hashFile(join(destination, 'project', 'package.json')) : null,
229
+ packageLock: presence.packageLock ? await hashFile(join(destination, 'project', 'package-lock.json')) : null,
230
+ extensions: presence.extensions ? await hashDirectory(join(destination, 'extensions')) : null,
231
+ localFiles: presence.localFiles ? await hashDirectory(join(destination, 'files')) : null,
232
+ },
233
+ };
234
+ }
235
+
236
+ async function verifyBackupIntegrity(backupPath, manifest) {
237
+ if (manifest.format === LEGACY_BACKUP_FORMAT_VERSION) return false;
238
+ await verifyAssetDigest(join(backupPath, 'database.sql.gz'), 'file', manifest.integrity.database);
239
+ for (const [key, relativePath, kind] of [
240
+ ['env', ['project', '.env'], 'file'],
241
+ ['packageJson', ['project', 'package.json'], 'file'],
242
+ ['packageLock', ['project', 'package-lock.json'], 'file'],
243
+ ['extensions', ['extensions'], 'directory'],
244
+ ['localFiles', ['files'], 'directory'],
245
+ ]) {
246
+ if (!manifest.project[key]) continue;
247
+ await verifyAssetDigest(join(backupPath, ...relativePath), kind, manifest.integrity.project[key]);
248
+ }
249
+ return true;
250
+ }
251
+
252
+ async function nearestExistingAncestor(path) {
253
+ let current = resolve(path);
254
+ while (true) {
255
+ try {
256
+ await lstat(current);
257
+ return current;
258
+ } catch (error) {
259
+ if (error?.code !== 'ENOENT') throw error;
260
+ const parent = dirname(current);
261
+ if (parent === current) throw error;
262
+ current = parent;
263
+ }
264
+ }
265
+ }
266
+
267
+ async function assertRestoreTarget(target, kind) {
268
+ let info = null;
269
+ try {
270
+ info = await lstat(target);
271
+ } catch (error) {
272
+ if (error?.code !== 'ENOENT') throw error;
273
+ }
274
+
275
+ if (info?.isSymbolicLink()) {
276
+ const error = new Error(`Restore target cannot be a symbolic link: ${target}`);
277
+ error.code = 'BACKUP_RESTORE_TARGET_INVALID';
278
+ error.targetPath = target;
279
+ throw error;
280
+ }
281
+ if (info && kind === 'file' && !info.isFile()) {
282
+ const error = new Error(`Restore target must be a regular file or absent: ${target}`);
283
+ error.code = 'BACKUP_RESTORE_TARGET_INVALID';
284
+ error.targetPath = target;
285
+ throw error;
286
+ }
287
+ if (info && kind === 'directory' && !info.isDirectory()) {
288
+ const error = new Error(`Restore target must be a directory or absent: ${target}`);
289
+ error.code = 'BACKUP_RESTORE_TARGET_INVALID';
290
+ error.targetPath = target;
291
+ throw error;
292
+ }
293
+
294
+ const writableAncestor = await nearestExistingAncestor(dirname(target));
295
+ try {
296
+ await access(writableAncestor, fsConstants.W_OK);
297
+ } catch (cause) {
298
+ const error = new Error(`Restore target parent is not writable: ${target}`);
299
+ error.code = 'BACKUP_RESTORE_TARGET_UNWRITABLE';
300
+ error.targetPath = target;
301
+ error.cause = cause;
302
+ throw error;
303
+ }
304
+ }
305
+
306
+ async function assertRestoreTargets(cwd, localFilesPath, extensionsPath, allowDifferentDatabaseTarget) {
307
+ await assertRestoreTarget(localFilesPath, 'directory');
308
+ await assertRestoreTarget(extensionsPath, 'directory');
309
+ await assertRestoreTarget(resolve(cwd, 'package.json'), 'file');
310
+ await assertRestoreTarget(resolve(cwd, 'package-lock.json'), 'file');
311
+ if (!allowDifferentDatabaseTarget) await assertRestoreTarget(resolve(cwd, '.env'), 'file');
312
+ }
313
+
314
+ async function effectiveDestinationPath(destination) {
315
+ const parent = dirname(destination);
316
+ await mkdir(parent, { recursive: true, mode: 0o700 });
317
+ const resolvedParent = await realpath(parent);
318
+ return join(resolvedParent, basename(destination));
319
+ }
320
+
321
+ export async function readBackupManifest(backupPath) {
322
+ const resolved = resolve(backupPath);
323
+ await assertDirectory(resolved, 'BACKUP_NOT_FOUND');
324
+ const manifestPath = join(resolved, 'manifest.json');
325
+ let manifest;
326
+ try {
327
+ manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
328
+ } catch (error) {
329
+ if (error?.code === 'ENOENT' || error instanceof SyntaxError) {
330
+ throw manifestError(manifestPath, 'file is missing or invalid JSON');
331
+ }
332
+ throw error;
333
+ }
334
+ validateBackupManifest(manifest, manifestPath);
335
+
336
+ const databaseDumpPath = join(resolved, 'database.sql.gz');
337
+ const dumpPresent = await assertBackupAssetType(databaseDumpPath, 'file', { optional: true });
338
+ if (!dumpPresent) {
339
+ const error = new Error(`Database dump is missing from backup: ${resolved}`);
340
+ error.code = 'BACKUP_DATABASE_MISSING';
341
+ throw error;
342
+ }
343
+ return { backupPath: resolved, manifest };
344
+ }
345
+
346
+ export async function createProjectBackup({
347
+ cwd = process.cwd(),
348
+ env = process.env,
349
+ backupPath = null,
350
+ now = new Date(),
351
+ dumpDatabaseFn = dumpDatabase,
352
+ verifyDatabaseFn = verifyDatabaseDump,
353
+ output = console,
354
+ } = {}) {
355
+ const config = loadConfig(env);
356
+ const destination = resolve(backupPath ?? defaultBackupPath(cwd, now));
357
+ const localFilesPath = resolveProjectPath(cwd, config.storage.localRoot);
358
+ const extensionsPath = resolve(cwd, 'extensions');
359
+
360
+ const sourceKinds = [
361
+ [localFilesPath, 'directory'],
362
+ [extensionsPath, 'directory'],
363
+ ];
364
+ for (const [source, kind] of sourceKinds) {
365
+ await assertBackupAssetType(source, kind, { optional: true });
366
+ }
367
+
368
+ const effectiveDestination = await effectiveDestinationPath(destination);
369
+ for (const [source] of sourceKinds) {
370
+ if (!(await exists(source))) continue;
371
+ const sourceRealPath = await realpath(source);
372
+ if (pathInside(sourceRealPath, effectiveDestination)) throw backupPathConflict(source, destination);
373
+ }
374
+
375
+ try {
376
+ await mkdir(destination, { mode: 0o700 });
377
+ } catch (error) {
378
+ if (error?.code === 'EEXIST') {
379
+ const conflict = new Error(`Backup destination already exists: ${destination}`);
380
+ conflict.code = 'BACKUP_ALREADY_EXISTS';
381
+ throw conflict;
382
+ }
383
+ throw error;
384
+ }
385
+
386
+ try {
387
+ const databaseDumpPath = join(destination, 'database.sql.gz');
388
+ output.log?.(`Creating database backup: ${config.database.database}`);
389
+ await dumpDatabaseFn({ config: config.database, outputPath: databaseDumpPath, env });
390
+ const databaseVerification = await verifyDatabaseFn({ inputPath: databaseDumpPath });
391
+
392
+ const presence = {
393
+ env: await copyOptionalFile(resolve(cwd, '.env'), join(destination, 'project', '.env')),
394
+ packageJson: await copyOptionalFile(resolve(cwd, 'package.json'), join(destination, 'project', 'package.json')),
395
+ packageLock: await copyOptionalFile(resolve(cwd, 'package-lock.json'), join(destination, 'project', 'package-lock.json')),
396
+ extensions: await copyOptionalDirectory(extensionsPath, join(destination, 'extensions')),
397
+ localFiles: await copyOptionalDirectory(localFilesPath, join(destination, 'files')),
398
+ };
399
+ const integrity = await calculateBackupIntegrity(destination, presence);
400
+
401
+ const manifest = {
402
+ format: BACKUP_FORMAT_VERSION,
403
+ complete: true,
404
+ createdAt: now.toISOString(),
405
+ database: {
406
+ host: config.database.host,
407
+ port: config.database.port,
408
+ database: config.database.database,
409
+ user: config.database.user,
410
+ ssl: config.database.ssl,
411
+ verifiedDecompressedBytes: Number(databaseVerification?.decompressedBytes ?? 0),
412
+ },
413
+ project: {
414
+ ...presence,
415
+ localFilesRoot: config.storage.localRoot,
416
+ },
417
+ s3: {
418
+ configured: Boolean(config.storage.s3.bucket),
419
+ bucket: config.storage.s3.bucket || null,
420
+ objectsBackedUp: false,
421
+ },
422
+ integrity,
423
+ };
424
+
425
+ validateBackupManifest(manifest, join(destination, 'manifest.json'));
426
+ await writeFile(
427
+ join(destination, 'manifest.json'),
428
+ `${JSON.stringify(manifest, null, 2)}\n`,
429
+ { encoding: 'utf8', mode: 0o600 },
430
+ );
431
+
432
+ output.log?.(`Backup completed: ${destination}`);
433
+ if (manifest.s3.configured) {
434
+ output.warn?.('S3 objects are not copied by YunCMS backup; provider-side versioning/snapshots are required.');
435
+ }
436
+ return { backupPath: destination, manifest };
437
+ } catch (error) {
438
+ await rm(destination, { recursive: true, force: true }).catch(() => {});
439
+ throw error;
440
+ }
441
+ }
442
+
443
+ function assertRestoreDatabaseMatches(manifest, config, allowDifferentDatabaseTarget) {
444
+ if (allowDifferentDatabaseTarget) return;
445
+ const expected = manifest.database;
446
+ const actual = config.database;
447
+ const matches = expected.database === actual.database
448
+ && expected.host === actual.host
449
+ && Number(expected.port) === Number(actual.port);
450
+ if (!matches) {
451
+ const error = new Error(
452
+ `Backup targets ${expected.host}:${expected.port}/${expected.database}, current environment targets ${actual.host}:${actual.port}/${actual.database}`,
453
+ );
454
+ error.code = 'BACKUP_DATABASE_TARGET_MISMATCH';
455
+ throw error;
456
+ }
457
+ }
458
+
459
+ async function restoreOptionalFile({ backupPath, existed, sourceName, target }) {
460
+ const source = join(backupPath, 'project', sourceName);
461
+ if (!existed) {
462
+ await rm(target, { force: true });
463
+ return;
464
+ }
465
+ await mkdir(dirname(target), { recursive: true });
466
+ await copyFile(source, target);
467
+ }
468
+
469
+ async function restoreOptionalDirectory({ backupPath, existed, sourceName, target }) {
470
+ const source = join(backupPath, sourceName);
471
+ await rm(target, { recursive: true, force: true });
472
+ if (!existed) return;
473
+ await mkdir(dirname(target), { recursive: true });
474
+ await cp(source, target, { recursive: true, force: false, errorOnExist: true });
475
+ }
476
+
477
+ export async function restoreProjectBackup({
478
+ backupPath,
479
+ cwd = process.cwd(),
480
+ env = process.env,
481
+ allowDifferentDatabaseTarget = false,
482
+ restoreDatabaseFn = restoreDatabase,
483
+ resetDatabaseFn = resetDatabaseObjects,
484
+ verifyDatabaseFn = verifyDatabaseDump,
485
+ beforeDestructive = null,
486
+ output = console,
487
+ } = {}) {
488
+ if (!backupPath) {
489
+ const error = new Error('Backup path is required');
490
+ error.code = 'BACKUP_PATH_REQUIRED';
491
+ throw error;
492
+ }
493
+ if (beforeDestructive !== null && typeof beforeDestructive !== 'function') {
494
+ throw new Error('beforeDestructive must be a function when provided');
495
+ }
496
+
497
+ const { backupPath: resolvedBackupPath, manifest } = await readBackupManifest(backupPath);
498
+ const config = loadConfig(env);
499
+ assertRestoreDatabaseMatches(manifest, config, allowDifferentDatabaseTarget);
500
+
501
+ const localFilesPath = resolveProjectPath(cwd, config.storage.localRoot);
502
+ const extensionsPath = resolve(cwd, 'extensions');
503
+ await assertRestoreTargets(cwd, localFilesPath, extensionsPath, allowDifferentDatabaseTarget);
504
+
505
+ const backupRealPath = await realpath(resolvedBackupPath);
506
+ for (const target of [localFilesPath, extensionsPath]) {
507
+ let targetPath = target;
508
+ if (await exists(target)) targetPath = await realpath(target);
509
+ if (pathInside(targetPath, backupRealPath)) throw restorePathConflict(target, resolvedBackupPath);
510
+ }
511
+
512
+ const databaseDumpPath = join(resolvedBackupPath, 'database.sql.gz');
513
+ output.log?.(`Validating backup before destructive restore: ${resolvedBackupPath}`);
514
+ await verifyDatabaseFn({ inputPath: databaseDumpPath });
515
+ await assertExpectedBackupAssets(resolvedBackupPath, manifest);
516
+ const integrityVerified = await verifyBackupIntegrity(resolvedBackupPath, manifest);
517
+ if (!integrityVerified) {
518
+ output.warn?.('Restoring legacy backup format 1 without SHA-256 project asset integrity hashes.');
519
+ }
520
+ if (beforeDestructive) await beforeDestructive();
521
+
522
+ output.log?.(`Resetting database before restore: ${config.database.database}`);
523
+ await resetDatabaseFn({ config: config.database });
524
+ output.log?.(`Restoring database backup: ${resolvedBackupPath}`);
525
+ await restoreDatabaseFn({ config: config.database, inputPath: databaseDumpPath, env });
526
+
527
+ await restoreOptionalDirectory({
528
+ backupPath: resolvedBackupPath,
529
+ existed: manifest.project.localFiles,
530
+ sourceName: 'files',
531
+ target: localFilesPath,
532
+ });
533
+ await restoreOptionalDirectory({
534
+ backupPath: resolvedBackupPath,
535
+ existed: manifest.project.extensions,
536
+ sourceName: 'extensions',
537
+ target: extensionsPath,
538
+ });
539
+ await restoreOptionalFile({
540
+ backupPath: resolvedBackupPath,
541
+ existed: manifest.project.packageJson,
542
+ sourceName: 'package.json',
543
+ target: resolve(cwd, 'package.json'),
544
+ });
545
+ await restoreOptionalFile({
546
+ backupPath: resolvedBackupPath,
547
+ existed: manifest.project.packageLock,
548
+ sourceName: 'package-lock.json',
549
+ target: resolve(cwd, 'package-lock.json'),
550
+ });
551
+ if (allowDifferentDatabaseTarget) {
552
+ output.warn?.('Preserving the current .env because restore targets a different database; the backup .env remains available inside the backup directory.');
553
+ } else {
554
+ await restoreOptionalFile({
555
+ backupPath: resolvedBackupPath,
556
+ existed: manifest.project.env,
557
+ sourceName: '.env',
558
+ target: resolve(cwd, '.env'),
559
+ });
560
+ }
561
+
562
+ output.log?.(`Restore completed: ${resolvedBackupPath}`);
563
+ return { backupPath: resolvedBackupPath, manifest };
564
+ }
@@ -0,0 +1,75 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ import { loadConfig } from '@yunsoft/yuncms-core';
4
+
5
+ import { parseCommandOptions } from './command-options.js';
6
+ import { acquireDatabaseMaintenanceLock } from './maintenance-lock.js';
7
+ import { restoreProjectBackup } from './project-backup.js';
8
+ import { assertYunCmsStopped } from './service-state.js';
9
+ import { acquireUpdateLock } from './update-lock.js';
10
+
11
+ function assertLockContract(lock) {
12
+ if (!lock || typeof lock.assertHeld !== 'function' || typeof lock.release !== 'function') {
13
+ const error = new Error('Database maintenance lock implementation is invalid');
14
+ error.code = 'DATABASE_MAINTENANCE_LOCK_INVALID';
15
+ throw error;
16
+ }
17
+ return lock;
18
+ }
19
+
20
+ export async function runRestoreCommand({
21
+ args = [],
22
+ cwd = process.cwd(),
23
+ env = process.env,
24
+ output = console,
25
+ restoreBackup = restoreProjectBackup,
26
+ acquireLock = acquireUpdateLock,
27
+ acquireMaintenanceLock = acquireDatabaseMaintenanceLock,
28
+ assertStopped = assertYunCmsStopped,
29
+ fetchFn = globalThis.fetch,
30
+ } = {}) {
31
+ const { values, positionals } = parseCommandOptions(args, {
32
+ boolean: ['--yes', '--allow-different-database-target'],
33
+ minPositionals: 1,
34
+ maxPositionals: 1,
35
+ });
36
+
37
+ if (!values['--yes']) {
38
+ const error = new Error('Restore is destructive and requires --yes');
39
+ error.code = 'RESTORE_CONFIRMATION_REQUIRED';
40
+ throw error;
41
+ }
42
+
43
+ const config = loadConfig(env);
44
+ const assertServiceStopped = () => assertStopped({
45
+ host: config.server.host,
46
+ port: config.server.port,
47
+ fetchFn,
48
+ });
49
+
50
+ const lock = await acquireLock({ cwd });
51
+ let maintenanceLock = null;
52
+ try {
53
+ await assertServiceStopped();
54
+ maintenanceLock = assertLockContract(await acquireMaintenanceLock({ env }));
55
+ await assertServiceStopped();
56
+ await maintenanceLock.assertHeld();
57
+
58
+ const beforeDestructive = async () => {
59
+ await assertServiceStopped();
60
+ await maintenanceLock.assertHeld();
61
+ };
62
+
63
+ return await restoreBackup({
64
+ backupPath: resolve(cwd, positionals[0]),
65
+ cwd,
66
+ env,
67
+ output,
68
+ allowDifferentDatabaseTarget: values['--allow-different-database-target'] === true,
69
+ beforeDestructive,
70
+ });
71
+ } finally {
72
+ if (maintenanceLock) await maintenanceLock.release();
73
+ await lock.release();
74
+ }
75
+ }