parrot-blackbox 2.0.1 → 2.0.4
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/package.json +1 -1
- package/src/backup/btrfs-send.js +5 -5
- package/src/backup/snapshot.js +143 -30
- package/src/cli.js +68 -2
- package/src/commands/wizard.js +87 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"description": "Crash-proof, multi-cloud backup & recovery automation for Parrot OS. Daily/weekly off-disk backups with automatic catch-up, smart storage across many MEGA + Google Drive accounts, Timeshift snapshot backups and one-command restore.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.js",
|
package/src/backup/btrfs-send.js
CHANGED
|
@@ -160,7 +160,7 @@ export function getSnapshotParent(manifestPath) {
|
|
|
160
160
|
* Create a BTRFS send stream.
|
|
161
161
|
* @param {string} subvolPath - Path to the snapshot subvolume
|
|
162
162
|
* @param {object} opts - {parent: string|null, privileged: 'interactive'|'noninteractive'}
|
|
163
|
-
* @returns {Promise<
|
|
163
|
+
* @returns {Promise<{stream: ReadableStream, child: ChildProcess}>}
|
|
164
164
|
*/
|
|
165
165
|
export async function createSendStream(subvolPath, { parent = null, privileged = 'noninteractive' } = {}) {
|
|
166
166
|
const args = ['btrfs', 'send'];
|
|
@@ -170,8 +170,9 @@ export async function createSendStream(subvolPath, { parent = null, privileged =
|
|
|
170
170
|
args.push(subvolPath);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
// Spawn via sudo, return the stdout stream
|
|
174
|
-
|
|
173
|
+
// Spawn via sudo, return the stdout stream and the child process so callers
|
|
174
|
+
// can detect errors (btrfs send exits non-zero when the path is not a subvolume).
|
|
175
|
+
const sudoArgs = privileged === 'interactive'
|
|
175
176
|
? ['sudo', '-E', ...args]
|
|
176
177
|
: ['sudo', '-n', ...args];
|
|
177
178
|
|
|
@@ -179,8 +180,7 @@ export async function createSendStream(subvolPath, { parent = null, privileged =
|
|
|
179
180
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
180
181
|
});
|
|
181
182
|
|
|
182
|
-
|
|
183
|
-
return child.stdout;
|
|
183
|
+
return { stream: child.stdout, child };
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
/**
|
package/src/backup/snapshot.js
CHANGED
|
@@ -158,6 +158,8 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
|
|
|
158
158
|
if (privileged === 'interactive') {
|
|
159
159
|
await ensureSudo();
|
|
160
160
|
const res = await sudoInteractive(args);
|
|
161
|
+
// Timeshift exits 0 even when the qgroup destroy fails, so we verify the
|
|
162
|
+
// snapshot is actually gone rather than trusting the exit code alone.
|
|
161
163
|
if (res.exitCode !== 0) throw new Error(`timeshift --delete ${name} failed (exit ${res.exitCode})`);
|
|
162
164
|
return true;
|
|
163
165
|
}
|
|
@@ -168,6 +170,95 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
|
|
|
168
170
|
}
|
|
169
171
|
return true;
|
|
170
172
|
}
|
|
173
|
+
|
|
174
|
+
/** Run btrfs quota rescan -w / and wait for it to finish. */
|
|
175
|
+
async function btrfsQuotaRescan({ privileged = 'interactive', onProgress } = {}) {
|
|
176
|
+
onProgress?.('Running btrfs quota rescan — this may take a moment…');
|
|
177
|
+
const res = privileged === 'interactive'
|
|
178
|
+
? await sudoInteractive(['btrfs', 'quota', 'rescan', '-w', '/'])
|
|
179
|
+
: await sudoNonInteractive(['btrfs', 'quota', 'rescan', '-w', '/']);
|
|
180
|
+
if (res.exitCode !== 0) {
|
|
181
|
+
onProgress?.(`⚠ btrfs quota rescan exited ${res.exitCode} — quotas may not be enabled, continuing`);
|
|
182
|
+
} else {
|
|
183
|
+
onProgress?.('✔ btrfs quota rescan complete');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Delete ALL local Timeshift snapshots.
|
|
189
|
+
*
|
|
190
|
+
* Strategy (matches the confirmed-working pattern from research):
|
|
191
|
+
* 1. Run `btrfs quota rescan -w /` upfront.
|
|
192
|
+
* 2. For each snapshot: attempt delete, then verify it is GONE from
|
|
193
|
+
* `timeshift --list`. Timeshift exits 0 even when it prints
|
|
194
|
+
* "E: Failed to remove snapshot" (qgroup destroy fails silently).
|
|
195
|
+
* Verification catches that.
|
|
196
|
+
* 3. If a snapshot is still present after the first attempt:
|
|
197
|
+
* run another rescan (the delete itself may have left a new stale entry)
|
|
198
|
+
* and retry exactly once.
|
|
199
|
+
* 4. If it still persists after retry, record it as failed and move on.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} opts
|
|
202
|
+
* @param {'interactive'|'noninteractive'} opts.privileged
|
|
203
|
+
* @param {(msg:string)=>void} opts.onProgress optional progress callback
|
|
204
|
+
* @returns {Promise<{deleted:string[], failed:Array<{name:string,error:string}>}>}
|
|
205
|
+
*/
|
|
206
|
+
export async function deleteAllSnapshots({ privileged = 'interactive', onProgress } = {}) {
|
|
207
|
+
if (privileged === 'interactive') await ensureSudo();
|
|
208
|
+
|
|
209
|
+
// Step 1: initial rescan to clear stale qgroup entries.
|
|
210
|
+
await btrfsQuotaRescan({ privileged, onProgress });
|
|
211
|
+
|
|
212
|
+
// Step 2: delete each snapshot, verifying it's actually gone.
|
|
213
|
+
const snapshots = await listLocalSnapshots({ privileged });
|
|
214
|
+
const deleted = [];
|
|
215
|
+
const failed = [];
|
|
216
|
+
|
|
217
|
+
for (const sn of snapshots) {
|
|
218
|
+
onProgress?.(`Deleting snapshot: ${sn.name}`);
|
|
219
|
+
let success = false;
|
|
220
|
+
|
|
221
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
222
|
+
try {
|
|
223
|
+
await deleteSnapshot(sn.name, { privileged });
|
|
224
|
+
} catch {
|
|
225
|
+
// Timeshift may have removed the subvolume but still exit non-zero.
|
|
226
|
+
// Fall through to the verify step.
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Verify the snapshot is actually gone from timeshift --list.
|
|
230
|
+
const remaining = await listLocalSnapshots({ privileged });
|
|
231
|
+
const stillPresent = remaining.some((s) => s.name === sn.name);
|
|
232
|
+
|
|
233
|
+
if (!stillPresent) {
|
|
234
|
+
success = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (attempt === 1) {
|
|
239
|
+
// The delete left a new stale qgroup entry — rescan and retry once.
|
|
240
|
+
onProgress?.(` ⚠ ${sn.name} still present after delete — rescanning qgroups and retrying…`);
|
|
241
|
+
await btrfsQuotaRescan({ privileged, onProgress });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (success) {
|
|
246
|
+
deleted.push(sn.name);
|
|
247
|
+
} else {
|
|
248
|
+
failed.push({ name: sn.name, error: 'still present after 2 attempts + qgroup rescan' });
|
|
249
|
+
onProgress?.(` ✖ ${sn.name}: could not delete after rescan — try deleting it individually`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (failed.length > 0) {
|
|
254
|
+
throw Object.assign(
|
|
255
|
+
new Error(`${failed.length} snapshot(s) could not be deleted`),
|
|
256
|
+
{ deleted, failed },
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { deleted, failed };
|
|
261
|
+
}
|
|
171
262
|
/**
|
|
172
263
|
* Resolve the on-disk directory of a snapshot.
|
|
173
264
|
*
|
|
@@ -401,38 +492,45 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
401
492
|
/**
|
|
402
493
|
* Upload a snapshot using BTRFS send/receive streaming.
|
|
403
494
|
* Creates: btrfs send [-p parent] | zstd | [openssl] | rclone rcat (chunked)
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
495
|
+
*
|
|
496
|
+
* Uses the path resolved by snapshotDirFor() (which mounts the BTRFS root subvolume
|
|
497
|
+
* at a temporary mount point) so that `btrfs send` receives an actual subvolume path,
|
|
498
|
+
* not a directory inside a regular mount.
|
|
408
499
|
*/
|
|
409
500
|
async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, parentDir, accounts, cfg, due, privileged, onProgress }) {
|
|
410
501
|
const { createSendStream, estimateSendSize } = await import('./btrfs-send.js');
|
|
411
502
|
const { planAndPlaceStream } = await import('../storage/allocator.js');
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
const subvolPath =
|
|
417
|
-
const parentSubvolPath =
|
|
418
|
-
|
|
419
|
-
journal('snapshots', `
|
|
420
|
-
|
|
421
|
-
// Estimate size for progress reporting
|
|
422
|
-
const estimatedSize = await estimateSendSize(
|
|
503
|
+
|
|
504
|
+
// snapshotDir was resolved by snapshotDirFor() — it is the real subvolume path
|
|
505
|
+
// (e.g. /run/parrot-blackbox-btrfs-<ts>/timeshift-btrfs/snapshots/<name>).
|
|
506
|
+
// Use it directly; do NOT fall back to a hardcoded absolute path.
|
|
507
|
+
const subvolPath = snapshotDir;
|
|
508
|
+
const parentSubvolPath = parentDir || null;
|
|
509
|
+
|
|
510
|
+
journal('snapshots', `btrfs send subvolPath=${subvolPath} parent=${parentSubvolPath || 'null'}`);
|
|
511
|
+
|
|
512
|
+
// Estimate size for progress reporting
|
|
513
|
+
const estimatedSize = await estimateSendSize(subvolPath, { parent: parentSubvolPath });
|
|
423
514
|
const btrfsCfg = cfg.jobs.snapshots.btrfs || {};
|
|
424
|
-
|
|
425
|
-
console.log(`\n📤 Uploading ${
|
|
515
|
+
|
|
516
|
+
console.log(`\n📤 Uploading ${parentSubvolPath ? 'incremental' : 'full'} BTRFS stream...`);
|
|
426
517
|
console.log(` Estimated size: ${(estimatedSize / (1024 ** 3)).toFixed(2)} GiB`);
|
|
427
|
-
if (btrfsCfg.compression) console.log(` Compression: zstd enabled`);
|
|
428
|
-
if (btrfsCfg.encryption && cfg.storage
|
|
518
|
+
if (btrfsCfg.compression !== false) console.log(` Compression: zstd enabled`);
|
|
519
|
+
if (btrfsCfg.encryption && cfg.storage?.encryptionPassphrase) console.log(` Encryption: AES-256 enabled`);
|
|
429
520
|
|
|
430
|
-
// Create the BTRFS send stream
|
|
431
|
-
const sendStream = await createSendStream(subvolPath, {
|
|
521
|
+
// Create the BTRFS send stream — returns {stream, child} so we can detect errors
|
|
522
|
+
const { stream: sendStream, child: sendChild } = await createSendStream(subvolPath, {
|
|
523
|
+
parent: parentSubvolPath,
|
|
524
|
+
privileged,
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Collect any stderr from btrfs send for better error messages
|
|
528
|
+
const sendStderr = [];
|
|
529
|
+
sendChild.stderr?.on('data', (d) => sendStderr.push(d));
|
|
432
530
|
|
|
433
|
-
// Build
|
|
531
|
+
// Build compression / encryption pipeline
|
|
434
532
|
const { spawn } = await import('node:child_process');
|
|
435
|
-
const pipeline = [];
|
|
533
|
+
const pipeline = [sendChild]; // include send process so we wait on its exit too
|
|
436
534
|
let currentStream = sendStream;
|
|
437
535
|
|
|
438
536
|
// Stage 1: Compression
|
|
@@ -444,7 +542,7 @@ async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, paren
|
|
|
444
542
|
}
|
|
445
543
|
|
|
446
544
|
// Stage 2: Encryption
|
|
447
|
-
if (btrfsCfg.encryption && cfg.storage
|
|
545
|
+
if (btrfsCfg.encryption && cfg.storage?.encryptionPassphrase) {
|
|
448
546
|
const openssl = spawn('openssl', ['enc', '-e', '-aes256', '-pbkdf2', '-pass', `pass:${cfg.storage.encryptionPassphrase}`], {
|
|
449
547
|
stdio: ['pipe', 'pipe', 'inherit'],
|
|
450
548
|
});
|
|
@@ -454,7 +552,7 @@ async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, paren
|
|
|
454
552
|
}
|
|
455
553
|
|
|
456
554
|
// Stage 3: Stream to cloud via allocator's planAndPlaceStream (handles chunking across accounts)
|
|
457
|
-
const
|
|
555
|
+
const manifestPromise = planAndPlaceStream(currentStream, {
|
|
458
556
|
kind: 'snapshots',
|
|
459
557
|
id: snapshot.name,
|
|
460
558
|
accounts,
|
|
@@ -464,11 +562,26 @@ async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, paren
|
|
|
464
562
|
originalSize: estimatedSize,
|
|
465
563
|
});
|
|
466
564
|
|
|
467
|
-
// Wait for all pipeline stages to
|
|
468
|
-
await Promise.all(pipeline.map(proc
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
565
|
+
// Wait for all pipeline stages (including btrfs send itself) to exit cleanly
|
|
566
|
+
await Promise.all(pipeline.map((proc) =>
|
|
567
|
+
new Promise((resolve, reject) => {
|
|
568
|
+
proc.on('close', (code) => {
|
|
569
|
+
if (code === 0 || code === null) {
|
|
570
|
+
resolve();
|
|
571
|
+
} else {
|
|
572
|
+
const stderr = Buffer.concat(sendStderr).toString().trim();
|
|
573
|
+
reject(new Error(
|
|
574
|
+
proc === sendChild
|
|
575
|
+
? `btrfs send failed (exit ${code})${stderr ? ': ' + stderr : ''} — is ${subvolPath} a BTRFS subvolume?`
|
|
576
|
+
: `Pipeline stage failed (exit ${code})`
|
|
577
|
+
));
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
proc.on('error', reject);
|
|
581
|
+
})
|
|
582
|
+
));
|
|
583
|
+
|
|
584
|
+
const manifest = await manifestPromise;
|
|
472
585
|
|
|
473
586
|
// Save manifest locally
|
|
474
587
|
const manifestPath = path.join(manifestsDir(), `snapshots-${snapshot.name}.json`);
|
package/src/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ import { runDoctor, runStatus, runUninstallWizard } from './commands/manage.js';
|
|
|
11
11
|
import { installService, removeService } from './commands/service.js';
|
|
12
12
|
import { runDueJobs } from './daemon/scheduler.js';
|
|
13
13
|
import { startDaemon, stopDaemon, daemonRunning } from './daemon/daemon.js';
|
|
14
|
-
import { runSnapshotNow, listLocalSnapshots, pruneSnapshots } from './backup/snapshot.js';
|
|
14
|
+
import { runSnapshotNow, listLocalSnapshots, pruneSnapshots, deleteSnapshot, deleteAllSnapshots } from './backup/snapshot.js';
|
|
15
15
|
import { restoreSnapshot, restoreFiles } from './backup/restore.js';
|
|
16
16
|
import { listAccounts, addAccount, removeAccount, refreshAccounts, poolSummary } from './storage/accounts.js';
|
|
17
17
|
import { listArtifacts } from './storage/archive.js';
|
|
@@ -48,6 +48,7 @@ ${pc.bold('Usage:')}
|
|
|
48
48
|
parrot-blackbox force ⭐ Run every enabled backup NOW (default = weekly snapshot) ${pc.dim('[sudo]')}
|
|
49
49
|
parrot-blackbox snapshot now Create a weekly snapshot + upload it now ${pc.dim('[sudo]')}
|
|
50
50
|
parrot-blackbox snapshot list List local & cloud snapshots
|
|
51
|
+
parrot-blackbox snapshot delete [<name>|--all] Delete one or all local snapshots ${pc.dim('[sudo]')}
|
|
51
52
|
parrot-blackbox snapshot prune Delete snapshots beyond the keep limit ${pc.dim('[sudo]')}
|
|
52
53
|
parrot-blackbox list [files] List cloud file backups
|
|
53
54
|
parrot-blackbox restore Restore a snapshot or file backup ${pc.dim('[sudo]')}
|
|
@@ -429,7 +430,72 @@ const main = defineCommand({
|
|
|
429
430
|
}
|
|
430
431
|
return;
|
|
431
432
|
}
|
|
432
|
-
|
|
433
|
+
// snapshot delete [<name>|--all]
|
|
434
|
+
if (sub === 'delete' || sub === 'rm' || sub === 'remove') {
|
|
435
|
+
const target = args[0];
|
|
436
|
+
const deleteAll = target === '--all' || !target;
|
|
437
|
+
|
|
438
|
+
if (deleteAll && !target) {
|
|
439
|
+
// No name and no --all: list snapshots and bail with usage hint.
|
|
440
|
+
const local = await listLocalSnapshots({ privileged: 'interactive' }).catch(() => []);
|
|
441
|
+
if (local.length === 0) {
|
|
442
|
+
console.log(pc.dim('No local snapshots found.'));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
console.log(pc.bold('\nLocal snapshots:'));
|
|
446
|
+
for (const sn of local) console.log(` - ${pc.cyan(sn.name)}`);
|
|
447
|
+
console.log(pc.yellow('\nUsage:'));
|
|
448
|
+
console.log(' parrot-blackbox snapshot delete <name> — delete one snapshot');
|
|
449
|
+
console.log(' parrot-blackbox snapshot delete --all — delete all (runs btrfs quota rescan first)');
|
|
450
|
+
console.log();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (target === '--all') {
|
|
455
|
+
// Confirm unless stdin is non-interactive.
|
|
456
|
+
if (process.stdin.isTTY) {
|
|
457
|
+
const local = await listLocalSnapshots({ privileged: 'interactive' }).catch(() => []);
|
|
458
|
+
if (local.length === 0) { console.log(pc.dim('No local snapshots found.')); return; }
|
|
459
|
+
const { confirm } = await import('@clack/prompts');
|
|
460
|
+
const ok = await confirm({
|
|
461
|
+
message: pc.red(`Delete ALL ${local.length} local snapshot(s)? This cannot be undone.`),
|
|
462
|
+
initialValue: false,
|
|
463
|
+
});
|
|
464
|
+
const { isCancel } = await import('@clack/prompts');
|
|
465
|
+
if (isCancel(ok) || !ok) { console.log(pc.dim('Aborted — nothing deleted.')); return; }
|
|
466
|
+
}
|
|
467
|
+
console.log(pc.dim('Running btrfs quota rescan first…'));
|
|
468
|
+
try {
|
|
469
|
+
const result = await deleteAllSnapshots({
|
|
470
|
+
privileged: 'interactive',
|
|
471
|
+
onProgress: (msg) => console.log(pc.dim(` ${msg}`)),
|
|
472
|
+
});
|
|
473
|
+
console.log(pc.green(`✔ Deleted ${result.deleted.length} snapshot(s).`));
|
|
474
|
+
} catch (e) {
|
|
475
|
+
if (e.deleted?.length) console.log(pc.green(`✔ Deleted: ${e.deleted.join(', ')}`));
|
|
476
|
+
if (e.failed?.length) {
|
|
477
|
+
console.error(pc.red(`✖ Failed: ${e.failed.map((f) => `${f.name} (${f.error})`).join(', ')}`));
|
|
478
|
+
console.log(pc.dim(' Try running again — qgroup rescan may need a second pass.'));
|
|
479
|
+
process.exitCode = 1;
|
|
480
|
+
} else {
|
|
481
|
+
console.error(pc.red(`✖ ${e.message}`));
|
|
482
|
+
process.exitCode = 1;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Delete a single named snapshot.
|
|
489
|
+
try {
|
|
490
|
+
await deleteSnapshot(target, { privileged: 'interactive' });
|
|
491
|
+
console.log(pc.green(`✔ Snapshot ${target} deleted.`));
|
|
492
|
+
} catch (e) {
|
|
493
|
+
console.error(pc.red(`✖ ${e.message}`));
|
|
494
|
+
process.exitCode = 1;
|
|
495
|
+
}
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
console.log(pc.yellow('snapshot subcommands: now | list | delete [<name>|--all] | prune'));
|
|
433
499
|
return;
|
|
434
500
|
}
|
|
435
501
|
|
package/src/commands/wizard.js
CHANGED
|
@@ -17,7 +17,7 @@ import { guidedRemoteAdd } from './remote.js';
|
|
|
17
17
|
import { listAccounts, refreshAccounts, poolSummary, addAccount, removeAccount } from '../storage/accounts.js';
|
|
18
18
|
import { loadConfig, saveConfig } from '../core/store.js';
|
|
19
19
|
import { runDueJobs } from '../daemon/scheduler.js';
|
|
20
|
-
import { runSnapshotNow, listLocalSnapshots } from '../backup/snapshot.js';
|
|
20
|
+
import { runSnapshotNow, listLocalSnapshots, deleteSnapshot, deleteAllSnapshots } from '../backup/snapshot.js';
|
|
21
21
|
import { listArtifacts } from '../storage/archive.js';
|
|
22
22
|
import { restoreFiles, restoreSnapshot } from '../backup/restore.js';
|
|
23
23
|
import { installService, removeService } from './service.js';
|
|
@@ -196,6 +196,90 @@ async function listBackupsAction() {
|
|
|
196
196
|
p.log.message(pc.dim('No accounts configured — add one from the menu.'));
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Interactive snapshot delete menu.
|
|
201
|
+
* Lists all local snapshots, lets the user pick one to delete or delete all.
|
|
202
|
+
* Always runs `btrfs quota rescan -w /` before any delete loop.
|
|
203
|
+
*/
|
|
204
|
+
async function deleteSnapshotsMenu() {
|
|
205
|
+
// Fetch the current list first so we can show it.
|
|
206
|
+
let snapshots;
|
|
207
|
+
try {
|
|
208
|
+
snapshots = await listLocalSnapshots({ privileged: 'interactive' });
|
|
209
|
+
} catch (e) {
|
|
210
|
+
p.log.warn(`Could not list snapshots: ${e.message}`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (snapshots.length === 0) {
|
|
215
|
+
p.log.message(pc.dim('No local snapshots found — nothing to delete.'));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Build option list: one entry per snapshot + Delete All + Back.
|
|
220
|
+
const options = [
|
|
221
|
+
...snapshots.map((sn) => ({ value: sn.name, label: `🗑 ${pc.cyan(sn.name)}`, hint: 'delete this snapshot' })),
|
|
222
|
+
{ value: '__all', label: `💣 Delete ALL snapshots ${pc.dim(`(${snapshots.length} total)`)}`, hint: 'qgroup rescan + full wipe' },
|
|
223
|
+
{ value: '__back', label: '← Back' },
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const pick = await p.select({
|
|
227
|
+
message: '🗑 Delete snapshots — pick one or delete all',
|
|
228
|
+
options,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (p.isCancel(pick) || pick === '__back') return;
|
|
232
|
+
|
|
233
|
+
// ── Delete ALL ─────────────────────────────────────────────────────────────
|
|
234
|
+
if (pick === '__all') {
|
|
235
|
+
const confirm = await p.confirm({
|
|
236
|
+
message: pc.red(`Delete ALL ${snapshots.length} local snapshot(s)? This cannot be undone.`),
|
|
237
|
+
initialValue: false,
|
|
238
|
+
});
|
|
239
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
240
|
+
p.log.message(pc.dim('Cancelled — nothing was deleted.'));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
p.log.message(pc.dim('Running btrfs quota rescan first…'));
|
|
245
|
+
try {
|
|
246
|
+
const result = await deleteAllSnapshots({
|
|
247
|
+
privileged: 'interactive',
|
|
248
|
+
onProgress: (msg) => p.log.message(pc.dim(` ${msg}`)),
|
|
249
|
+
});
|
|
250
|
+
p.log.success(`✔ Deleted ${result.deleted.length} snapshot(s).`);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
// e.deleted / e.failed are attached by deleteAllSnapshots
|
|
253
|
+
if (e.deleted?.length) p.log.success(`✔ Deleted: ${e.deleted.join(', ')}`);
|
|
254
|
+
if (e.failed?.length) {
|
|
255
|
+
p.log.warn(`✖ Failed: ${e.failed.map((f) => `${f.name} (${f.error})`).join(', ')}`);
|
|
256
|
+
p.log.message(pc.dim(' Try running again — the qgroup rescan may need a second pass.'));
|
|
257
|
+
} else {
|
|
258
|
+
p.log.warn(`✖ ${e.message}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Delete ONE ─────────────────────────────────────────────────────────────
|
|
265
|
+
const confirm = await p.confirm({
|
|
266
|
+
message: pc.yellow(`Delete snapshot ${pc.bold(pick)}?`),
|
|
267
|
+
initialValue: false,
|
|
268
|
+
});
|
|
269
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
270
|
+
p.log.message(pc.dim('Cancelled — nothing was deleted.'));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
await deleteSnapshot(pick, { privileged: 'interactive' });
|
|
276
|
+
p.log.success(`✔ Snapshot ${pc.bold(pick)} deleted.`);
|
|
277
|
+
} catch (e) {
|
|
278
|
+
p.log.warn(`✖ ${e.message}`);
|
|
279
|
+
p.log.message(pc.dim(' If you see a qgroup error, try "Delete ALL" which runs btrfs quota rescan first.'));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
199
283
|
/** Restore files or a system snapshot. */
|
|
200
284
|
async function restoreMenu() {
|
|
201
285
|
const accs = listAccounts();
|
|
@@ -323,6 +407,7 @@ export async function runWizard() {
|
|
|
323
407
|
{ value: 'backup', label: '💾 Run all backups', hint: 'snapshots + file backups' },
|
|
324
408
|
{ value: 'restore', label: '♻️ Restore backup', hint: 'files or system snapshot' },
|
|
325
409
|
{ value: 'list', label: '📋 List backups', hint: 'see what\'s saved' },
|
|
410
|
+
{ value: 'delete', label: '🗑 Delete snapshots', hint: 'remove one or all local snapshots' },
|
|
326
411
|
{ value: 'add', label: '☁️ Add cloud account', hint: 'MEGA or Google Drive' },
|
|
327
412
|
{ value: 'accounts', label: '🗂 Manage storage', hint: 'pool, quotas, accounts' },
|
|
328
413
|
{ value: 'setup', label: '🚀 Guided setup', hint: 'first-time configuration' },
|
|
@@ -352,6 +437,7 @@ export async function runWizard() {
|
|
|
352
437
|
case 'resume': await snapshotNowAction(); break;
|
|
353
438
|
case 'backup': await backupNowAction(); break;
|
|
354
439
|
case 'list': await listBackupsAction(); break;
|
|
440
|
+
case 'delete': await deleteSnapshotsMenu(); break;
|
|
355
441
|
case 'restore': await restoreMenu(); break;
|
|
356
442
|
case 'service': await serviceMenu(); break;
|
|
357
443
|
case 'daemon': await daemonMenu(); break;
|