parrot-blackbox 1.0.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/LICENSE +21 -0
- package/README.md +260 -0
- package/bin/parrot-blackbox.js +2 -0
- package/package.json +58 -0
- package/src/backup/git-exclude.js +122 -0
- package/src/backup/restore.js +93 -0
- package/src/backup/retention.js +22 -0
- package/src/backup/snapshot.js +214 -0
- package/src/backup/workspace.js +106 -0
- package/src/cli.js +426 -0
- package/src/commands/manage.js +128 -0
- package/src/commands/service.js +122 -0
- package/src/commands/setup.js +214 -0
- package/src/core/lock.js +67 -0
- package/src/core/paths.js +95 -0
- package/src/core/store.js +173 -0
- package/src/core/time.js +142 -0
- package/src/daemon/daemon.js +107 -0
- package/src/daemon/scheduler.js +135 -0
- package/src/storage/accounts.js +110 -0
- package/src/storage/allocator.js +189 -0
- package/src/storage/archive.js +138 -0
- package/src/storage/rclone.js +106 -0
- package/src/util/misc.js +114 -0
- package/src/util/network.js +26 -0
- package/src/util/sudo.js +56 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { defineCommand, runMain } from 'citty';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { runSetup } from './commands/setup.js';
|
|
6
|
+
import { runDoctor, runStatus, runUninstallWizard } from './commands/manage.js';
|
|
7
|
+
import { installService, removeService } from './commands/service.js';
|
|
8
|
+
import { runDueJobs } from './daemon/scheduler.js';
|
|
9
|
+
import { startDaemon, stopDaemon, daemonRunning } from './daemon/daemon.js';
|
|
10
|
+
import { runSnapshotNow, listLocalSnapshots, pruneSnapshots } from './backup/snapshot.js';
|
|
11
|
+
import { restoreSnapshot, restoreFiles } from './backup/restore.js';
|
|
12
|
+
import { listAccounts, addAccount, removeAccount, refreshAccounts, poolSummary } from './storage/accounts.js';
|
|
13
|
+
import { listArtifacts } from './storage/archive.js';
|
|
14
|
+
import { loadConfig, loadState, saveConfig } from './core/store.js';
|
|
15
|
+
import { configFile, stateDir } from './core/paths.js';
|
|
16
|
+
import { bytesHuman } from './util/misc.js';
|
|
17
|
+
import { isOnline } from './util/network.js';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const pkg = require('../package.json');
|
|
21
|
+
|
|
22
|
+
/** Terminal safety net (same as gitswitch/theamify/warp-wizard). */
|
|
23
|
+
function terminateAndRestore(code) {
|
|
24
|
+
try {
|
|
25
|
+
if (process.stdout.isTTY) process.stdout.write('\x1b[?25h\n\r');
|
|
26
|
+
} catch { /* best effort */ }
|
|
27
|
+
process.exit(code);
|
|
28
|
+
}
|
|
29
|
+
if (process.platform !== 'win32') process.on('SIGTSTP', () => terminateAndRestore(130));
|
|
30
|
+
process.on('SIGINT', () => terminateAndRestore(130));
|
|
31
|
+
process.on('SIGTERM', () => terminateAndRestore(143));
|
|
32
|
+
|
|
33
|
+
function printUsage() {
|
|
34
|
+
console.log(`
|
|
35
|
+
${pc.bold('parrot-blackbox')} ${pc.dim(`v${pkg.version}`)} — crash-proof multi-cloud backup & recovery for Parrot OS
|
|
36
|
+
|
|
37
|
+
${pc.bold('Usage:')}
|
|
38
|
+
parrot-blackbox Interactive setup wizard (checks & installs tools)
|
|
39
|
+
parrot-blackbox run Run any due / pending backups now (safe for cron)
|
|
40
|
+
parrot-blackbox force ⭐ Run every enabled backup NOW (default = weekly snapshot) ${pc.dim('[sudo]')}
|
|
41
|
+
parrot-blackbox snapshot now Create a weekly snapshot + upload it now ${pc.dim('[sudo]')}
|
|
42
|
+
parrot-blackbox snapshot list List local & cloud snapshots
|
|
43
|
+
parrot-blackbox snapshot prune Delete snapshots beyond the keep limit ${pc.dim('[sudo]')}
|
|
44
|
+
parrot-blackbox list [files] List cloud file backups
|
|
45
|
+
parrot-blackbox restore Restore a snapshot or file backup ${pc.dim('[sudo]')}
|
|
46
|
+
parrot-blackbox account add Add a MEGA / Google Drive account
|
|
47
|
+
parrot-blackbox account list Show the storage pool & usage
|
|
48
|
+
parrot-blackbox account remove <id> Remove an account from the pool
|
|
49
|
+
parrot-blackbox account quota <id> <GiB> Override an account quota
|
|
50
|
+
parrot-blackbox daemon start|stop|status Background automation
|
|
51
|
+
parrot-blackbox schedule install|remove systemd / cron always-on setup
|
|
52
|
+
parrot-blackbox doctor Full diagnostics
|
|
53
|
+
parrot-blackbox status Quick status
|
|
54
|
+
parrot-blackbox uninstall Remove everything (cloud data kept)
|
|
55
|
+
parrot-blackbox version | help
|
|
56
|
+
`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function invokeRun(privileged) {
|
|
60
|
+
const res = await runDueJobs({ privileged });
|
|
61
|
+
const report = res.report || [];
|
|
62
|
+
if (report.length === 0) {
|
|
63
|
+
console.log(pc.dim('Nothing due right now — the default schedule is the weekly snapshot (Saturday 22:00).'));
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
for (const r of report) {
|
|
67
|
+
if (r.ok) {
|
|
68
|
+
if (r.deferred) {
|
|
69
|
+
console.log(`${pc.yellow('⏸')} ${r.type} ${r.due} deferred (sudo needed) — will retry automatically when the sudo timestamp is re-armed.`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const extra = r.pruned?.length ? ` (pruned ${r.pruned.join(', ')})` : '';
|
|
73
|
+
const sizeLabel = r.size ? ` (${bytesHuman(r.size)})` : '';
|
|
74
|
+
console.log(`${pc.green('✔')} ${r.type} ${r.due} ${r.snapshot ? `snapshot=${r.snapshot}` : 'stored'}${sizeLabel}${extra}`);
|
|
75
|
+
} else {
|
|
76
|
+
console.log(`${pc.red('✖')} ${r.type} ${r.due} failed: ${r.error}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (res.deferred) console.log(pc.yellow('Deferred (offline or sudo needed) — will catch up automatically when possible.'));
|
|
80
|
+
return report.some((r) => !r.ok) ? 1 : 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function snapshotList() {
|
|
84
|
+
const cfg = loadConfig();
|
|
85
|
+
const accs = listAccounts();
|
|
86
|
+
console.log(`${pc.bold('\nLocal snapshots (Timeshift):')}`);
|
|
87
|
+
try {
|
|
88
|
+
const local = await listLocalSnapshots({ privileged: 'noninteractive' });
|
|
89
|
+
if (local.length === 0) console.log(` ${pc.dim('none')}`);
|
|
90
|
+
for (const s of local) console.log(` - ${pc.cyan(s.name)} ${pc.dim(s.tags)}`);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.log(` ${pc.yellow(e.message)}`);
|
|
93
|
+
}
|
|
94
|
+
if (accs.length) {
|
|
95
|
+
const cloud = await listArtifacts('snapshots', accs, cfg.storage.remoteRoot);
|
|
96
|
+
console.log(`\nCloud snapshots:`);
|
|
97
|
+
if (cloud.length === 0) console.log(` ${pc.dim('none')}`);
|
|
98
|
+
for (const c of cloud) {
|
|
99
|
+
console.log(` - ${pc.cyan(c.id)} ${bytesHuman(c.totalSize)} ${pc.dim(c.account)}`);
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
console.log(`\n${pc.yellow('No accounts configured — run `parrot-blackbox account add` or setup.')}`);
|
|
103
|
+
}
|
|
104
|
+
console.log();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function listFiles() {
|
|
108
|
+
const cfg = loadConfig();
|
|
109
|
+
const accs = listAccounts();
|
|
110
|
+
if (!accs.length) {
|
|
111
|
+
console.log(pc.yellow('No accounts configured yet.'));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const artifacts = await listArtifacts('files', accs, cfg.storage.remoteRoot);
|
|
115
|
+
console.log(`\n${pc.bold('Cloud file backups:')}`);
|
|
116
|
+
if (artifacts.length === 0) console.log(` ${pc.dim('none yet — run `parrot-blackbox force`')}`);
|
|
117
|
+
for (const a of artifacts) {
|
|
118
|
+
console.log(` - ${pc.cyan(a.id)} ${bytesHuman(a.totalSize)} ${pc.dim(a.account)}`);
|
|
119
|
+
}
|
|
120
|
+
console.log();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function accountAddFlow(args) {
|
|
124
|
+
const provider = args[0] || (await p.select({
|
|
125
|
+
message: 'Provider?',
|
|
126
|
+
options: [
|
|
127
|
+
{ value: 'mega', label: 'MEGA' },
|
|
128
|
+
{ value: 'gdrive', label: 'Google Drive' },
|
|
129
|
+
],
|
|
130
|
+
}));
|
|
131
|
+
if (p.isCancel(provider)) return;
|
|
132
|
+
const remote = args[1] || (await p.text({ message: 'rclone remote name (create it first with `rclone config`):' }));
|
|
133
|
+
if (!remote) return;
|
|
134
|
+
const res = await addAccount({ provider, remote });
|
|
135
|
+
if (res.ok) console.log(pc.green(`✔ Added ${res.account.label} (${res.account.provider}).`));
|
|
136
|
+
else p.log.warn(res.error);
|
|
137
|
+
}
|
|
138
|
+
async function restoreFlow(rest) {
|
|
139
|
+
const cfg = loadConfig();
|
|
140
|
+
const accs = listAccounts();
|
|
141
|
+
if (accs.length === 0) {
|
|
142
|
+
p.log.warn('No accounts configured — cannot reach the cloud backups. Run `parrot-blackbox account add` first.');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const kind = rest[0] || (await p.select({
|
|
146
|
+
message: 'Restore what?',
|
|
147
|
+
options: [
|
|
148
|
+
{ value: 'snapshot', label: 'System snapshot (Timeshift) — overwrites the whole system', hint: '[sudo]' },
|
|
149
|
+
{ value: 'files', label: 'File backup — recover fonts/images/docs into a folder' },
|
|
150
|
+
],
|
|
151
|
+
}));
|
|
152
|
+
if (p.isCancel(kind)) return;
|
|
153
|
+
|
|
154
|
+
if (kind === 'files') {
|
|
155
|
+
const artifacts = await listArtifacts('files', accs, cfg.storage.remoteRoot);
|
|
156
|
+
if (artifacts.length === 0) { p.log.warn('No file backups found.'); return; }
|
|
157
|
+
const id = rest[1] || (await p.select({
|
|
158
|
+
message: 'Pick a backup generation:',
|
|
159
|
+
options: artifacts.map((a) => ({ value: a.id, label: `${a.id} (${bytesHuman(a.totalSize)})` })),
|
|
160
|
+
}));
|
|
161
|
+
if (p.isCancel(id)) return;
|
|
162
|
+
const toDir = rest[2] || (await p.text({
|
|
163
|
+
message: 'Restore to which directory?',
|
|
164
|
+
initialValue: `./restored-${id}`,
|
|
165
|
+
}));
|
|
166
|
+
const s = p.spinner();
|
|
167
|
+
s.start('Restoring…');
|
|
168
|
+
try {
|
|
169
|
+
const res = await restoreFiles({ id, toDir, accounts: accs, cfg });
|
|
170
|
+
s.stop(`✔ Restored ${res.files} file(s), ${bytesHuman(res.bytes)} into ${toDir}`);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
s.stop('✖ Restore failed.');
|
|
173
|
+
p.log.warn(e.message);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// System snapshot restore — Level 5.
|
|
179
|
+
if (!rest[1]) {
|
|
180
|
+
// Interactive picker requires a terminal.
|
|
181
|
+
const cloud0 = await listArtifacts('snapshots', accs, cfg.storage.remoteRoot);
|
|
182
|
+
if (cloud0.length === 0) { p.log.warn('No cloud snapshots found.'); return; }
|
|
183
|
+
const pick = await p.select({
|
|
184
|
+
message: 'Pick a snapshot to restore:',
|
|
185
|
+
options: cloud0.map((c) => ({ value: c.id, label: `${c.id} (${bytesHuman(c.totalSize)})` })),
|
|
186
|
+
});
|
|
187
|
+
if (p.isCancel(pick)) return;
|
|
188
|
+
rest[1] = pick;
|
|
189
|
+
}
|
|
190
|
+
const id = rest[1];
|
|
191
|
+
if (!rest.includes('--yes') && !process.stdin.isTTY) {
|
|
192
|
+
console.log(pc.yellow('Restore aborted — snapshot restore overwrites the whole system. Pass --yes to confirm in non-interactive mode.'));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const confirm = rest.includes('--yes') || (await p.confirm({
|
|
196
|
+
message: pc.red(`This OVERWRITES the entire running system with snapshot ${id}. Continue?`),
|
|
197
|
+
initialValue: false,
|
|
198
|
+
}));
|
|
199
|
+
if (p.isCancel(confirm)) { p.cancel('Aborted.'); return; }
|
|
200
|
+
if (!confirm) { p.log.message(pc.dim('Restore aborted — nothing was touched.')); return; }
|
|
201
|
+
|
|
202
|
+
const s = p.spinner();
|
|
203
|
+
s.start('Preparing restore…');
|
|
204
|
+
s.stop('');
|
|
205
|
+
try {
|
|
206
|
+
await restoreSnapshot({ id, accounts: accs, cfg, confirm: true, privileged: 'interactive' });
|
|
207
|
+
} catch (e) {
|
|
208
|
+
p.log.warn(e.message);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function accountCommands(rest) {
|
|
213
|
+
const [sub, ...args] = rest;
|
|
214
|
+
switch (sub) {
|
|
215
|
+
case 'list':
|
|
216
|
+
case 'ls': {
|
|
217
|
+
const cfg = loadConfig();
|
|
218
|
+
const accs = listAccounts();
|
|
219
|
+
if (!accs.length) { console.log(pc.yellow('No accounts yet.')); return; }
|
|
220
|
+
const pool = poolSummary(await refreshAccounts(cfg));
|
|
221
|
+
console.log(`\n${pc.bold('Storage pool:')} ${pool.text}\n`);
|
|
222
|
+
for (const a of await refreshAccounts(cfg)) {
|
|
223
|
+
console.log(` - ${pc.bold(a.label)} ${a.provider} remote=${a.remote} ${bytesHuman(a.free)} free / ${bytesHuman(a.total)}`);
|
|
224
|
+
}
|
|
225
|
+
console.log();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
case 'add':
|
|
229
|
+
await accountAddFlow(args);
|
|
230
|
+
return;
|
|
231
|
+
case 'remove':
|
|
232
|
+
case 'rm': {
|
|
233
|
+
if (!args[0]) { console.log(pc.yellow('Usage: parrot-blackbox account remove <id-or-remote>')); return; }
|
|
234
|
+
console.log(removeAccount(args[0]) ? pc.green(`✔ Removed account ${args[0]}.`) : pc.yellow(`No account matched ${args[0]}.`));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
case 'quota': {
|
|
238
|
+
const [id, giB] = args;
|
|
239
|
+
if (!id || !giB) { console.log(pc.yellow('Usage: parrot-blackbox account quota <id-or-remote> <GiB>')); return; }
|
|
240
|
+
const cfg = loadConfig();
|
|
241
|
+
const acc = (cfg.storage.accounts || []).find((a) => a.id === id || a.remote === id);
|
|
242
|
+
if (!acc) { console.log(pc.yellow(`No account matched ${id}.`)); return; }
|
|
243
|
+
acc.quotaGiB = Number(giB);
|
|
244
|
+
writeConfigQuiet(cfg);
|
|
245
|
+
console.log(pc.green(`✔ ${acc.label} quota set to ${giB} GiB.`));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
default:
|
|
249
|
+
console.log(pc.yellow('account subcommands: add | list | remove <id> | quota <id> <GiB>'));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function writeConfigQuiet(cfg) {
|
|
254
|
+
saveConfig(cfg);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const main = defineCommand({
|
|
258
|
+
meta: {
|
|
259
|
+
name: 'parrot-blackbox',
|
|
260
|
+
version: pkg.version,
|
|
261
|
+
description: 'crash-proof multi-cloud backup & recovery automation for Parrot OS',
|
|
262
|
+
},
|
|
263
|
+
async run({ args }) {
|
|
264
|
+
const [cmd, ...rest] = args._;
|
|
265
|
+
|
|
266
|
+
if (process.platform !== 'linux') {
|
|
267
|
+
console.error(pc.red('parrot-blackbox is Linux-only: it orchestrates Timeshift snapshots, which only exist on Linux.'));
|
|
268
|
+
process.exitCode = 1;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (args.V) {
|
|
272
|
+
console.log(`parrot-blackbox v${pkg.version}`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!cmd) return runSetup();
|
|
277
|
+
|
|
278
|
+
switch (cmd) {
|
|
279
|
+
case 'setup':
|
|
280
|
+
case 'wizard':
|
|
281
|
+
case 'install':
|
|
282
|
+
return runSetup();
|
|
283
|
+
|
|
284
|
+
case 'run':
|
|
285
|
+
process.exitCode = await invokeRun('noninteractive');
|
|
286
|
+
return;
|
|
287
|
+
|
|
288
|
+
case 'force':
|
|
289
|
+
case 'backup': {
|
|
290
|
+
// Runs every ENABLED job right now (default = the weekly snapshot).
|
|
291
|
+
const res = await runDueJobs({ force: true, privileged: 'interactive' });
|
|
292
|
+
const report = res.report || [];
|
|
293
|
+
if (report.length === 0) console.log(pc.dim('No enabled backup jobs — run `parrot-blackbox` to set up the schedule.'));
|
|
294
|
+
for (const r of report) {
|
|
295
|
+
if (r.ok) {
|
|
296
|
+
const sizeLabel = r.size ? ` (${bytesHuman(r.size)})` : '';
|
|
297
|
+
const label = r.snapshot ? `Snapshot ${r.snapshot}` : `File backup ${r.due}`;
|
|
298
|
+
console.log(`${pc.green('✔')} ${label} created & uploaded${sizeLabel}.`);
|
|
299
|
+
} else if (r.deferred) {
|
|
300
|
+
console.log(`${pc.yellow('⏸')} Snapshot deferred (sudo needed) — run \`parrot-blackbox snapshot now\` once to authenticate.`);
|
|
301
|
+
} else {
|
|
302
|
+
console.log(`${pc.red('✖')} Backup failed: ${r.error}`);
|
|
303
|
+
process.exitCode = 1;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
case 'snapshot': {
|
|
310
|
+
const [sub, ...args] = rest;
|
|
311
|
+
if (sub === 'now' || sub === 'create' || sub === 'force') {
|
|
312
|
+
try {
|
|
313
|
+
const r = await runSnapshotNow();
|
|
314
|
+
console.log(`${pc.green('✔')} Snapshot ${r.snapshot} created & uploaded (${bytesHuman(r.manifest.totalSize)}).`);
|
|
315
|
+
if (r.pruned?.length) console.log(pc.dim(`Pruned: ${r.pruned.join(', ')}`));
|
|
316
|
+
} catch (e) {
|
|
317
|
+
console.error(pc.red(`✖ ${e.message}`));
|
|
318
|
+
process.exitCode = 1;
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (sub === 'list' || sub === 'ls') return snapshotList();
|
|
323
|
+
if (sub === 'prune') {
|
|
324
|
+
const cfg = loadConfig();
|
|
325
|
+
try {
|
|
326
|
+
const pruned = await pruneSnapshots(cfg, loadState(), listAccounts(), { privileged: 'interactive' });
|
|
327
|
+
if (pruned.length) console.log(pc.green(`✔ Pruned: ${pruned.join(', ')} (local + cloud).`));
|
|
328
|
+
else console.log(pc.dim('Nothing to prune.'));
|
|
329
|
+
} catch (e) {
|
|
330
|
+
console.error(pc.red(`✖ ${e.message}`));
|
|
331
|
+
process.exitCode = 1;
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
console.log(pc.yellow('snapshot subcommands: now | list | prune'));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
case 'list':
|
|
340
|
+
case 'ls':
|
|
341
|
+
case 'files':
|
|
342
|
+
return listFiles();
|
|
343
|
+
|
|
344
|
+
case 'restore':
|
|
345
|
+
case 'recover':
|
|
346
|
+
return restoreFlow(rest);
|
|
347
|
+
|
|
348
|
+
case 'account':
|
|
349
|
+
case 'accounts':
|
|
350
|
+
return accountCommands(rest);
|
|
351
|
+
|
|
352
|
+
case 'daemon': {
|
|
353
|
+
const [sub] = rest;
|
|
354
|
+
if (sub === 'start') {
|
|
355
|
+
const res = await startDaemon();
|
|
356
|
+
console.log(res.started ? pc.green(`✔ Daemon started (pid ${res.pid}).`) : pc.yellow(`Daemon ${res.reason || 'already running'}.`));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (sub === 'stop') {
|
|
360
|
+
const res = await stopDaemon();
|
|
361
|
+
console.log(res.stopped ? pc.green(`✔ Daemon stopped.`) : pc.yellow(`Daemon ${res.reason || 'not running'}.`));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (sub === 'status') {
|
|
365
|
+
console.log(`Daemon: ${daemonRunning() ? pc.green('running') : pc.yellow('not running')}`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (sub === 'foreground') {
|
|
369
|
+
const { daemonForeground } = await import('./daemon/daemon.js');
|
|
370
|
+
await daemonForeground();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
console.log(pc.yellow('daemon subcommands: start | stop | status | foreground'));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case 'schedule': {
|
|
378
|
+
const [sub] = rest;
|
|
379
|
+
if (sub === 'install' || sub === 'on') {
|
|
380
|
+
const backend = await installService();
|
|
381
|
+
console.log(pc.green(`✔ Always-on schedule installed (${backend}).`));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (sub === 'remove' || sub === 'off') {
|
|
385
|
+
await removeService();
|
|
386
|
+
console.log(pc.green('✔ Schedule removed.'));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
console.log(pc.yellow('schedule subcommands: install | remove'));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
case 'doctor':
|
|
394
|
+
return runDoctor().then(() => undefined);
|
|
395
|
+
|
|
396
|
+
case 'status':
|
|
397
|
+
return runStatus();
|
|
398
|
+
|
|
399
|
+
case 'uninstall':
|
|
400
|
+
return runUninstallWizard();
|
|
401
|
+
|
|
402
|
+
case 'config':
|
|
403
|
+
console.log(`config: ${configFile()}`);
|
|
404
|
+
console.log(`state : ${stateDir()}`);
|
|
405
|
+
return;
|
|
406
|
+
|
|
407
|
+
case 'version':
|
|
408
|
+
case '-v':
|
|
409
|
+
case '-V':
|
|
410
|
+
case '--version':
|
|
411
|
+
console.log(`parrot-blackbox v${pkg.version}`);
|
|
412
|
+
return;
|
|
413
|
+
|
|
414
|
+
case 'help':
|
|
415
|
+
case '-h':
|
|
416
|
+
case '--help':
|
|
417
|
+
default:
|
|
418
|
+
if (['help', '-h', '--help'].includes(cmd)) { printUsage(); return; }
|
|
419
|
+
console.error(pc.red(`Unknown command: ${cmd}`));
|
|
420
|
+
printUsage();
|
|
421
|
+
process.exitCode = 1;
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
runMain(main);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostics & lifecycle: `doctor`, `status`, `uninstall`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import * as p from '@clack/prompts';
|
|
7
|
+
import pc from 'picocolors';
|
|
8
|
+
import { execa } from 'execa';
|
|
9
|
+
import { loadConfig, loadState, journal, hasCommandSync, lastJournal } from '../core/store.js';
|
|
10
|
+
import { configFile, stateDir } from '../core/paths.js';
|
|
11
|
+
import { bytesHuman } from '../util/misc.js';
|
|
12
|
+
import { listAccounts, refreshAccounts, poolSummary } from '../storage/accounts.js';
|
|
13
|
+
import { listArtifacts } from '../storage/archive.js';
|
|
14
|
+
import { rcloneVersion } from '../storage/rclone.js';
|
|
15
|
+
import { listLocalSnapshots, timeshiftAvailable } from '../backup/snapshot.js';
|
|
16
|
+
import { daemonRunning } from '../daemon/daemon.js';
|
|
17
|
+
import { serviceBackend } from './service.js';
|
|
18
|
+
import { isOnline } from '../util/network.js';
|
|
19
|
+
|
|
20
|
+
export async function runDoctor() {
|
|
21
|
+
const cfg = loadConfig();
|
|
22
|
+
const state = loadState();
|
|
23
|
+
|
|
24
|
+
p.intro(pc.bold('🩺 parrot-blackbox doctor'));
|
|
25
|
+
console.log(`\n Tooling:`);
|
|
26
|
+
const tools = [
|
|
27
|
+
['node', () => process.version],
|
|
28
|
+
['rclone', rcloneVersion],
|
|
29
|
+
['timeshift', () => (timeshiftAvailable() ? 'present' : 'MISSING')],
|
|
30
|
+
['git', () => (hasCommandSync('git') ? 'present' : 'MISSING')],
|
|
31
|
+
['systemd', () => (serviceBackend() === 'systemd' ? 'available' : serviceBackend())],
|
|
32
|
+
];
|
|
33
|
+
for (const [name, fn] of tools) {
|
|
34
|
+
let v;
|
|
35
|
+
try { v = await fn(); } catch { v = 'error'; }
|
|
36
|
+
console.log(` - ${pc.cyan(name.padEnd(10))} ${v}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(`\n Network : ${(await isOnline()) ? pc.green('online') : pc.yellow('offline')}`);
|
|
40
|
+
console.log(` Daemon : ${daemonRunning() ? pc.green('running') : pc.yellow('stopped')}`);
|
|
41
|
+
console.log(` Config : ${configFile()}`);
|
|
42
|
+
console.log(` State : ${stateDir()}`);
|
|
43
|
+
|
|
44
|
+
console.log(`\n Storage accounts:`);
|
|
45
|
+
const accs = listAccounts();
|
|
46
|
+
if (accs.length === 0) {
|
|
47
|
+
console.log(` ${pc.yellow('none — run `parrot-blackbox setup`')}`);
|
|
48
|
+
} else {
|
|
49
|
+
const refreshed = await refreshAccounts(cfg);
|
|
50
|
+
for (const a of refreshed) {
|
|
51
|
+
console.log(` - ${pc.bold(a.label)} (${a.provider}, ${a.remote}) ${bytesHuman(a.free)} free / ${bytesHuman(a.total)}`);
|
|
52
|
+
}
|
|
53
|
+
console.log(` ${pc.dim(poolSummary(refreshed).text)}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log(`\n Recent journal:`);
|
|
57
|
+
for (const line of lastJournal(8)) console.log(` ${pc.dim(line)}`);
|
|
58
|
+
|
|
59
|
+
console.log(`\n Schedule state:`);
|
|
60
|
+
for (const type of ['files', 'snapshots']) {
|
|
61
|
+
const j = state.jobs[type];
|
|
62
|
+
const icfg = cfg.jobs[type];
|
|
63
|
+
console.log(
|
|
64
|
+
` - ${pc.bold(type)} ${icfg?.schedule?.kind} ${icfg?.enabled ? '' : pc.yellow('(disabled)')}` +
|
|
65
|
+
` last=${j.lastCompletedDue || '—'} pending=${(j.pending || []).join(',') || '—'} status=${j.lastStatus || '—'}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function runStatus() {
|
|
71
|
+
const cfg = loadConfig();
|
|
72
|
+
const state = loadState();
|
|
73
|
+
const accs = listAccounts();
|
|
74
|
+
|
|
75
|
+
console.log(pc.bold(`\n🧠 parrot-blackbox status\n`));
|
|
76
|
+
if (accs.length) {
|
|
77
|
+
const pool = poolSummary(await refreshAccounts(cfg));
|
|
78
|
+
console.log(` Pool : ${pool.text}`);
|
|
79
|
+
} else {
|
|
80
|
+
console.log(` Pool : ${pc.yellow('no accounts — run `parrot-blackbox setup`')}`);
|
|
81
|
+
}
|
|
82
|
+
console.log(` Network : ${(await isOnline()) ? pc.green('online') : pc.yellow('offline — backups will defer & catch up later')}`);
|
|
83
|
+
console.log(` Daemon : ${daemonRunning() ? pc.green('running') : pc.yellow('stopped')}`);
|
|
84
|
+
|
|
85
|
+
for (const type of ['files', 'snapshots']) {
|
|
86
|
+
const j = state.jobs[type] || {};
|
|
87
|
+
const icfg = cfg.jobs[type] || {};
|
|
88
|
+
console.log(`\n ${pc.bold(type)} (${icfg.schedule?.kind ?? '?'}, keep ${icfg.keep})`);
|
|
89
|
+
console.log(` Last done : ${j.lastCompletedDue || pc.dim('never')}`);
|
|
90
|
+
console.log(` Pending : ${(j.pending || []).join(', ') || pc.dim('none')}`);
|
|
91
|
+
if (j.lastError) console.log(` Last error: ${pc.red(j.lastError.slice(0, 160))}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const clouds = await listArtifacts('snapshots', accs, cfg.storage.remoteRoot).catch(() => []);
|
|
95
|
+
if (clouds.length) {
|
|
96
|
+
console.log(`\n Cloud snapshots: ${clouds.map((c) => `${c.id} (${bytesHuman(c.totalSize)})`).join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
console.log();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function runUninstallWizard() {
|
|
102
|
+
p.intro(pc.bgRed(pc.black(' parrot-blackbox uninstaller ')));
|
|
103
|
+
|
|
104
|
+
const confirm = await p.confirm({
|
|
105
|
+
message:
|
|
106
|
+
'Uninstall parrot-blackbox completely? This stops the daemon, removes the systemd/cron schedule, deletes the local config & journal (~/.config/parrot-blackbox, ~/.local/state/parrot-blackbox) and the npm package. CLOUD BACKUPS ARE NOT DELETED.',
|
|
107
|
+
initialValue: false,
|
|
108
|
+
});
|
|
109
|
+
if (p.isCancel(confirm)) { p.cancel('Aborted.'); process.exit(0); }
|
|
110
|
+
if (!confirm) { p.outro('Nothing was removed.'); return; }
|
|
111
|
+
|
|
112
|
+
// Stop daemon & remove service
|
|
113
|
+
try { await execa('bash', ['-c', `"${process.execPath}" "${process.argv[1]}" daemon stop`], { reject: false }); } catch { /* best effort */ }
|
|
114
|
+
try { await execa('bash', ['-c', `"${process.execPath}" "${process.argv[1]}" schedule remove`], { reject: false }); } catch { /* best effort */ }
|
|
115
|
+
|
|
116
|
+
const removed = [];
|
|
117
|
+
for (const dir of [configFile(), stateDir()]) {
|
|
118
|
+
if (fs.existsSync(dir)) {
|
|
119
|
+
if (fs.statSync(dir).isDirectory() && dir.includes('parrot-blackbox')) fs.rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
else fs.rmSync(dir, { force: true });
|
|
121
|
+
removed.push(dir);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (removed.length) p.log.success(`Removed local data: ${removed.join(', ')}`);
|
|
125
|
+
else p.log.message(pc.dim('No local parrot-blackbox data found.'));
|
|
126
|
+
|
|
127
|
+
p.outro(pc.green('Uninstalled. Cloud backups remain safe in your accounts.'));
|
|
128
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Always-on integration. Preferred: a systemd USER unit that keeps the daemon
|
|
3
|
+
* running (Restart=always, starts on login). Fallback for systems without
|
|
4
|
+
* systemd: a cron line that runs `parrot-blackbox run` every 15 minutes (the
|
|
5
|
+
* run itself defers when offline and catches up later, so it never hangs).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { execa } from 'execa';
|
|
11
|
+
import { serviceFile, daemonLogFile } from '../core/paths.js';
|
|
12
|
+
import { loadConfig, journal, hasCommandSync } from '../core/store.js';
|
|
13
|
+
|
|
14
|
+
function findCliBin() {
|
|
15
|
+
// npm-installed global binary (preferred) or our own bin entry.
|
|
16
|
+
const candidates = [
|
|
17
|
+
process.env.PBB_BIN,
|
|
18
|
+
path.join(path.dirname(process.argv[1] || ''), 'parrot-blackbox.js'),
|
|
19
|
+
];
|
|
20
|
+
return candidates.find((c) => c && fs.existsSync(c)) || 'parrot-blackbox';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function serviceBackend() {
|
|
24
|
+
if (process.env.PBB_SERVICE_BACKEND) return process.env.PBB_SERVICE_BACKEND;
|
|
25
|
+
if (hasCommandSync('systemctl')) return 'systemd';
|
|
26
|
+
if (hasCommandSync('crontab')) return 'cron';
|
|
27
|
+
return 'none';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Install the systemd user unit (or cron fallback). Returns detected backend. */
|
|
31
|
+
export async function installService() {
|
|
32
|
+
const backend = serviceBackend();
|
|
33
|
+
if (backend === 'systemd') {
|
|
34
|
+
const bin = findCliBin();
|
|
35
|
+
const unit = [
|
|
36
|
+
'[Unit]',
|
|
37
|
+
'Description=parrot-blackbox — crash-proof backup daemon',
|
|
38
|
+
'After=network-online.target',
|
|
39
|
+
'Wants=network-online.target',
|
|
40
|
+
'',
|
|
41
|
+
'[Service]',
|
|
42
|
+
'Type=simple',
|
|
43
|
+
`ExecStart=${process.execPath} ${bin} daemon foreground`,
|
|
44
|
+
'Restart=always',
|
|
45
|
+
'RestartSec=60',
|
|
46
|
+
`StandardOutput=append:${daemonLogFile()}`,
|
|
47
|
+
`StandardError=append:${daemonLogFile()}`,
|
|
48
|
+
'',
|
|
49
|
+
'[Install]',
|
|
50
|
+
'WantedBy=default.target',
|
|
51
|
+
'',
|
|
52
|
+
].join('\n');
|
|
53
|
+
fs.mkdirSync(path.dirname(serviceFile()), { recursive: true });
|
|
54
|
+
fs.writeFileSync(serviceFile(), unit);
|
|
55
|
+
journal('service', `systemd unit written: ${serviceFile()}`);
|
|
56
|
+
for (const cmd of [
|
|
57
|
+
['systemctl', '--user', 'daemon-reload'],
|
|
58
|
+
['systemctl', '--user', 'enable', '--now', 'parrot-blackbox.service'],
|
|
59
|
+
]) {
|
|
60
|
+
const res = await execa(cmd[0], cmd.slice(1), { reject: false });
|
|
61
|
+
if (res.exitCode !== 0) {
|
|
62
|
+
journal('service', `systemctl ${cmd.slice(2).join(' ')} failed: ${res.stderr?.trim()}`, 'warn');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return 'systemd';
|
|
66
|
+
}
|
|
67
|
+
if (backend === 'cron') {
|
|
68
|
+
await installCron();
|
|
69
|
+
return 'cron';
|
|
70
|
+
}
|
|
71
|
+
return 'none';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function cronLinesFor() {
|
|
75
|
+
const bin = findCliBin();
|
|
76
|
+
const marker = '# parrot-blackbox automatic backup schedule';
|
|
77
|
+
const line = `*/15 * * * * "${process.execPath}" ${bin} run >> ${daemonLogFile()} 2>&1`;
|
|
78
|
+
return { marker, line };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function installCron() {
|
|
82
|
+
const { marker, line } = cronLinesFor();
|
|
83
|
+
const existing = await execa('crontab', ['-l'], { reject: false });
|
|
84
|
+
const body = existing.exitCode === 0 ? existing.stdout : '';
|
|
85
|
+
const lines = body.split('\n').filter((l) => !l.includes(marker));
|
|
86
|
+
lines.push(marker);
|
|
87
|
+
lines.push(line);
|
|
88
|
+
lines.push('');
|
|
89
|
+
const write = await execa('crontab', ['-'], { reject: false, input: lines.join('\n') });
|
|
90
|
+
journal('service', write.exitCode === 0 ? 'cron line installed' : `crontab failed: ${write.stderr?.trim()}`, write.exitCode === 0 ? 'info' : 'warn');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function removeCron() {
|
|
94
|
+
const { marker } = cronLinesFor();
|
|
95
|
+
const existing = await execa('crontab', ['-l'], { reject: false });
|
|
96
|
+
if (existing.exitCode !== 0) return;
|
|
97
|
+
const lines = existing.stdout.split('\n').filter((l) => !l.includes(marker));
|
|
98
|
+
await execa('crontab', ['-'], { reject: false, input: lines.join('\n') + '\n' });
|
|
99
|
+
journal('service', 'cron line removed');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function removeService() {
|
|
103
|
+
const backend = serviceBackend();
|
|
104
|
+
if (backend === 'systemd') {
|
|
105
|
+
for (const cmd of [
|
|
106
|
+
['systemctl', '--user', 'stop', 'parrot-blackbox.service'],
|
|
107
|
+
['systemctl', '--user', 'disable', 'parrot-blackbox.service'],
|
|
108
|
+
]) {
|
|
109
|
+
const res = await execa(cmd[0], cmd.slice(1), { reject: false });
|
|
110
|
+
if (res.exitCode !== 0) journal('service', `${cmd.join(' ')} failed`, 'warn');
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
fs.rmSync(serviceFile(), { force: true });
|
|
114
|
+
} catch { /* best effort */ }
|
|
115
|
+
return 'systemd';
|
|
116
|
+
}
|
|
117
|
+
if (backend === 'cron') {
|
|
118
|
+
await removeCron();
|
|
119
|
+
return 'cron';
|
|
120
|
+
}
|
|
121
|
+
return 'none';
|
|
122
|
+
}
|