parrot-blackbox 2.0.6 → 2.0.9
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/snapshot.js +42 -0
- package/src/cli.js +7 -3
- package/src/commands/manage.js +4 -2
- package/src/commands/wizard.js +54 -12
- package/src/storage/allocator.js +25 -1
- package/src/util/misc.js +107 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.9",
|
|
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/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
|
}
|
package/src/cli.js
CHANGED
|
@@ -19,7 +19,7 @@ import { listAccounts, addAccount, removeAccount, refreshAccounts, poolSummary }
|
|
|
19
19
|
import { listArtifacts } from './storage/archive.js';
|
|
20
20
|
import { loadConfig, loadState, saveConfig } from './core/store.js';
|
|
21
21
|
import { configFile, stateDir } from './core/paths.js';
|
|
22
|
-
import { bytesHuman } from './util/misc.js';
|
|
22
|
+
import { bytesHuman, makeProgressRenderer } from './util/misc.js';
|
|
23
23
|
import { isOnline } from './util/network.js';
|
|
24
24
|
|
|
25
25
|
const require = createRequire(import.meta.url);
|
|
@@ -388,7 +388,9 @@ const main = defineCommand({
|
|
|
388
388
|
case 'force':
|
|
389
389
|
case 'backup': {
|
|
390
390
|
// Runs every ENABLED job right now (default = the weekly snapshot).
|
|
391
|
-
const
|
|
391
|
+
const progress = makeProgressRenderer();
|
|
392
|
+
const res = await runDueJobs({ force: true, privileged: 'interactive', onProgress: progress });
|
|
393
|
+
progress.stop();
|
|
392
394
|
const report = res.report || [];
|
|
393
395
|
if (report.length === 0) console.log(pc.dim('No enabled backup jobs — run `parrot-blackbox` to set up the schedule.'));
|
|
394
396
|
for (const r of report) {
|
|
@@ -410,7 +412,9 @@ const main = defineCommand({
|
|
|
410
412
|
const [sub, ...args] = rest;
|
|
411
413
|
if (sub === 'now' || sub === 'create' || sub === 'force') {
|
|
412
414
|
try {
|
|
413
|
-
const
|
|
415
|
+
const progress = makeProgressRenderer();
|
|
416
|
+
const r = await runSnapshotNow(undefined, undefined, { onProgress: progress });
|
|
417
|
+
progress.stop();
|
|
414
418
|
console.log(`${pc.green('✔')} Snapshot ${r.snapshot} created & uploaded (${bytesHuman(r.manifest.totalSize)}).`);
|
|
415
419
|
if (r.pruned?.length) console.log(pc.dim(`Pruned: ${r.pruned.join(', ')}`));
|
|
416
420
|
} catch (e) {
|
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,22 @@ 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
21
|
import { listArtifacts } from '../storage/archive.js';
|
|
22
22
|
import { restoreFiles, restoreSnapshot } from '../backup/restore.js';
|
|
23
23
|
import { installService, removeService } from './service.js';
|
|
24
24
|
import { startDaemon, stopDaemon, daemonRunning } from '../daemon/daemon.js';
|
|
25
25
|
import { runDoctor, runStatus, runUninstallWizard } from './manage.js';
|
|
26
26
|
import { runSetup } from './setup.js';
|
|
27
|
-
import { bytesHuman } from '../util/misc.js';
|
|
27
|
+
import { bytesHuman, makeClackProgressRenderer } from '../util/misc.js';
|
|
28
28
|
|
|
29
29
|
const require = createRequire(import.meta.url);
|
|
30
30
|
const pkg = require('../../package.json');
|
|
31
31
|
|
|
32
|
+
/** Set once a self-update happened inside THIS process — the wizard then
|
|
33
|
+
* warns that it is still running the OLD code until restarted. */
|
|
34
|
+
let updatedInSession = false;
|
|
35
|
+
|
|
32
36
|
async function importSelf() {
|
|
33
37
|
return import('../lib/self.js');
|
|
34
38
|
}
|
|
@@ -38,7 +42,7 @@ async function autoUpdateCheck() {
|
|
|
38
42
|
const { checkForUpdate, promptSelfUpdate } = await importSelf();
|
|
39
43
|
try {
|
|
40
44
|
const { outdated } = await checkForUpdate();
|
|
41
|
-
if (outdated
|
|
45
|
+
if (outdated && await promptSelfUpdate()) updatedInSession = true;
|
|
42
46
|
} catch {
|
|
43
47
|
/* offline / npm missing — never block the wizard on the update check */
|
|
44
48
|
}
|
|
@@ -139,10 +143,23 @@ async function accountsMenu() {
|
|
|
139
143
|
|
|
140
144
|
/** Run every enabled backup right now. */
|
|
141
145
|
async function backupNowAction() {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
p.log.message(pc.dim('Running backup…'));
|
|
147
|
+
const cfg = loadConfig();
|
|
148
|
+
// Warn BEFORE a FULL baseline snapshot upload (10s of GiB, hours long) is
|
|
149
|
+
// accidentally started from "Run all backups".
|
|
150
|
+
if (cfg.jobs?.snapshots?.enabled !== false) {
|
|
151
|
+
const mode = await nextSnapshotUploadMode({ cfg, privileged: 'interactive' });
|
|
152
|
+
if (mode.full) {
|
|
153
|
+
const ok = await p.confirm({
|
|
154
|
+
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?'),
|
|
155
|
+
initialValue: false,
|
|
156
|
+
});
|
|
157
|
+
if (p.isCancel(ok) || !ok) { p.log.message(pc.dim('Aborted — nothing was uploaded.')); return; }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const progress = makeClackProgressRenderer(p);
|
|
161
|
+
const res = await runDueJobs({ force: true, privileged: 'interactive', onProgress: progress });
|
|
162
|
+
progress.stop();
|
|
146
163
|
const report = res.report || [];
|
|
147
164
|
if (report.length === 0) { p.log.message(pc.dim('No enabled backup jobs.')); return; }
|
|
148
165
|
for (const r of report) {
|
|
@@ -163,7 +180,22 @@ async function backupNowAction() {
|
|
|
163
180
|
/** Create + upload a snapshot immediately. */
|
|
164
181
|
async function snapshotNowAction() {
|
|
165
182
|
try {
|
|
166
|
-
|
|
183
|
+
// A full baseline sends the ENTIRE system subvolume (10s of GiB, hours).
|
|
184
|
+
// Confirm BEFORE creating the snapshot so the user can back out cheaply.
|
|
185
|
+
const mode = await nextSnapshotUploadMode({ cfg: loadConfig(), privileged: 'interactive' });
|
|
186
|
+
if (mode.full) {
|
|
187
|
+
const ok = await p.confirm({
|
|
188
|
+
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?'),
|
|
189
|
+
initialValue: false,
|
|
190
|
+
});
|
|
191
|
+
if (p.isCancel(ok) || !ok) { p.log.message(pc.dim('Aborted — nothing was uploaded.')); return; }
|
|
192
|
+
} else if (mode.parent) {
|
|
193
|
+
p.log.message(pc.dim(`Incremental upload (parent: ${mode.parent}).`));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const progress = makeClackProgressRenderer(p);
|
|
197
|
+
const r = await runSnapshotNow(undefined, undefined, { onProgress: progress });
|
|
198
|
+
progress.stop();
|
|
167
199
|
p.log.success(`✔ Snapshot ${r.snapshot} created & uploaded (${bytesHuman(r.manifest?.totalSize ?? 0)}).`);
|
|
168
200
|
if (r.pruned?.length) p.log.message(pc.dim(`Pruned: ${r.pruned.join(', ')}`));
|
|
169
201
|
} catch (e) {
|
|
@@ -233,7 +265,8 @@ async function deleteSnapshotsMenu() {
|
|
|
233
265
|
// ── Delete ALL ─────────────────────────────────────────────────────────────
|
|
234
266
|
if (pick === '__all') {
|
|
235
267
|
const confirm = await p.confirm({
|
|
236
|
-
message: pc.red(`Delete ALL ${snapshots.length} local snapshot(s)? This cannot be undone.`)
|
|
268
|
+
message: pc.red(`Delete ALL ${snapshots.length} local snapshot(s)? This cannot be undone.`) +
|
|
269
|
+
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).'),
|
|
237
270
|
initialValue: false,
|
|
238
271
|
});
|
|
239
272
|
if (p.isCancel(confirm) || !confirm) {
|
|
@@ -263,7 +296,8 @@ async function deleteSnapshotsMenu() {
|
|
|
263
296
|
|
|
264
297
|
// ── Delete ONE ─────────────────────────────────────────────────────────────
|
|
265
298
|
const confirm = await p.confirm({
|
|
266
|
-
message: pc.yellow(`Delete snapshot ${pc.bold(pick)}?`)
|
|
299
|
+
message: pc.yellow(`Delete snapshot ${pc.bold(pick)}?`) +
|
|
300
|
+
(snapshots.length === 1 ? pc.yellow(' This is the last snapshot — the next backup will be a FULL baseline upload.') : ''),
|
|
267
301
|
initialValue: false,
|
|
268
302
|
});
|
|
269
303
|
if (p.isCancel(confirm) || !confirm) {
|
|
@@ -399,6 +433,14 @@ export async function runWizard() {
|
|
|
399
433
|
await autoUpdateCheck();
|
|
400
434
|
|
|
401
435
|
for (;;) {
|
|
436
|
+
// After an in-session self-update THIS process is still running the loaded
|
|
437
|
+
// (old) code — that's exactly the trap that makes uploads look silent in a
|
|
438
|
+
// stale session. Remind once per update so the user restarts.
|
|
439
|
+
if (updatedInSession) {
|
|
440
|
+
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.`);
|
|
441
|
+
updatedInSession = false;
|
|
442
|
+
}
|
|
443
|
+
|
|
402
444
|
const action = await p.select({
|
|
403
445
|
message: 'What would you like to do?',
|
|
404
446
|
options: [
|
|
@@ -444,8 +486,8 @@ export async function runWizard() {
|
|
|
444
486
|
case 'setup': await runSetup(); break;
|
|
445
487
|
case 'status': await runStatus(); break;
|
|
446
488
|
case 'doctor': await runDoctor(); break;
|
|
447
|
-
case 'repair': { const { runRepair } = await import('./manage.js'); await runRepair(); break; }
|
|
448
|
-
case 'update': { const { runSelfUpdate } = await import('../lib/self.js'); await runSelfUpdate(); break; }
|
|
489
|
+
case 'repair': { const { runRepair } = await import('./manage.js'); const res = await runRepair(); if (res?.updated) updatedInSession = true; break; }
|
|
490
|
+
case 'update': { const { runSelfUpdate } = await import('../lib/self.js'); if (await runSelfUpdate()) updatedInSession = true; break; }
|
|
449
491
|
case 'uninstall': await runUninstallWizard(); p.outro('parrot-blackbox removed — cloud backups are safe.'); return;
|
|
450
492
|
default: break;
|
|
451
493
|
}
|
package/src/storage/allocator.js
CHANGED
|
@@ -269,6 +269,12 @@ export async function planAndPlaceStream(btrfsStream, { kind, id, accounts, remo
|
|
|
269
269
|
let locs = [];
|
|
270
270
|
let currentStart = 0;
|
|
271
271
|
let target = getNextAccountAndPath();
|
|
272
|
+
|
|
273
|
+
// Speed tracking
|
|
274
|
+
let speedBytesWindow = 0;
|
|
275
|
+
let speedWindowStart = Date.now();
|
|
276
|
+
let currentSpeedMBs = 0;
|
|
277
|
+
const SPEED_WINDOW_MS = 1500; // recalculate speed every 1.5 s
|
|
272
278
|
|
|
273
279
|
currentChild = spawn(process.env.PBB_RCLONE || 'rclone', ['rcat', `${target.remote}:${target.path}`]);
|
|
274
280
|
let childFailed = false;
|
|
@@ -287,6 +293,7 @@ export async function planAndPlaceStream(btrfsStream, { kind, id, accounts, remo
|
|
|
287
293
|
|
|
288
294
|
currentBytesInChunk += toWrite;
|
|
289
295
|
totalBytes += toWrite;
|
|
296
|
+
speedBytesWindow += toWrite;
|
|
290
297
|
offset += toWrite;
|
|
291
298
|
|
|
292
299
|
if (!canContinue) {
|
|
@@ -309,7 +316,24 @@ export async function planAndPlaceStream(btrfsStream, { kind, id, accounts, remo
|
|
|
309
316
|
currentChild.on('exit', (code) => { if (code !== 0) childFailed = true; });
|
|
310
317
|
}
|
|
311
318
|
}
|
|
312
|
-
|
|
319
|
+
|
|
320
|
+
// Recalculate speed on each chunk and emit progress
|
|
321
|
+
if (onProgress) {
|
|
322
|
+
const now = Date.now();
|
|
323
|
+
const elapsed = now - speedWindowStart;
|
|
324
|
+
if (elapsed >= SPEED_WINDOW_MS) {
|
|
325
|
+
currentSpeedMBs = (speedBytesWindow / (1024 * 1024)) / (elapsed / 1000);
|
|
326
|
+
speedBytesWindow = 0;
|
|
327
|
+
speedWindowStart = now;
|
|
328
|
+
}
|
|
329
|
+
onProgress({
|
|
330
|
+
done: totalBytes,
|
|
331
|
+
total: originalSize,
|
|
332
|
+
speedMBs: currentSpeedMBs,
|
|
333
|
+
remote: target.remote,
|
|
334
|
+
text: `uploading stream: ${(totalBytes / (1024 ** 2)).toFixed(1)} MB`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
313
337
|
}
|
|
314
338
|
|
|
315
339
|
if (currentChild) {
|
package/src/util/misc.js
CHANGED
|
@@ -111,4 +111,111 @@ export function shellQuote(s) {
|
|
|
111
111
|
|
|
112
112
|
export function pad2(n) {
|
|
113
113
|
return String(n).padStart(2, '0');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Returns an onProgress handler that renders a live, in-place progress bar
|
|
118
|
+
* with percentage, MB transferred, and upload speed.
|
|
119
|
+
*
|
|
120
|
+
* Expected event shape:
|
|
121
|
+
* { done: number (bytes), total: number (bytes or 0), speedMBs?: number, remote?: string }
|
|
122
|
+
*
|
|
123
|
+
* Call renderer.stop() when done to advance to the next line.
|
|
124
|
+
*
|
|
125
|
+
* When `total` is 0 (stream size unknown) we display bytes + speed only.
|
|
126
|
+
*/
|
|
127
|
+
export function makeProgressRenderer() {
|
|
128
|
+
const isTTY = process.stdout.isTTY;
|
|
129
|
+
const BAR_WIDTH = 25;
|
|
130
|
+
let lastLine = '';
|
|
131
|
+
|
|
132
|
+
function buildLine({ done = 0, total = 0, speedMBs = 0, remote = '' } = {}) {
|
|
133
|
+
const doneMB = done / (1024 * 1024);
|
|
134
|
+
const totalMB = total / (1024 * 1024);
|
|
135
|
+
const speedStr = speedMBs > 0 ? ` ${speedMBs.toFixed(1)} MB/s` : '';
|
|
136
|
+
const destStr = remote ? ` → ${remote}` : '';
|
|
137
|
+
|
|
138
|
+
if (total > 0) {
|
|
139
|
+
const pct = Math.min(100, Math.round((done / total) * 100));
|
|
140
|
+
const filled = Math.round((pct / 100) * BAR_WIDTH);
|
|
141
|
+
const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
|
|
142
|
+
return ` [${bar}] ${String(pct).padStart(3)}% ${doneMB.toFixed(1)} MB / ${totalMB.toFixed(1)} MB${speedStr}${destStr}`;
|
|
143
|
+
}
|
|
144
|
+
return ` ⬆ ${doneMB.toFixed(1)} MB streamed${speedStr}${destStr}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function render(evt = {}) {
|
|
148
|
+
const line = buildLine(evt);
|
|
149
|
+
if (isTTY) {
|
|
150
|
+
process.stdout.write(`\r${line}\x1b[K`);
|
|
151
|
+
} else if (line !== lastLine) {
|
|
152
|
+
// Non-TTY (piped / daemon log): only emit when something changes to
|
|
153
|
+
// avoid flooding the journal with thousands of identical lines.
|
|
154
|
+
process.stdout.write(line + '\n');
|
|
155
|
+
}
|
|
156
|
+
lastLine = line;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
render.stop = function stop() {
|
|
160
|
+
if (isTTY && lastLine) process.stdout.write('\n');
|
|
161
|
+
lastLine = '';
|
|
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
|
+
|
|
220
|
+
return render;
|
|
114
221
|
}
|