parrot-blackbox 2.0.0 → 2.0.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parrot-blackbox",
3
- "version": "2.0.0",
3
+ "version": "2.0.3",
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",
@@ -58,6 +58,21 @@ export async function getBtrfsDevice(mountPoint = '/') {
58
58
  }
59
59
  }
60
60
 
61
+ /**
62
+ * Check if a path is actually a BTRFS subvolume (not just a directory on BTRFS).
63
+ * @param {string} path
64
+ * @returns {Promise<boolean>}
65
+ */
66
+ export async function isSubvolume(path) {
67
+ try {
68
+ // Need sudo to check subvolume info
69
+ const res = await execa('sudo', ['-n', 'btrfs', 'subvolume', 'show', path], { reject: false });
70
+ return res.exitCode === 0;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
61
76
  /**
62
77
  * Set a BTRFS subvolume to read-only mode (required for send).
63
78
  * @param {string} subvolPath - Path to the subvolume
@@ -66,6 +81,12 @@ export async function getBtrfsDevice(mountPoint = '/') {
66
81
  * @returns {Promise<void>}
67
82
  */
68
83
  export async function setSubvolumeReadOnly(subvolPath, readOnly = true, { privileged = 'noninteractive' } = {}) {
84
+ // First verify it's actually a subvolume
85
+ const isSubvol = await isSubvolume(subvolPath);
86
+ if (!isSubvol) {
87
+ throw new Error(`${subvolPath} is not a BTRFS subvolume`);
88
+ }
89
+
69
90
  const roValue = readOnly ? 'true' : 'false';
70
91
  const args = ['btrfs', 'property', 'set', '-ts', subvolPath, 'ro', roValue];
71
92
  const res = await sudoExec(args, { privileged });
@@ -139,7 +160,7 @@ export function getSnapshotParent(manifestPath) {
139
160
  * Create a BTRFS send stream.
140
161
  * @param {string} subvolPath - Path to the snapshot subvolume
141
162
  * @param {object} opts - {parent: string|null, privileged: 'interactive'|'noninteractive'}
142
- * @returns {Promise<ReadableStream>} - The send stream (not yet piped through compression/encryption)
163
+ * @returns {Promise<{stream: ReadableStream, child: ChildProcess}>}
143
164
  */
144
165
  export async function createSendStream(subvolPath, { parent = null, privileged = 'noninteractive' } = {}) {
145
166
  const args = ['btrfs', 'send'];
@@ -149,8 +170,9 @@ export async function createSendStream(subvolPath, { parent = null, privileged =
149
170
  args.push(subvolPath);
150
171
  }
151
172
 
152
- // Spawn via sudo, return the stdout stream
153
- const sudoArgs = privileged === 'interactive'
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'
154
176
  ? ['sudo', '-E', ...args]
155
177
  : ['sudo', '-n', ...args];
156
178
 
@@ -158,8 +180,7 @@ export async function createSendStream(subvolPath, { parent = null, privileged =
158
180
  stdio: ['ignore', 'pipe', 'pipe'],
159
181
  });
160
182
 
161
- // Convert child.stdout to a Node readable stream
162
- return child.stdout;
183
+ return { stream: child.stdout, child };
163
184
  }
164
185
 
165
186
  /**
@@ -168,6 +168,66 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
168
168
  }
169
169
  return true;
170
170
  }
171
+
172
+ /**
173
+ * Delete ALL local Timeshift snapshots.
174
+ *
175
+ * Runs `sudo btrfs quota rescan -w /` first to re-sync qgroup accounting —
176
+ * without this, a stale qgroup entry can cause `timeshift --delete` to fail
177
+ * with "Failed to destroy qgroup" even though the subvolume itself was removed.
178
+ *
179
+ * After the rescan, each snapshot is deleted in a loop. If a delete still
180
+ * fails the function records the error and carries on so the rest can be
181
+ * cleaned up; it throws at the end if any deletions failed.
182
+ *
183
+ * @param {object} opts
184
+ * @param {'interactive'|'noninteractive'} opts.privileged
185
+ * @param {(msg:string)=>void} opts.onProgress optional progress callback
186
+ * @returns {Promise<{deleted:string[], failed:Array<{name:string,error:string}>}>}
187
+ */
188
+ export async function deleteAllSnapshots({ privileged = 'interactive', onProgress } = {}) {
189
+ if (privileged === 'interactive') await ensureSudo();
190
+
191
+ // Step 1: rescan qgroups so stale entries don't block the deletes.
192
+ onProgress?.('Running btrfs quota rescan — this may take a moment…');
193
+ const rescanRes = privileged === 'interactive'
194
+ ? await sudoInteractive(['btrfs', 'quota', 'rescan', '-w', '/'])
195
+ : await sudoNonInteractive(['btrfs', 'quota', 'rescan', '-w', '/']);
196
+
197
+ // A non-zero exit here is not fatal — it just means quotas may not be enabled
198
+ // (e.g. rsync-mode Timeshift). Log and continue.
199
+ if (rescanRes.exitCode !== 0) {
200
+ onProgress?.(`⚠ btrfs quota rescan exited ${rescanRes.exitCode} — continuing anyway`);
201
+ } else {
202
+ onProgress?.('✔ btrfs quota rescan complete');
203
+ }
204
+
205
+ // Step 2: list, then delete each snapshot.
206
+ const snapshots = await listLocalSnapshots({ privileged });
207
+ const deleted = [];
208
+ const failed = [];
209
+
210
+ for (const sn of snapshots) {
211
+ onProgress?.(`Deleting snapshot: ${sn.name}`);
212
+ try {
213
+ await deleteSnapshot(sn.name, { privileged });
214
+ deleted.push(sn.name);
215
+ } catch (e) {
216
+ // qgroup bookkeeping might still fail on the first pass — caller can retry.
217
+ failed.push({ name: sn.name, error: e.message });
218
+ onProgress?.(` ✖ ${sn.name}: ${e.message}`);
219
+ }
220
+ }
221
+
222
+ if (failed.length > 0) {
223
+ throw Object.assign(
224
+ new Error(`${failed.length} snapshot(s) could not be deleted — try running again after a fresh qgroup rescan`),
225
+ { deleted, failed },
226
+ );
227
+ }
228
+
229
+ return { deleted, failed };
230
+ }
171
231
  /**
172
232
  * Resolve the on-disk directory of a snapshot.
173
233
  *
@@ -401,33 +461,45 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
401
461
  /**
402
462
  * Upload a snapshot using BTRFS send/receive streaming.
403
463
  * Creates: btrfs send [-p parent] | zstd | [openssl] | rclone rcat (chunked)
464
+ *
465
+ * Uses the path resolved by snapshotDirFor() (which mounts the BTRFS root subvolume
466
+ * at a temporary mount point) so that `btrfs send` receives an actual subvolume path,
467
+ * not a directory inside a regular mount.
404
468
  */
405
469
  async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, parentDir, accounts, cfg, due, privileged, onProgress }) {
406
- const { setSubvolumeReadOnly, createSendStream, estimateSendSize } = await import('./btrfs-send.js');
470
+ const { createSendStream, estimateSendSize } = await import('./btrfs-send.js');
407
471
  const { planAndPlaceStream } = await import('../storage/allocator.js');
408
-
409
- // Ensure snapshot is read-only
410
- try {
411
- await setSubvolumeReadOnly(snapshotDir, true, { privileged });
412
- } catch (e) {
413
- console.log(`⚠ Could not set snapshot read-only: ${e.message}`);
414
- }
472
+
473
+ // snapshotDir was resolved by snapshotDirFor() — it is the real subvolume path
474
+ // (e.g. /run/parrot-blackbox-btrfs-<ts>/timeshift-btrfs/snapshots/<name>).
475
+ // Use it directly; do NOT fall back to a hardcoded absolute path.
476
+ const subvolPath = snapshotDir;
477
+ const parentSubvolPath = parentDir || null;
478
+
479
+ journal('snapshots', `btrfs send subvolPath=${subvolPath} parent=${parentSubvolPath || 'null'}`);
415
480
 
416
481
  // Estimate size for progress reporting
417
- const estimatedSize = await estimateSendSize(snapshotDir, { parent: parentDir });
482
+ const estimatedSize = await estimateSendSize(subvolPath, { parent: parentSubvolPath });
418
483
  const btrfsCfg = cfg.jobs.snapshots.btrfs || {};
419
-
420
- console.log(`\n📤 Uploading ${parentDir ? 'incremental' : 'full'} BTRFS stream...`);
484
+
485
+ console.log(`\n📤 Uploading ${parentSubvolPath ? 'incremental' : 'full'} BTRFS stream...`);
421
486
  console.log(` Estimated size: ${(estimatedSize / (1024 ** 3)).toFixed(2)} GiB`);
422
- if (btrfsCfg.compression) console.log(` Compression: zstd enabled`);
423
- if (btrfsCfg.encryption && cfg.storage.encryptionPassphrase) console.log(` Encryption: AES-256 enabled`);
487
+ if (btrfsCfg.compression !== false) console.log(` Compression: zstd enabled`);
488
+ if (btrfsCfg.encryption && cfg.storage?.encryptionPassphrase) console.log(` Encryption: AES-256 enabled`);
489
+
490
+ // Create the BTRFS send stream — returns {stream, child} so we can detect errors
491
+ const { stream: sendStream, child: sendChild } = await createSendStream(subvolPath, {
492
+ parent: parentSubvolPath,
493
+ privileged,
494
+ });
424
495
 
425
- // Create the BTRFS send stream
426
- const sendStream = await createSendStream(snapshotDir, { parent: parentDir, privileged });
496
+ // Collect any stderr from btrfs send for better error messages
497
+ const sendStderr = [];
498
+ sendChild.stderr?.on('data', (d) => sendStderr.push(d));
427
499
 
428
- // Build the pipeline: btrfs send -> [zstd] -> [openssl] -> chunked rclone rcat
500
+ // Build compression / encryption pipeline
429
501
  const { spawn } = await import('node:child_process');
430
- const pipeline = [];
502
+ const pipeline = [sendChild]; // include send process so we wait on its exit too
431
503
  let currentStream = sendStream;
432
504
 
433
505
  // Stage 1: Compression
@@ -439,7 +511,7 @@ async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, paren
439
511
  }
440
512
 
441
513
  // Stage 2: Encryption
442
- if (btrfsCfg.encryption && cfg.storage.encryptionPassphrase) {
514
+ if (btrfsCfg.encryption && cfg.storage?.encryptionPassphrase) {
443
515
  const openssl = spawn('openssl', ['enc', '-e', '-aes256', '-pbkdf2', '-pass', `pass:${cfg.storage.encryptionPassphrase}`], {
444
516
  stdio: ['pipe', 'pipe', 'inherit'],
445
517
  });
@@ -449,7 +521,7 @@ async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, paren
449
521
  }
450
522
 
451
523
  // Stage 3: Stream to cloud via allocator's planAndPlaceStream (handles chunking across accounts)
452
- const manifest = await planAndPlaceStream(currentStream, {
524
+ const manifestPromise = planAndPlaceStream(currentStream, {
453
525
  kind: 'snapshots',
454
526
  id: snapshot.name,
455
527
  accounts,
@@ -459,11 +531,26 @@ async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, paren
459
531
  originalSize: estimatedSize,
460
532
  });
461
533
 
462
- // Wait for all pipeline stages to complete
463
- await Promise.all(pipeline.map(proc => new Promise((resolve, reject) => {
464
- proc.on('close', code => code === 0 ? resolve() : reject(new Error(`Pipeline stage failed: exit ${code}`)));
465
- proc.on('error', reject);
466
- })));
534
+ // Wait for all pipeline stages (including btrfs send itself) to exit cleanly
535
+ await Promise.all(pipeline.map((proc) =>
536
+ new Promise((resolve, reject) => {
537
+ proc.on('close', (code) => {
538
+ if (code === 0 || code === null) {
539
+ resolve();
540
+ } else {
541
+ const stderr = Buffer.concat(sendStderr).toString().trim();
542
+ reject(new Error(
543
+ proc === sendChild
544
+ ? `btrfs send failed (exit ${code})${stderr ? ': ' + stderr : ''} — is ${subvolPath} a BTRFS subvolume?`
545
+ : `Pipeline stage failed (exit ${code})`
546
+ ));
547
+ }
548
+ });
549
+ proc.on('error', reject);
550
+ })
551
+ ));
552
+
553
+ const manifest = await manifestPromise;
467
554
 
468
555
  // Save manifest locally
469
556
  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
- console.log(pc.yellow('snapshot subcommands: now | list | prune'));
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
 
@@ -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;