parrot-blackbox 2.0.9 → 2.1.1
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/git-exclude.js +17 -2
- package/src/backup/restore.js +5 -6
- package/src/backup/urgent.js +147 -0
- package/src/cli.js +30 -4
- package/src/commands/wizard.js +58 -18
- package/src/storage/allocator.js +25 -19
- package/src/storage/rclone.js +44 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
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",
|
|
@@ -49,7 +49,21 @@ export function collectFiles(sources, { exclude = [], home = process.env.HOME }
|
|
|
49
49
|
|
|
50
50
|
for (const src of sources) {
|
|
51
51
|
const abs = expandPath(src, home);
|
|
52
|
-
|
|
52
|
+
let st;
|
|
53
|
+
try {
|
|
54
|
+
st = fs.statSync(abs); // throws → source is missing
|
|
55
|
+
} catch {
|
|
56
|
+
missing.push(src);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A bare FILE source (e.g. `~/.gitconfig`) is backed up verbatim.
|
|
61
|
+
if (st.isFile()) {
|
|
62
|
+
files.push({ abs, rel: path.basename(abs) || 'file' });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// Sockets / FIFOs / devices aren't copyable as sources — skip cleanly.
|
|
66
|
+
if (!st.isDirectory()) {
|
|
53
67
|
missing.push(src);
|
|
54
68
|
continue;
|
|
55
69
|
}
|
|
@@ -91,9 +105,10 @@ export function collectFiles(sources, { exclude = [], home = process.env.HOME }
|
|
|
91
105
|
continue;
|
|
92
106
|
}
|
|
93
107
|
walk(eAbs, rel, sourceRoot);
|
|
94
|
-
} else {
|
|
108
|
+
} else if (st.isFile()) {
|
|
95
109
|
files.push({ abs: eAbs, rel });
|
|
96
110
|
}
|
|
111
|
+
// sockets / FIFOs / devices can't be copied — silently skipped.
|
|
97
112
|
}
|
|
98
113
|
}
|
|
99
114
|
|
package/src/backup/restore.js
CHANGED
|
@@ -22,16 +22,15 @@ import { listLocalSnapshots } from './snapshot.js';
|
|
|
22
22
|
import { ensureSudo, sudoInteractive } from '../util/sudo.js';
|
|
23
23
|
import { bytesHuman } from '../util/misc.js';
|
|
24
24
|
|
|
25
|
-
/** Restore a file
|
|
26
|
-
export async function restoreFiles({ id, toDir, accounts, cfg, onProgress }) {
|
|
27
|
-
const found = await discoverManifest(
|
|
25
|
+
/** Restore a file-backup generation (or an urgent backup) into a writable local directory. */
|
|
26
|
+
export async function restoreFiles({ id, toDir, accounts, cfg, kind = 'files', onProgress }) {
|
|
27
|
+
const found = await discoverManifest(kind, id, accounts, cfg.storage.remoteRoot);
|
|
28
28
|
if (!found) {
|
|
29
|
-
|
|
30
|
-
throw new Error(`no file backup found for id "${id}" — check with \`parrot-blackbox list\``);
|
|
29
|
+
throw new Error(`no ${kind} backup found for id "${id}" — check with \`parrot-blackbox list\``);
|
|
31
30
|
}
|
|
32
31
|
fs.mkdirSync(toDir, { recursive: true });
|
|
33
32
|
const res = await restoreArtifact(found.manifest, toDir, { onProgress });
|
|
34
|
-
journal('restore',
|
|
33
|
+
journal('restore', `${kind} id=${id} -> ${toDir} files=${res.files} bytes=${res.bytes}`);
|
|
35
34
|
return { id, toDir, ...res, manifest: found.manifest };
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The URGENT backup — a fast, one-off rescue artifact for a fresh install.
|
|
3
|
+
*
|
|
4
|
+
* It bundles the user's real working files (Desktop / Downloads / Documents /
|
|
5
|
+
* Music / Pictures / Programming / Videos / Learning) PLUS the tooling needed
|
|
6
|
+
* to be productive on day one:
|
|
7
|
+
* - VS Codium data profile + extensions (+ shared storage / user config)
|
|
8
|
+
* - gitswitch bookkeeping + the SSH keys it manages + git config
|
|
9
|
+
*
|
|
10
|
+
* Design notes (why this is intentionally NOT the daily files job):
|
|
11
|
+
* - It uses its own fixed source list, so it never depends on the user's
|
|
12
|
+
* config having the right sources enabled.
|
|
13
|
+
* - It still honours the golden rule: git-tracked trees are skipped (GitHub
|
|
14
|
+
* already owns them) and the lean exclude list drops bloat (node_modules,
|
|
15
|
+
* caches, session noise).
|
|
16
|
+
* - It uploads through the same smart storage pool under a DISTINCT kind
|
|
17
|
+
* ('urgent') so it is never confused with — or pruned by — scheduled
|
|
18
|
+
* backups, and is cleanly restorable via the existing restore flow.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { loadConfig, loadState, saveState, journal } from '../core/store.js';
|
|
24
|
+
import { stagingDir } from '../core/paths.js';
|
|
25
|
+
import { iso, clock } from '../core/time.js';
|
|
26
|
+
import { refreshAccounts } from '../storage/accounts.js';
|
|
27
|
+
import { planAndPlace } from '../storage/allocator.js';
|
|
28
|
+
import { collectFiles, stageFiles, sumFiles } from './git-exclude.js';
|
|
29
|
+
|
|
30
|
+
/** Cloud artifact kind (a distinct bucket from 'files' / 'snapshots'). */
|
|
31
|
+
export const KIND = 'urgent';
|
|
32
|
+
|
|
33
|
+
/** Everything the urgent backup takes from the home directory. */
|
|
34
|
+
export const URGENT_SOURCES = [
|
|
35
|
+
// Working files
|
|
36
|
+
'~/Desktop',
|
|
37
|
+
'~/Downloads',
|
|
38
|
+
'~/Documents',
|
|
39
|
+
'~/Learning',
|
|
40
|
+
'~/Music',
|
|
41
|
+
'~/Pictures',
|
|
42
|
+
'~/Programming',
|
|
43
|
+
'~/Videos',
|
|
44
|
+
// VS Codium — data profile + extensions + shared storage + user config
|
|
45
|
+
'~/.vscode-oss',
|
|
46
|
+
'~/.vscode-oss-shared',
|
|
47
|
+
'~/.config/VSCodium/User',
|
|
48
|
+
// gitswitch — accounts/SSH bookkeeping, the SSH keys it manages, git config
|
|
49
|
+
'~/.gitswitch',
|
|
50
|
+
'~/.ssh',
|
|
51
|
+
'~/.gitconfig',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** Lean exclude list — git repos + bloat are out, real files are in. */
|
|
55
|
+
export const URGENT_EXCLUDE = [
|
|
56
|
+
'**/.cache/**',
|
|
57
|
+
'**/.git/**',
|
|
58
|
+
'**/node_modules/**',
|
|
59
|
+
'**/__pycache__/**',
|
|
60
|
+
'**/*.tmp',
|
|
61
|
+
'**/*.swp',
|
|
62
|
+
'**/*.log',
|
|
63
|
+
'**/lost+found/**',
|
|
64
|
+
// VS Codium cache / session noise — the profile is what matters, not caches.
|
|
65
|
+
'**/Cache/**',
|
|
66
|
+
'**/CachedData/**',
|
|
67
|
+
'**/CachedExtensionVSIXs/**',
|
|
68
|
+
'**/GPUCache/**',
|
|
69
|
+
'**/blob_storage/**',
|
|
70
|
+
'**/Code Cache/**',
|
|
71
|
+
'**/Crashpad/**',
|
|
72
|
+
'**/Service Worker/**',
|
|
73
|
+
'**/WebStorage/**',
|
|
74
|
+
'**/Local Storage/**',
|
|
75
|
+
'**/Session Storage/**',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Collect + stage the urgent sources into a fresh bundle dir.
|
|
80
|
+
* @returns {{dir:string, files:Array, sizeBytes:number, skippedRepos:string[], missing:string[], skipped:number}}
|
|
81
|
+
*/
|
|
82
|
+
export function buildUrgentBundle({ home = process.env.HOME } = {}) {
|
|
83
|
+
const col = collectFiles(URGENT_SOURCES, { exclude: URGENT_EXCLUDE, home });
|
|
84
|
+
const dir = path.join(stagingDir(), `urgent-${Date.now()}-${process.pid}`);
|
|
85
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
87
|
+
stageFiles(col.files, dir);
|
|
88
|
+
return {
|
|
89
|
+
dir,
|
|
90
|
+
files: col.files,
|
|
91
|
+
sizeBytes: sumFiles(col.files),
|
|
92
|
+
skippedRepos: col.skippedRepos,
|
|
93
|
+
missing: col.missing,
|
|
94
|
+
skipped: col.skipped,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run one URGENT backup generation — bundle → stage → upload to the pool.
|
|
100
|
+
* Unlike scheduled backups this does NOT run retention, so the rescue artifact
|
|
101
|
+
* is never auto-deleted.
|
|
102
|
+
* @returns {Promise<{id:string, manifest:object, sizeBytes:number, skippedRepos:string[], missing:string[]}>}
|
|
103
|
+
*/
|
|
104
|
+
export async function runUrgentBackup(cfg = loadConfig(), state = loadState(), { onProgress } = {}) {
|
|
105
|
+
const now = clock();
|
|
106
|
+
const id = iso(now);
|
|
107
|
+
journal('urgent', `start id=${id}`);
|
|
108
|
+
const bundle = buildUrgentBundle();
|
|
109
|
+
|
|
110
|
+
const accounts = await refreshAccounts(cfg);
|
|
111
|
+
if (!accounts.length) {
|
|
112
|
+
fs.rmSync(bundle.dir, { recursive: true, force: true });
|
|
113
|
+
throw new Error('No cloud accounts configured — run `parrot-blackbox account add` (or Guided Setup) first.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let manifest;
|
|
117
|
+
try {
|
|
118
|
+
manifest = await planAndPlace(bundle.dir, {
|
|
119
|
+
kind: KIND,
|
|
120
|
+
id,
|
|
121
|
+
accounts,
|
|
122
|
+
remoteRoot: cfg.storage.remoteRoot,
|
|
123
|
+
chunkSize: cfg.storage.chunkSize,
|
|
124
|
+
onProgress,
|
|
125
|
+
});
|
|
126
|
+
} finally {
|
|
127
|
+
fs.rmSync(bundle.dir, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
state.manifests[`${KIND}-${manifest.id}`] = {
|
|
131
|
+
kind: KIND,
|
|
132
|
+
id: manifest.id,
|
|
133
|
+
createdAt: manifest.createdAt,
|
|
134
|
+
totalSize: manifest.totalSize,
|
|
135
|
+
entryCount: manifest.entries?.length || 0,
|
|
136
|
+
};
|
|
137
|
+
saveState(state);
|
|
138
|
+
journal('urgent', `done id=${id} bytes=${manifest.totalSize}`);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
id,
|
|
142
|
+
manifest,
|
|
143
|
+
sizeBytes: manifest.totalSize,
|
|
144
|
+
skippedRepos: bundle.skippedRepos,
|
|
145
|
+
missing: bundle.missing,
|
|
146
|
+
};
|
|
147
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { installService, removeService } from './commands/service.js';
|
|
|
14
14
|
import { runDueJobs } from './daemon/scheduler.js';
|
|
15
15
|
import { startDaemon, stopDaemon, daemonRunning } from './daemon/daemon.js';
|
|
16
16
|
import { runSnapshotNow, listLocalSnapshots, pruneSnapshots, deleteSnapshot, deleteAllSnapshots } from './backup/snapshot.js';
|
|
17
|
+
import { runUrgentBackup } from './backup/urgent.js';
|
|
17
18
|
import { restoreSnapshot, restoreFiles } from './backup/restore.js';
|
|
18
19
|
import { listAccounts, addAccount, removeAccount, refreshAccounts, poolSummary } from './storage/accounts.js';
|
|
19
20
|
import { listArtifacts } from './storage/archive.js';
|
|
@@ -52,6 +53,7 @@ ${pc.bold('Usage:')}
|
|
|
52
53
|
parrot-blackbox snapshot list List local & cloud snapshots
|
|
53
54
|
parrot-blackbox snapshot delete [<name>|--all] Delete one or all local snapshots ${pc.dim('[sudo]')}
|
|
54
55
|
parrot-blackbox snapshot prune Delete snapshots beyond the keep limit ${pc.dim('[sudo]')}
|
|
56
|
+
parrot-blackbox urgent ⚡ One-off rescue backup: working files + VS Codium + gitswitch/SSH data
|
|
55
57
|
parrot-blackbox list [files] List cloud file backups
|
|
56
58
|
parrot-blackbox restore Restore a snapshot or file backup ${pc.dim('[sudo]')}
|
|
57
59
|
parrot-blackbox account add Add a MEGA / Google Drive account (remote must already exist)
|
|
@@ -132,6 +134,12 @@ async function listFiles() {
|
|
|
132
134
|
for (const a of artifacts) {
|
|
133
135
|
console.log(` - ${pc.cyan(a.id)} ${bytesHuman(a.totalSize)} ${pc.dim(a.account)}`);
|
|
134
136
|
}
|
|
137
|
+
const urgent = await listArtifacts('urgent', accs, cfg.storage.remoteRoot);
|
|
138
|
+
console.log(`\n${pc.bold('Cloud urgent backups:')}`);
|
|
139
|
+
if (urgent.length === 0) console.log(` ${pc.dim('none yet — run `parrot-blackbox urgent`')}`);
|
|
140
|
+
for (const a of urgent) {
|
|
141
|
+
console.log(` - ${pc.cyan(a.id)} ${bytesHuman(a.totalSize)} ${pc.dim(a.account)}`);
|
|
142
|
+
}
|
|
135
143
|
console.log();
|
|
136
144
|
}
|
|
137
145
|
|
|
@@ -162,13 +170,14 @@ async function restoreFlow(rest) {
|
|
|
162
170
|
options: [
|
|
163
171
|
{ value: 'snapshot', label: 'System snapshot (Timeshift) — overwrites the whole system', hint: '[sudo]' },
|
|
164
172
|
{ value: 'files', label: 'File backup — recover fonts/images/docs into a folder' },
|
|
173
|
+
{ value: 'urgent', label: 'Urgent backup — user files + tool profiles (fresh install)' },
|
|
165
174
|
],
|
|
166
175
|
}));
|
|
167
176
|
if (p.isCancel(kind)) return;
|
|
168
177
|
|
|
169
|
-
if (kind === 'files') {
|
|
170
|
-
const artifacts = await listArtifacts(
|
|
171
|
-
if (artifacts.length === 0) { p.log.warn(
|
|
178
|
+
if (kind === 'files' || kind === 'urgent') {
|
|
179
|
+
const artifacts = await listArtifacts(kind, accs, cfg.storage.remoteRoot);
|
|
180
|
+
if (artifacts.length === 0) { p.log.warn(`No ${kind === 'urgent' ? 'urgent' : 'file'} backups found.`); return; }
|
|
172
181
|
const id = rest[1] || (await p.select({
|
|
173
182
|
message: 'Pick a backup generation:',
|
|
174
183
|
options: artifacts.map((a) => ({ value: a.id, label: `${a.id} (${bytesHuman(a.totalSize)})` })),
|
|
@@ -181,7 +190,7 @@ async function restoreFlow(rest) {
|
|
|
181
190
|
const s = p.spinner();
|
|
182
191
|
s.start('Restoring…');
|
|
183
192
|
try {
|
|
184
|
-
const res = await restoreFiles({ id, toDir, accounts: accs, cfg });
|
|
193
|
+
const res = await restoreFiles({ id, toDir, accounts: accs, cfg, kind });
|
|
185
194
|
s.stop(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}`);
|
|
186
195
|
} catch (e) {
|
|
187
196
|
s.stop('✖ Restore failed.');
|
|
@@ -408,6 +417,23 @@ const main = defineCommand({
|
|
|
408
417
|
return;
|
|
409
418
|
}
|
|
410
419
|
|
|
420
|
+
case 'urgent': {
|
|
421
|
+
// One-off rescue backup — working files + VS Codium + gitswitch/SSH data.
|
|
422
|
+
const progress = makeProgressRenderer();
|
|
423
|
+
try {
|
|
424
|
+
const r = await runUrgentBackup(undefined, undefined, { onProgress: progress });
|
|
425
|
+
progress.stop();
|
|
426
|
+
console.log(`${pc.green('✔')} Urgent backup stored (${bytesHuman(r.sizeBytes)}). Restore with: \`parrot-blackbox restore urgent\`.`);
|
|
427
|
+
if (r.skippedRepos?.length) console.log(pc.dim(`Skipped ${r.skippedRepos.length} git-tracked folder(s).`));
|
|
428
|
+
if (r.missing?.length) console.log(pc.dim(`Source(s) not present, skipped: ${r.missing.join(', ')}.`));
|
|
429
|
+
} catch (e) {
|
|
430
|
+
progress.stop();
|
|
431
|
+
console.error(pc.red(`✖ ${e.message}`));
|
|
432
|
+
process.exitCode = 1;
|
|
433
|
+
}
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
411
437
|
case 'snapshot': {
|
|
412
438
|
const [sub, ...args] = rest;
|
|
413
439
|
if (sub === 'now' || sub === 'create' || sub === 'force') {
|
package/src/commands/wizard.js
CHANGED
|
@@ -18,6 +18,7 @@ import { listAccounts, refreshAccounts, poolSummary, addAccount, removeAccount }
|
|
|
18
18
|
import { loadConfig, saveConfig } from '../core/store.js';
|
|
19
19
|
import { runDueJobs } from '../daemon/scheduler.js';
|
|
20
20
|
import { runSnapshotNow, listLocalSnapshots, deleteSnapshot, deleteAllSnapshots, nextSnapshotUploadMode } from '../backup/snapshot.js';
|
|
21
|
+
import { runUrgentBackup } from '../backup/urgent.js';
|
|
21
22
|
import { listArtifacts } from '../storage/archive.js';
|
|
22
23
|
import { restoreFiles, restoreSnapshot } from '../backup/restore.js';
|
|
23
24
|
import { installService, removeService } from './service.js';
|
|
@@ -177,6 +178,33 @@ async function backupNowAction() {
|
|
|
177
178
|
}
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
/** One-off rescue backup: working files + VS Codium + gitswitch/SSH data (fast, for a fresh install). */
|
|
182
|
+
async function urgentBackupAction() {
|
|
183
|
+
const cfg = loadConfig();
|
|
184
|
+
if (!listAccounts().length) { p.log.warn('No cloud accounts configured — add one from the menu first.'); return; }
|
|
185
|
+
const { URGENT_SOURCES } = await import('../backup/urgent.js');
|
|
186
|
+
const names = URGENT_SOURCES.map((s) => s.replace(/^~\//, ''));
|
|
187
|
+
const ok = await p.confirm({
|
|
188
|
+
message: pc.bold(`⚡ Urgent backup: ${names.length} sources`) +
|
|
189
|
+
pc.dim(` — ${names.join(', ')}.`) +
|
|
190
|
+
pc.dim('\nGit-tracked folders are skipped (already on GitHub); only real files + tool profiles are stored. Continue?'),
|
|
191
|
+
initialValue: true,
|
|
192
|
+
});
|
|
193
|
+
if (p.isCancel(ok) || !ok) { p.log.message(pc.dim('Cancelled — nothing was backed up.')); return; }
|
|
194
|
+
|
|
195
|
+
const progress = makeClackProgressRenderer(p);
|
|
196
|
+
try {
|
|
197
|
+
const r = await runUrgentBackup(cfg, undefined, { onProgress: progress });
|
|
198
|
+
p.log.success(`✔ Urgent backup stored (${bytesHuman(r.sizeBytes)}). Restore later via: Restore backup → Urgent backup.`);
|
|
199
|
+
if (r.skippedRepos?.length) p.log.message(pc.dim(`Skipped ${r.skippedRepos.length} git-tracked folder(s).`));
|
|
200
|
+
if (r.missing?.length) p.log.message(pc.dim(`Source(s) not present, skipped: ${r.missing.join(', ')}.`));
|
|
201
|
+
} catch (e) {
|
|
202
|
+
p.log.warn(`✖ ${e.message}`);
|
|
203
|
+
} finally {
|
|
204
|
+
progress.stop();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
180
208
|
/** Create + upload a snapshot immediately. */
|
|
181
209
|
async function snapshotNowAction() {
|
|
182
210
|
try {
|
|
@@ -224,6 +252,10 @@ async function listBackupsAction() {
|
|
|
224
252
|
const files = await listArtifacts('files', accs, cfg.storage.remoteRoot);
|
|
225
253
|
if (!files.length) p.log.message(pc.dim(' none'));
|
|
226
254
|
for (const f of files) p.log.message(` - ${pc.cyan(f.id)} ${bytesHuman(f.totalSize)}`);
|
|
255
|
+
p.log.message(pc.bold('Cloud urgent backups:'));
|
|
256
|
+
const urgent = await listArtifacts('urgent', accs, cfg.storage.remoteRoot);
|
|
257
|
+
if (!urgent.length) p.log.message(pc.dim(' none'));
|
|
258
|
+
for (const u of urgent) p.log.message(` - ${pc.cyan(u.id)} ${bytesHuman(u.totalSize)}`);
|
|
227
259
|
} else {
|
|
228
260
|
p.log.message(pc.dim('No accounts configured — add one from the menu.'));
|
|
229
261
|
}
|
|
@@ -314,7 +346,27 @@ async function deleteSnapshotsMenu() {
|
|
|
314
346
|
}
|
|
315
347
|
}
|
|
316
348
|
|
|
317
|
-
/** Restore files or a
|
|
349
|
+
/** Restore a file-like artifact ('files' or 'urgent') into a fresh directory. */
|
|
350
|
+
async function restoreFileLike(kind, accs, cfg) {
|
|
351
|
+
const artifacts = await listArtifacts(kind, accs, cfg.storage.remoteRoot);
|
|
352
|
+
if (!artifacts.length) { p.log.warn(`No ${kind === 'urgent' ? 'urgent' : 'file'} backups found.`); return; }
|
|
353
|
+
const id = await p.select({
|
|
354
|
+
message: 'Pick a backup to restore:',
|
|
355
|
+
options: artifacts.map((a) => ({ value: a.id, label: `${a.id} (${bytesHuman(a.totalSize)})` })).concat([{ value: '__back', label: '← Back' }]),
|
|
356
|
+
});
|
|
357
|
+
if (p.isCancel(id) || id === '__back') return;
|
|
358
|
+
const toDir = await p.text({ message: 'Restore into which directory?', initialValue: `./restored-${id}` });
|
|
359
|
+
if (p.isCancel(toDir) || !toDir) return;
|
|
360
|
+
fs.mkdirSync(toDir, { recursive: true });
|
|
361
|
+
try {
|
|
362
|
+
const res = await restoreFiles({ id, toDir, accounts: accs, cfg, kind });
|
|
363
|
+
p.log.success(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}`);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
p.log.warn(`✖ ${e.message}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Restore files / urgent backup / system snapshot. */
|
|
318
370
|
async function restoreMenu() {
|
|
319
371
|
const accs = listAccounts();
|
|
320
372
|
if (!accs.length) { p.log.warn('No cloud accounts configured yet.'); return; }
|
|
@@ -323,29 +375,15 @@ async function restoreMenu() {
|
|
|
323
375
|
message: '♻️ Restore backup',
|
|
324
376
|
options: [
|
|
325
377
|
{ value: 'files', label: '📄 Files', hint: 'recover documents, images, etc.' },
|
|
378
|
+
{ value: 'urgent', label: '⚡ Urgent backup', hint: 'user files + tool profiles (fresh install)' },
|
|
326
379
|
{ value: 'snapshot', label: '💽 System snapshot', hint: 'full system restore [sudo]' },
|
|
327
380
|
{ value: 'back', label: '← Back' },
|
|
328
381
|
],
|
|
329
382
|
});
|
|
330
383
|
if (p.isCancel(kind) || kind === 'back') return;
|
|
331
384
|
|
|
332
|
-
if (kind === 'files') {
|
|
333
|
-
|
|
334
|
-
if (!artifacts.length) { p.log.warn('No file backups found.'); return; }
|
|
335
|
-
const id = await p.select({
|
|
336
|
-
message: 'Pick a backup generation:',
|
|
337
|
-
options: artifacts.map((a) => ({ value: a.id, label: `${a.id} (${bytesHuman(a.totalSize)})` })).concat([{ value: '__back', label: '← Back' }]),
|
|
338
|
-
});
|
|
339
|
-
if (p.isCancel(id) || id === '__back') return;
|
|
340
|
-
const toDir = await p.text({ message: 'Restore into which directory?', initialValue: `./restored-${id}` });
|
|
341
|
-
if (p.isCancel(toDir) || !toDir) return;
|
|
342
|
-
fs.mkdirSync(toDir, { recursive: true });
|
|
343
|
-
try {
|
|
344
|
-
const res = await restoreFiles({ id, toDir, accounts: accs, cfg });
|
|
345
|
-
p.log.success(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}`);
|
|
346
|
-
} catch (e) {
|
|
347
|
-
p.log.warn(`✖ ${e.message}`);
|
|
348
|
-
}
|
|
385
|
+
if (kind === 'files' || kind === 'urgent') {
|
|
386
|
+
await restoreFileLike(kind, accs, cfg);
|
|
349
387
|
return;
|
|
350
388
|
}
|
|
351
389
|
|
|
@@ -445,6 +483,7 @@ export async function runWizard() {
|
|
|
445
483
|
message: 'What would you like to do?',
|
|
446
484
|
options: [
|
|
447
485
|
{ value: 'snapshot', label: '📸 Create snapshot', hint: 'backup your system now' },
|
|
486
|
+
{ value: 'urgent', label: '⚡ Urgent backup', hint: 'files + tool profiles, fast — rescue for a fresh install' },
|
|
448
487
|
{ value: 'resume', label: '⏳ Resume upload', hint: 'resume incomplete backup uploads' },
|
|
449
488
|
{ value: 'backup', label: '💾 Run all backups', hint: 'snapshots + file backups' },
|
|
450
489
|
{ value: 'restore', label: '♻️ Restore backup', hint: 'files or system snapshot' },
|
|
@@ -476,6 +515,7 @@ export async function runWizard() {
|
|
|
476
515
|
case 'accounts': await accountsMenu(); break;
|
|
477
516
|
case 'tools': await runToolsCheck(); break;
|
|
478
517
|
case 'snapshot': await snapshotNowAction(); break;
|
|
518
|
+
case 'urgent': await urgentBackupAction(); break;
|
|
479
519
|
case 'resume': await snapshotNowAction(); break;
|
|
480
520
|
case 'backup': await backupNowAction(); break;
|
|
481
521
|
case 'list': await listBackupsAction(); break;
|
package/src/storage/allocator.js
CHANGED
|
@@ -122,12 +122,12 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
|
|
|
122
122
|
entries: [],
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
-
const
|
|
126
|
-
if (typeof onProgress === 'function') onProgress({ done, total:
|
|
125
|
+
const emit = (done, text, remote) => {
|
|
126
|
+
if (typeof onProgress === 'function') onProgress({ done, total: totalSize, text, remote });
|
|
127
127
|
};
|
|
128
128
|
|
|
129
129
|
// PASS 1: Planning
|
|
130
|
-
|
|
130
|
+
emit(0, 'planning allocations...');
|
|
131
131
|
const batches = new Map(); // account.remote -> array of relative paths
|
|
132
132
|
const splits = []; // files that need splitting
|
|
133
133
|
|
|
@@ -162,24 +162,30 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
|
|
|
162
162
|
|
|
163
163
|
// PASS 2: Batch Uploading (Sequential accounts, but parallel files inside rclone)
|
|
164
164
|
const batchDir = process.env.PBB_STATE_DIR || '/tmp';
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
const fileSizes = new Map(files.map((f) => [f.rel, f.size]));
|
|
166
|
+
const batchBytes = (rels) => rels.reduce((s, r) => s + (fileSizes.get(r) ?? 0), 0);
|
|
167
|
+
let placedBytes = 0;
|
|
168
|
+
|
|
167
169
|
for (const [remote, batch] of batches.entries()) {
|
|
168
170
|
if (batch.files.length === 0) continue;
|
|
169
171
|
const batchPath = path.join(batchDir, `batch-${remote.replace(/[^a-z0-9]/gi, '_')}-${Date.now()}.txt`);
|
|
170
172
|
fs.writeFileSync(batchPath, batch.files.join('\n') + '\n');
|
|
171
|
-
|
|
173
|
+
|
|
172
174
|
// Ensure the remote directory exists to prevent "directory not found" errors
|
|
173
175
|
// when rclone tries to list the destination for --files-from checking.
|
|
174
176
|
await mkdirRemote(`${remote}:${basePath}`);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
177
|
+
|
|
178
|
+
const thisBatchBytes = batchBytes(batch.files);
|
|
179
|
+
const label = `uploading ${batch.files.length} file(s) → ${remote}`;
|
|
180
|
+
emit(placedBytes, label, remote);
|
|
181
|
+
const res = await copyBatch(localDir, `${remote}:${basePath}`, batchPath, {
|
|
182
|
+
onProgress: (p) => emit(placedBytes + p.done, label, remote),
|
|
183
|
+
});
|
|
178
184
|
if (!res.ok) throw new Error(`batch upload failed to ${remote}: ${res.error}`);
|
|
179
|
-
|
|
185
|
+
|
|
180
186
|
fs.unlinkSync(batchPath);
|
|
181
|
-
|
|
182
|
-
|
|
187
|
+
placedBytes += thisBatchBytes;
|
|
188
|
+
emit(placedBytes, `uploaded ${bytesHuman(placedBytes)}`, remote);
|
|
183
189
|
}
|
|
184
190
|
|
|
185
191
|
// PASS 3: Split large files
|
|
@@ -189,27 +195,27 @@ export async function planAndPlace(localDir, { kind, id, accounts, remoteRoot, c
|
|
|
189
195
|
const locs = [];
|
|
190
196
|
let start = 0;
|
|
191
197
|
let partIndex = 0;
|
|
192
|
-
|
|
193
|
-
|
|
198
|
+
|
|
199
|
+
emit(placedBytes, `splitting huge file ${rel}...`);
|
|
194
200
|
while (start < entry.size) {
|
|
195
201
|
const len = Math.min(chunkSize, entry.size - start);
|
|
196
202
|
const acc = chooseAccount(len, pool);
|
|
197
203
|
if (!acc) throw outOfSpace();
|
|
198
|
-
|
|
204
|
+
|
|
199
205
|
const partAbs = await makePartFile(entry, start, len);
|
|
200
206
|
const partPath = `${destPath}.part-${String(partIndex).padStart(4, '0')}`;
|
|
201
|
-
|
|
207
|
+
|
|
202
208
|
const res = await copyToFile(partAbs, `${acc.remote}:${partPath}`);
|
|
203
209
|
if (!res.ok) throw new Error(`upload failed for ${rel} part on ${acc.remote}: ${res.error}`);
|
|
204
|
-
|
|
210
|
+
|
|
205
211
|
consume(acc, len);
|
|
206
212
|
locs.push({ remote: acc.remote, path: partPath, start, end: start + len, size: len });
|
|
207
213
|
start += len;
|
|
208
214
|
partIndex += 1;
|
|
215
|
+
placedBytes += len;
|
|
216
|
+
emit(placedBytes, `uploaded ${bytesHuman(placedBytes)}`, acc.remote);
|
|
209
217
|
}
|
|
210
218
|
manifest.entries.push({ rel, type: 'file', size: entry.size, split: true, loc: locs });
|
|
211
|
-
placed += 1;
|
|
212
|
-
report(placed, `placed ${placed}/${files.length}`);
|
|
213
219
|
}
|
|
214
220
|
|
|
215
221
|
// Manifest: cloud + local mirror.
|
package/src/storage/rclone.js
CHANGED
|
@@ -71,19 +71,59 @@ export async function copyToFile(localFile, remotePath, { ignoreExisting = false
|
|
|
71
71
|
return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/** Copy a batch of files using --files-from.
|
|
75
|
-
|
|
74
|
+
/** Copy a batch of files using --files-from. When `onProgress` is supplied we
|
|
75
|
+
* ask rclone for live transfer stats and parse its `Transferred: X / Y` frames
|
|
76
|
+
* so the caller receives byte-level progress as `{ done, total }` (bytes). */
|
|
77
|
+
export async function copyBatch(localDir, remotePath, filesFromPath, { onProgress } = {}) {
|
|
76
78
|
const args = [
|
|
77
79
|
'copy', localDir, remotePath,
|
|
78
80
|
'--files-from', filesFromPath,
|
|
79
81
|
'--transfers=16',
|
|
80
82
|
'--checkers=16',
|
|
81
|
-
'--fast-list'
|
|
83
|
+
'--fast-list',
|
|
82
84
|
];
|
|
83
|
-
|
|
85
|
+
if (typeof onProgress === 'function') {
|
|
86
|
+
// rclone emits periodic frames even when stdout is not a TTY (pipelines).
|
|
87
|
+
args.push('--progress', '--stats=1s');
|
|
88
|
+
}
|
|
89
|
+
const child = rejectFalse(args);
|
|
90
|
+
if (typeof onProgress === 'function') {
|
|
91
|
+
await consumeProgress(child, onProgress);
|
|
92
|
+
}
|
|
93
|
+
const res = await child;
|
|
84
94
|
return { ok: res.exitCode === 0, exitCode: res.exitCode, error: res.stderr?.trim() };
|
|
85
95
|
}
|
|
86
96
|
|
|
97
|
+
/** Consume rclone's --progress stdout, emitting parsed byte counts per frame. */
|
|
98
|
+
async function consumeProgress(child, onProgress) {
|
|
99
|
+
let buf = '';
|
|
100
|
+
for await (const chunk of child.stdout) {
|
|
101
|
+
buf += chunk;
|
|
102
|
+
const frames = buf.split(/[\r\n]+/);
|
|
103
|
+
buf = frames.pop(); // keep a partial trailing frame for the next chunk
|
|
104
|
+
for (const frame of frames) {
|
|
105
|
+
const p = parseTransferFrame(frame);
|
|
106
|
+
if (p) onProgress(p);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const tail = parseTransferFrame(buf);
|
|
110
|
+
if (tail) onProgress(tail);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Extract the BYTE `Transferred: <done> / <total>` from an rclone progress
|
|
115
|
+
* frame. This deliberately requires a size unit (KiB/MiB/GiB/…) so the separate
|
|
116
|
+
* FILE-count `Transferred: 3 / 5` line is never mistaken for bytes.
|
|
117
|
+
* @returns {{done:number,total:number}|null}
|
|
118
|
+
*/
|
|
119
|
+
export function parseTransferFrame(frame) {
|
|
120
|
+
const m = /\bTransferred:\s+([\d.]+\s*(?:[kmgt]i?)?b)\s*\/\s*([\d.]+\s*(?:[kmgt]i?)?b)/i.exec(frame);
|
|
121
|
+
if (!m) return null;
|
|
122
|
+
const done = parseBytes(m[1]);
|
|
123
|
+
const total = parseBytes(m[2]);
|
|
124
|
+
return done !== null && total !== null ? { done, total } : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
87
127
|
/** Download a batch of files using --files-from (reverse of copyBatch). */
|
|
88
128
|
export async function downloadBatch(remotePath, localDir, filesFromPath) {
|
|
89
129
|
const args = [
|