parrot-blackbox 2.0.7 → 2.1.0
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/snapshot.js +42 -0
- package/src/backup/urgent.js +147 -0
- package/src/cli.js +30 -4
- package/src/commands/manage.js +4 -2
- package/src/commands/wizard.js +110 -30
- package/src/util/misc.js +64 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
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
|
|
package/src/backup/snapshot.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { execa, execaSync } from 'execa';
|
|
13
|
+
import pc from 'picocolors';
|
|
13
14
|
import { loadConfig, loadState, saveState, journal, hasCommandSync } from '../core/store.js';
|
|
14
15
|
import { timeshiftDir, stateDir, configFile, manifestsDir } from '../core/paths.js';
|
|
15
16
|
import { iso, clock } from '../core/time.js';
|
|
@@ -589,6 +590,15 @@ async function uploadViaBtrfsSend({ snapshot, subvolPath, parentSnapshot, parent
|
|
|
589
590
|
const estimatedSize = await estimateSendSize(subvolPath, { parent: parentSubvolPath });
|
|
590
591
|
const btrfsCfg = cfg.jobs.snapshots.btrfs || {};
|
|
591
592
|
|
|
593
|
+
// A missing parent means this is the unavoidable FULL baseline: `btrfs send -p`
|
|
594
|
+
// needs the parent subvolume to exist on disk, so there is no smaller option.
|
|
595
|
+
// Say so loudly BEFORE the upload starts — the wizard also confirmation-gates it.
|
|
596
|
+
if (!parentSubvolPath) {
|
|
597
|
+
console.log(pc.yellow('⚠ No parent snapshot available — this is a FULL baseline upload of the entire system subvolume.'));
|
|
598
|
+
console.log(pc.yellow(` Estimated ${(estimatedSize / (1024 ** 3)).toFixed(1)} GiB raw — on a typical uplink this can take hours.`));
|
|
599
|
+
console.log(pc.yellow(' Once a snapshot is fully uploaded and kept, subsequent backups become small increments.'));
|
|
600
|
+
}
|
|
601
|
+
|
|
592
602
|
console.log(`\n📤 Uploading ${parentSubvolPath ? 'incremental' : 'full'} BTRFS stream...`);
|
|
593
603
|
console.log(` Estimated size: ${(estimatedSize / (1024 ** 3)).toFixed(2)} GiB`);
|
|
594
604
|
if (btrfsCfg.compression !== false) console.log(` Compression: zstd enabled`);
|
|
@@ -834,6 +844,38 @@ export async function runSnapshotNow(cfg = loadConfig(), state = loadState(), op
|
|
|
834
844
|
return runSnapshotBackup(cfg, state, { due, privileged: 'interactive', ...opts });
|
|
835
845
|
}
|
|
836
846
|
|
|
847
|
+
/**
|
|
848
|
+
* Decide whether the NEXT snapshot upload would be a small incremental (a
|
|
849
|
+
* fully-uploaded parent snapshot still exists on disk) or a FULL baseline send.
|
|
850
|
+
*
|
|
851
|
+
* Lightweight probe — it only lists local snapshots and reads local manifests;
|
|
852
|
+
* it never creates anything. The wizard uses this to warn and confirm BEFORE
|
|
853
|
+
* a multi-hour full baseline upload is kicked off.
|
|
854
|
+
*
|
|
855
|
+
* @param {object} opts
|
|
856
|
+
* @param {object} [opts.cfg] config object (defaults to on-disk config)
|
|
857
|
+
* @param {'interactive'|'noninteractive'} [opts.privileged]
|
|
858
|
+
* @param {Array|null} [opts.localSnaps] injectable snapshot list (tests)
|
|
859
|
+
* @param {string|null} [opts.manifestsDirOverride] injectable manifests dir (tests)
|
|
860
|
+
* @returns {Promise<{full: boolean, parent: string|null, reason: string|null}>}
|
|
861
|
+
*/
|
|
862
|
+
export async function nextSnapshotUploadMode({ cfg = loadConfig(), privileged = 'noninteractive', localSnaps = null, manifestsDirOverride = null } = {}) {
|
|
863
|
+
const btrfsCfg = cfg?.jobs?.snapshots?.btrfs || {};
|
|
864
|
+
if (btrfsCfg.enabled === false || process.env.PBB_DISABLE_BTRFS) {
|
|
865
|
+
return { full: false, parent: null, reason: 'BTRFS streaming disabled — file-copy mode in use' };
|
|
866
|
+
}
|
|
867
|
+
if (btrfsCfg.incremental === false) {
|
|
868
|
+
return { full: false, parent: null, reason: 'incremental uploads disabled in config (full sends are intentional)' };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const snaps = localSnaps ?? (await listLocalSnapshots({ privileged }).catch(() => []));
|
|
872
|
+
const { findLastUploadedSnapshot } = await import('./btrfs-send.js');
|
|
873
|
+
const parent = findLastUploadedSnapshot(manifestsDirOverride || manifestsDir(), snaps);
|
|
874
|
+
return parent
|
|
875
|
+
? { full: false, parent, reason: null }
|
|
876
|
+
: { full: true, parent: null, reason: 'no fully-uploaded parent snapshot on disk' };
|
|
877
|
+
}
|
|
878
|
+
|
|
837
879
|
export function timeshiftAvailable() {
|
|
838
880
|
return hasCommandSync('timeshift');
|
|
839
881
|
}
|
|
@@ -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/manage.js
CHANGED
|
@@ -231,6 +231,7 @@ export async function runRepair({ auto = false } = {}) {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
// 5. Optional npm reinstall (repair a broken CLI install)
|
|
234
|
+
let updated = false;
|
|
234
235
|
if (!auto) {
|
|
235
236
|
const want = await p.confirm({
|
|
236
237
|
message: 'Reinstall parrot-blackbox from npm to repair the executable?',
|
|
@@ -239,10 +240,11 @@ export async function runRepair({ auto = false } = {}) {
|
|
|
239
240
|
if (!p.isCancel(want) && want) {
|
|
240
241
|
const { runSelfUpdate } = await import('../lib/self.js');
|
|
241
242
|
p.log.step('Reinstalling from npm…');
|
|
242
|
-
|
|
243
|
-
if (
|
|
243
|
+
updated = await runSelfUpdate({ force: true });
|
|
244
|
+
if (updated) fixed.push('npm');
|
|
244
245
|
}
|
|
245
246
|
}
|
|
246
247
|
|
|
247
248
|
p.outro(pc.green(fixed.length ? `Repair complete — fixed: ${fixed.join(', ')}.` : 'Nothing to repair — everything looks healthy.'));
|
|
249
|
+
return { updated };
|
|
248
250
|
}
|
package/src/commands/wizard.js
CHANGED
|
@@ -17,18 +17,23 @@ 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, deleteSnapshot, deleteAllSnapshots } from '../backup/snapshot.js';
|
|
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';
|
|
24
25
|
import { startDaemon, stopDaemon, daemonRunning } from '../daemon/daemon.js';
|
|
25
26
|
import { runDoctor, runStatus, runUninstallWizard } from './manage.js';
|
|
26
27
|
import { runSetup } from './setup.js';
|
|
27
|
-
import { bytesHuman,
|
|
28
|
+
import { bytesHuman, makeClackProgressRenderer } from '../util/misc.js';
|
|
28
29
|
|
|
29
30
|
const require = createRequire(import.meta.url);
|
|
30
31
|
const pkg = require('../../package.json');
|
|
31
32
|
|
|
33
|
+
/** Set once a self-update happened inside THIS process — the wizard then
|
|
34
|
+
* warns that it is still running the OLD code until restarted. */
|
|
35
|
+
let updatedInSession = false;
|
|
36
|
+
|
|
32
37
|
async function importSelf() {
|
|
33
38
|
return import('../lib/self.js');
|
|
34
39
|
}
|
|
@@ -38,7 +43,7 @@ async function autoUpdateCheck() {
|
|
|
38
43
|
const { checkForUpdate, promptSelfUpdate } = await importSelf();
|
|
39
44
|
try {
|
|
40
45
|
const { outdated } = await checkForUpdate();
|
|
41
|
-
if (outdated
|
|
46
|
+
if (outdated && await promptSelfUpdate()) updatedInSession = true;
|
|
42
47
|
} catch {
|
|
43
48
|
/* offline / npm missing — never block the wizard on the update check */
|
|
44
49
|
}
|
|
@@ -139,10 +144,23 @@ async function accountsMenu() {
|
|
|
139
144
|
|
|
140
145
|
/** Run every enabled backup right now. */
|
|
141
146
|
async function backupNowAction() {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
147
|
+
p.log.message(pc.dim('Running backup…'));
|
|
148
|
+
const cfg = loadConfig();
|
|
149
|
+
// Warn BEFORE a FULL baseline snapshot upload (10s of GiB, hours long) is
|
|
150
|
+
// accidentally started from "Run all backups".
|
|
151
|
+
if (cfg.jobs?.snapshots?.enabled !== false) {
|
|
152
|
+
const mode = await nextSnapshotUploadMode({ cfg, privileged: 'interactive' });
|
|
153
|
+
if (mode.full) {
|
|
154
|
+
const ok = await p.confirm({
|
|
155
|
+
message: pc.red('⚠ No previous snapshot found — the snapshot backup will be a FULL baseline upload of the ENTIRE system (10s of GiB, can take hours). Continue?'),
|
|
156
|
+
initialValue: false,
|
|
157
|
+
});
|
|
158
|
+
if (p.isCancel(ok) || !ok) { p.log.message(pc.dim('Aborted — nothing was uploaded.')); return; }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const progress = makeClackProgressRenderer(p);
|
|
162
|
+
const res = await runDueJobs({ force: true, privileged: 'interactive', onProgress: progress });
|
|
163
|
+
progress.stop();
|
|
146
164
|
const report = res.report || [];
|
|
147
165
|
if (report.length === 0) { p.log.message(pc.dim('No enabled backup jobs.')); return; }
|
|
148
166
|
for (const r of report) {
|
|
@@ -160,10 +178,50 @@ async function backupNowAction() {
|
|
|
160
178
|
}
|
|
161
179
|
}
|
|
162
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
|
+
|
|
163
208
|
/** Create + upload a snapshot immediately. */
|
|
164
209
|
async function snapshotNowAction() {
|
|
165
210
|
try {
|
|
166
|
-
|
|
211
|
+
// A full baseline sends the ENTIRE system subvolume (10s of GiB, hours).
|
|
212
|
+
// Confirm BEFORE creating the snapshot so the user can back out cheaply.
|
|
213
|
+
const mode = await nextSnapshotUploadMode({ cfg: loadConfig(), privileged: 'interactive' });
|
|
214
|
+
if (mode.full) {
|
|
215
|
+
const ok = await p.confirm({
|
|
216
|
+
message: pc.red('⚠ No previous snapshot found — this will be a FULL baseline upload of the ENTIRE system (10s of GiB, can take hours). Continue?'),
|
|
217
|
+
initialValue: false,
|
|
218
|
+
});
|
|
219
|
+
if (p.isCancel(ok) || !ok) { p.log.message(pc.dim('Aborted — nothing was uploaded.')); return; }
|
|
220
|
+
} else if (mode.parent) {
|
|
221
|
+
p.log.message(pc.dim(`Incremental upload (parent: ${mode.parent}).`));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const progress = makeClackProgressRenderer(p);
|
|
167
225
|
const r = await runSnapshotNow(undefined, undefined, { onProgress: progress });
|
|
168
226
|
progress.stop();
|
|
169
227
|
p.log.success(`✔ Snapshot ${r.snapshot} created & uploaded (${bytesHuman(r.manifest?.totalSize ?? 0)}).`);
|
|
@@ -194,6 +252,10 @@ async function listBackupsAction() {
|
|
|
194
252
|
const files = await listArtifacts('files', accs, cfg.storage.remoteRoot);
|
|
195
253
|
if (!files.length) p.log.message(pc.dim(' none'));
|
|
196
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)}`);
|
|
197
259
|
} else {
|
|
198
260
|
p.log.message(pc.dim('No accounts configured — add one from the menu.'));
|
|
199
261
|
}
|
|
@@ -235,7 +297,8 @@ async function deleteSnapshotsMenu() {
|
|
|
235
297
|
// ── Delete ALL ─────────────────────────────────────────────────────────────
|
|
236
298
|
if (pick === '__all') {
|
|
237
299
|
const confirm = await p.confirm({
|
|
238
|
-
message: pc.red(`Delete ALL ${snapshots.length} local snapshot(s)? This cannot be undone.`)
|
|
300
|
+
message: pc.red(`Delete ALL ${snapshots.length} local snapshot(s)? This cannot be undone.`) +
|
|
301
|
+
pc.yellow(' No parent snapshot will remain — the NEXT backup becomes a FULL baseline upload of the entire system (10s of GiB, can take hours).'),
|
|
239
302
|
initialValue: false,
|
|
240
303
|
});
|
|
241
304
|
if (p.isCancel(confirm) || !confirm) {
|
|
@@ -265,7 +328,8 @@ async function deleteSnapshotsMenu() {
|
|
|
265
328
|
|
|
266
329
|
// ── Delete ONE ─────────────────────────────────────────────────────────────
|
|
267
330
|
const confirm = await p.confirm({
|
|
268
|
-
message: pc.yellow(`Delete snapshot ${pc.bold(pick)}?`)
|
|
331
|
+
message: pc.yellow(`Delete snapshot ${pc.bold(pick)}?`) +
|
|
332
|
+
(snapshots.length === 1 ? pc.yellow(' This is the last snapshot — the next backup will be a FULL baseline upload.') : ''),
|
|
269
333
|
initialValue: false,
|
|
270
334
|
});
|
|
271
335
|
if (p.isCancel(confirm) || !confirm) {
|
|
@@ -282,7 +346,27 @@ async function deleteSnapshotsMenu() {
|
|
|
282
346
|
}
|
|
283
347
|
}
|
|
284
348
|
|
|
285
|
-
/** 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. */
|
|
286
370
|
async function restoreMenu() {
|
|
287
371
|
const accs = listAccounts();
|
|
288
372
|
if (!accs.length) { p.log.warn('No cloud accounts configured yet.'); return; }
|
|
@@ -291,29 +375,15 @@ async function restoreMenu() {
|
|
|
291
375
|
message: '♻️ Restore backup',
|
|
292
376
|
options: [
|
|
293
377
|
{ value: 'files', label: '📄 Files', hint: 'recover documents, images, etc.' },
|
|
378
|
+
{ value: 'urgent', label: '⚡ Urgent backup', hint: 'user files + tool profiles (fresh install)' },
|
|
294
379
|
{ value: 'snapshot', label: '💽 System snapshot', hint: 'full system restore [sudo]' },
|
|
295
380
|
{ value: 'back', label: '← Back' },
|
|
296
381
|
],
|
|
297
382
|
});
|
|
298
383
|
if (p.isCancel(kind) || kind === 'back') return;
|
|
299
384
|
|
|
300
|
-
if (kind === 'files') {
|
|
301
|
-
|
|
302
|
-
if (!artifacts.length) { p.log.warn('No file backups found.'); return; }
|
|
303
|
-
const id = await p.select({
|
|
304
|
-
message: 'Pick a backup generation:',
|
|
305
|
-
options: artifacts.map((a) => ({ value: a.id, label: `${a.id} (${bytesHuman(a.totalSize)})` })).concat([{ value: '__back', label: '← Back' }]),
|
|
306
|
-
});
|
|
307
|
-
if (p.isCancel(id) || id === '__back') return;
|
|
308
|
-
const toDir = await p.text({ message: 'Restore into which directory?', initialValue: `./restored-${id}` });
|
|
309
|
-
if (p.isCancel(toDir) || !toDir) return;
|
|
310
|
-
fs.mkdirSync(toDir, { recursive: true });
|
|
311
|
-
try {
|
|
312
|
-
const res = await restoreFiles({ id, toDir, accounts: accs, cfg });
|
|
313
|
-
p.log.success(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}`);
|
|
314
|
-
} catch (e) {
|
|
315
|
-
p.log.warn(`✖ ${e.message}`);
|
|
316
|
-
}
|
|
385
|
+
if (kind === 'files' || kind === 'urgent') {
|
|
386
|
+
await restoreFileLike(kind, accs, cfg);
|
|
317
387
|
return;
|
|
318
388
|
}
|
|
319
389
|
|
|
@@ -401,10 +471,19 @@ export async function runWizard() {
|
|
|
401
471
|
await autoUpdateCheck();
|
|
402
472
|
|
|
403
473
|
for (;;) {
|
|
474
|
+
// After an in-session self-update THIS process is still running the loaded
|
|
475
|
+
// (old) code — that's exactly the trap that makes uploads look silent in a
|
|
476
|
+
// stale session. Remind once per update so the user restarts.
|
|
477
|
+
if (updatedInSession) {
|
|
478
|
+
p.log.warn(`⚠ Updated earlier this session — this process still runs the OLD v${pkg.version} code. Exit and re-run \`parrot-blackbox\` to use the new version.`);
|
|
479
|
+
updatedInSession = false;
|
|
480
|
+
}
|
|
481
|
+
|
|
404
482
|
const action = await p.select({
|
|
405
483
|
message: 'What would you like to do?',
|
|
406
484
|
options: [
|
|
407
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' },
|
|
408
487
|
{ value: 'resume', label: '⏳ Resume upload', hint: 'resume incomplete backup uploads' },
|
|
409
488
|
{ value: 'backup', label: '💾 Run all backups', hint: 'snapshots + file backups' },
|
|
410
489
|
{ value: 'restore', label: '♻️ Restore backup', hint: 'files or system snapshot' },
|
|
@@ -436,6 +515,7 @@ export async function runWizard() {
|
|
|
436
515
|
case 'accounts': await accountsMenu(); break;
|
|
437
516
|
case 'tools': await runToolsCheck(); break;
|
|
438
517
|
case 'snapshot': await snapshotNowAction(); break;
|
|
518
|
+
case 'urgent': await urgentBackupAction(); break;
|
|
439
519
|
case 'resume': await snapshotNowAction(); break;
|
|
440
520
|
case 'backup': await backupNowAction(); break;
|
|
441
521
|
case 'list': await listBackupsAction(); break;
|
|
@@ -446,8 +526,8 @@ export async function runWizard() {
|
|
|
446
526
|
case 'setup': await runSetup(); break;
|
|
447
527
|
case 'status': await runStatus(); break;
|
|
448
528
|
case 'doctor': await runDoctor(); break;
|
|
449
|
-
case 'repair': { const { runRepair } = await import('./manage.js'); await runRepair(); break; }
|
|
450
|
-
case 'update': { const { runSelfUpdate } = await import('../lib/self.js'); await runSelfUpdate(); break; }
|
|
529
|
+
case 'repair': { const { runRepair } = await import('./manage.js'); const res = await runRepair(); if (res?.updated) updatedInSession = true; break; }
|
|
530
|
+
case 'update': { const { runSelfUpdate } = await import('../lib/self.js'); if (await runSelfUpdate()) updatedInSession = true; break; }
|
|
451
531
|
case 'uninstall': await runUninstallWizard(); p.outro('parrot-blackbox removed — cloud backups are safe.'); return;
|
|
452
532
|
default: break;
|
|
453
533
|
}
|
package/src/util/misc.js
CHANGED
|
@@ -129,22 +129,23 @@ export function makeProgressRenderer() {
|
|
|
129
129
|
const BAR_WIDTH = 25;
|
|
130
130
|
let lastLine = '';
|
|
131
131
|
|
|
132
|
-
function
|
|
133
|
-
const doneMB
|
|
134
|
-
const totalMB
|
|
132
|
+
function buildLine({ done = 0, total = 0, speedMBs = 0, remote = '' } = {}) {
|
|
133
|
+
const doneMB = done / (1024 * 1024);
|
|
134
|
+
const totalMB = total / (1024 * 1024);
|
|
135
135
|
const speedStr = speedMBs > 0 ? ` ${speedMBs.toFixed(1)} MB/s` : '';
|
|
136
136
|
const destStr = remote ? ` → ${remote}` : '';
|
|
137
137
|
|
|
138
|
-
let line;
|
|
139
138
|
if (total > 0) {
|
|
140
139
|
const pct = Math.min(100, Math.round((done / total) * 100));
|
|
141
140
|
const filled = Math.round((pct / 100) * BAR_WIDTH);
|
|
142
141
|
const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
|
|
143
|
-
|
|
144
|
-
} else {
|
|
145
|
-
line = ` ⬆ ${doneMB.toFixed(1)} MB streamed${speedStr}${destStr}`;
|
|
142
|
+
return ` [${bar}] ${String(pct).padStart(3)}% ${doneMB.toFixed(1)} MB / ${totalMB.toFixed(1)} MB${speedStr}${destStr}`;
|
|
146
143
|
}
|
|
144
|
+
return ` ⬆ ${doneMB.toFixed(1)} MB streamed${speedStr}${destStr}`;
|
|
145
|
+
}
|
|
147
146
|
|
|
147
|
+
function render(evt = {}) {
|
|
148
|
+
const line = buildLine(evt);
|
|
148
149
|
if (isTTY) {
|
|
149
150
|
process.stdout.write(`\r${line}\x1b[K`);
|
|
150
151
|
} else if (line !== lastLine) {
|
|
@@ -160,5 +161,61 @@ export function makeProgressRenderer() {
|
|
|
160
161
|
lastLine = '';
|
|
161
162
|
};
|
|
162
163
|
|
|
164
|
+
return render;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A @clack/prompts-aware progress renderer.
|
|
169
|
+
*
|
|
170
|
+
* Inside the clack wizard the terminal is managed by clack's ANSI cursor
|
|
171
|
+
* tracking — raw `\r` writes get clobbered. This variant throttles output
|
|
172
|
+
* to at most one `p.log.message()` call per second so clack can handle
|
|
173
|
+
* rendering, and the bar stays readable.
|
|
174
|
+
*
|
|
175
|
+
* Usage:
|
|
176
|
+
* import * as p from '@clack/prompts';
|
|
177
|
+
* const progress = makeClackProgressRenderer(p);
|
|
178
|
+
* await runSnapshotNow(undefined, undefined, { onProgress: progress });
|
|
179
|
+
* progress.stop();
|
|
180
|
+
*
|
|
181
|
+
* Expected event shape: same as makeProgressRenderer().
|
|
182
|
+
*/
|
|
183
|
+
export function makeClackProgressRenderer(p) {
|
|
184
|
+
const BAR_WIDTH = 20;
|
|
185
|
+
const THROTTLE_MS = 800; // max one clack log line per 800 ms
|
|
186
|
+
let lastEmitAt = 0;
|
|
187
|
+
let lastLine = '';
|
|
188
|
+
|
|
189
|
+
function buildLine({ done = 0, total = 0, speedMBs = 0, remote = '' } = {}) {
|
|
190
|
+
const doneMB = done / (1024 * 1024);
|
|
191
|
+
const totalMB = total / (1024 * 1024);
|
|
192
|
+
const speedStr = speedMBs > 0 ? ` ${speedMBs.toFixed(1)} MB/s` : '';
|
|
193
|
+
const destStr = remote ? ` → ${remote}` : '';
|
|
194
|
+
|
|
195
|
+
if (total > 0) {
|
|
196
|
+
const pct = Math.min(100, Math.round((done / total) * 100));
|
|
197
|
+
const filled = Math.round((pct / 100) * BAR_WIDTH);
|
|
198
|
+
const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
|
|
199
|
+
return `[${bar}] ${String(pct).padStart(3)}% ${doneMB.toFixed(1)} / ${totalMB.toFixed(1)} MB${speedStr}${destStr}`;
|
|
200
|
+
}
|
|
201
|
+
return `⬆ ${doneMB.toFixed(1)} MB streamed${speedStr}${destStr}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function render(evt = {}) {
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
const line = buildLine(evt);
|
|
207
|
+
if (line === lastLine) return; // nothing changed
|
|
208
|
+
if (now - lastEmitAt < THROTTLE_MS) return; // too soon
|
|
209
|
+
lastEmitAt = now;
|
|
210
|
+
lastLine = line;
|
|
211
|
+
p.log.message(line);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
render.stop = function stop() {
|
|
215
|
+
// emit the final state unconditionally so the user sees 100% or final MB
|
|
216
|
+
if (lastLine) p.log.message(lastLine);
|
|
217
|
+
lastLine = '';
|
|
218
|
+
};
|
|
219
|
+
|
|
163
220
|
return render;
|
|
164
221
|
}
|