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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive setup wizard (default command, like gitswitch/theamify).
|
|
3
|
+
* Walks: dependency check + AUTO-INSTALL → rclone remotes (accounts) →
|
|
4
|
+
* schedule overview → always-on service install → optional first backup.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as p from '@clack/prompts';
|
|
8
|
+
import pc from 'picocolors';
|
|
9
|
+
import { execa } from 'execa';
|
|
10
|
+
import { loadConfig, journal, hasCommandSync } from '../core/store.js';
|
|
11
|
+
import { listRemotes } from '../storage/rclone.js';
|
|
12
|
+
import { addAccount, listAccounts, refreshAccounts, poolSummary } from '../storage/accounts.js';
|
|
13
|
+
import { installService } from './service.js';
|
|
14
|
+
import { runDueJobs } from '../daemon/scheduler.js';
|
|
15
|
+
import { isOnline } from '../util/network.js';
|
|
16
|
+
import { bytesHuman } from '../util/misc.js';
|
|
17
|
+
|
|
18
|
+
const REQUIRED = [
|
|
19
|
+
{ bin: 'rclone', pkg: 'rclone', why: 'talks to MEGA / Google Drive (cloud storage)' },
|
|
20
|
+
{ bin: 'timeshift', pkg: 'timeshift', why: 'system snapshots — create AND restore' },
|
|
21
|
+
{ bin: 'git', pkg: 'git', why: 'skip GitHub-tracked files' },
|
|
22
|
+
{ bin: 'curl', pkg: 'curl', why: 'connectivity checks' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Detect the distro package manager (apt/dnf/yum/pacman/zypper/apk). */
|
|
26
|
+
function detectPackageManager() {
|
|
27
|
+
const order = ['apt-get', 'dnf', 'yum', 'pacman', 'zypper', 'apk'];
|
|
28
|
+
return order.find((name) => hasCommandSync(name)) || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Auto-install the tools the system needs for snapshot backup & restore.
|
|
33
|
+
* Prompts per missing tool, then runs the package-manager install with an
|
|
34
|
+
* interactive sudo prompt (spinner released first, Ctrl+C safe).
|
|
35
|
+
* @returns {Promise<string[]>} tools that were freshly installed
|
|
36
|
+
*/
|
|
37
|
+
async function ensureSystemTools() {
|
|
38
|
+
const missing = REQUIRED.filter((t) => !hasCommandSync(t.bin));
|
|
39
|
+
if (missing.length === 0) return [];
|
|
40
|
+
|
|
41
|
+
const pm = detectPackageManager();
|
|
42
|
+
if (!pm) {
|
|
43
|
+
p.log.warn('No supported package manager detected (apt/dnf/yum/pacman/zypper/apk).');
|
|
44
|
+
p.log.message(pc.dim(`Install manually, then re-run: sudo apt install ${missing.map((m) => m.pkg).join(' ')}`));
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const installed = [];
|
|
49
|
+
for (const tool of missing) {
|
|
50
|
+
const want = await p.confirm({
|
|
51
|
+
message: `${pc.cyan(tool.bin)} is missing. Install it now? (needed for ${tool.why})`,
|
|
52
|
+
initialValue: true,
|
|
53
|
+
});
|
|
54
|
+
if (p.isCancel(want)) { p.cancel('Aborted.'); process.exit(0); }
|
|
55
|
+
if (!want) {
|
|
56
|
+
p.log.warn(`Skipped ${pc.cyan(tool.bin)} — snapshot backup/restore may not work without it.`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
p.log.step(`Installing ${pc.cyan(tool.bin)}…`);
|
|
60
|
+
const s = p.spinner();
|
|
61
|
+
s.start(`Installing ${tool.bin}…`);
|
|
62
|
+
// Release the spinner FIRST so the sudo password prompt is visible & interruptible.
|
|
63
|
+
s.stop('');
|
|
64
|
+
try {
|
|
65
|
+
const args = pm === 'pacman' ? ['-S', '--noconfirm', tool.pkg] : [pm, 'install', '-y', tool.pkg];
|
|
66
|
+
const res = await execa('sudo', args, { stdio: 'inherit', reject: false });
|
|
67
|
+
if (res.exitCode === 0 && hasCommandSync(tool.bin)) {
|
|
68
|
+
p.log.success(`${pc.cyan(tool.bin)} installed.`);
|
|
69
|
+
installed.push(tool.bin);
|
|
70
|
+
} else {
|
|
71
|
+
p.log.warn(`Could not install ${pc.cyan(tool.bin)} — run: sudo ${args.join(' ')}`);
|
|
72
|
+
}
|
|
73
|
+
} catch (e) {
|
|
74
|
+
p.log.warn(`${pc.cyan(tool.bin)} install failed: ${e.message}`);
|
|
75
|
+
}
|
|
76
|
+
s.stop('');
|
|
77
|
+
}
|
|
78
|
+
return installed;
|
|
79
|
+
}
|
|
80
|
+
export async function runSetup() {
|
|
81
|
+
p.intro(pc.bgYellow(pc.black(' parrot-blackbox setup ')));
|
|
82
|
+
|
|
83
|
+
// 1. Dependencies — check the system and INSTALL anything needed for the
|
|
84
|
+
// snapshot to be created, uploaded and (later) RESTORED.
|
|
85
|
+
p.note('Checking & installing the tools needed for snapshot backup + restore…', 'Step 1/5 — tools');
|
|
86
|
+
const missing = REQUIRED.filter((t) => !hasCommandSync(t.bin));
|
|
87
|
+
if (missing.length === 0) {
|
|
88
|
+
p.log.success(`All tools present: ${REQUIRED.map((r) => r.bin).join(', ')}`);
|
|
89
|
+
} else {
|
|
90
|
+
const installed = await ensureSystemTools();
|
|
91
|
+
const stillMissing = REQUIRED.filter((t) => !hasCommandSync(t.bin));
|
|
92
|
+
if (installed.length) p.log.success(`Installed: ${installed.join(', ')}`);
|
|
93
|
+
if (stillMissing.length) {
|
|
94
|
+
p.log.warn(`Still missing: ${stillMissing.map((m) => m.bin).join(', ')}`);
|
|
95
|
+
} else {
|
|
96
|
+
p.log.success(`All required tools now present: ${REQUIRED.map((r) => r.bin).join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!hasCommandSync('timeshift')) {
|
|
100
|
+
p.log.message(pc.dim('Timeshift missing = snapshot backup & restore are unavailable. Run `parrot-blackbox` again after installing it.'));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2. Accounts
|
|
104
|
+
p.note('Accounts are rclone remotes — one per MEGA or Google Drive login.', 'Step 2/5 — storage pool');
|
|
105
|
+
const existing = listAccounts();
|
|
106
|
+
const needMore = await p.confirm({
|
|
107
|
+
message: existing.length
|
|
108
|
+
? `You already have ${existing.length} account(s). Add or authorize another cloud login?`
|
|
109
|
+
: 'No accounts yet — add a MEGA or Google Drive account now?',
|
|
110
|
+
initialValue: true,
|
|
111
|
+
});
|
|
112
|
+
if (p.isCancel(needMore)) { p.cancel('Aborted.'); process.exit(0); }
|
|
113
|
+
|
|
114
|
+
if (needMore) {
|
|
115
|
+
const want = await p.select({
|
|
116
|
+
message: 'How do you want to add the account?',
|
|
117
|
+
options: [
|
|
118
|
+
{ value: 'wizard', label: 'Run `rclone config` (recommended — handles MEGA + Google OAuth)', hint: 'authenticates in your browser' },
|
|
119
|
+
{ value: 'manual', label: 'I already created the rclone remote myself' },
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
if (p.isCancel(want)) { p.cancel('Aborted.'); process.exit(0); }
|
|
123
|
+
|
|
124
|
+
if (want === 'wizard') {
|
|
125
|
+
p.log.step('Starting rclone config — follow its prompts, then come back.');
|
|
126
|
+
await execa('rclone', ['config'], { stdio: 'inherit' });
|
|
127
|
+
}
|
|
128
|
+
await registerAccountsFlow();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 3. Schedule overview — snapshot ONLY by default (storage-conscious).
|
|
132
|
+
const cfg = loadConfig();
|
|
133
|
+
p.note(
|
|
134
|
+
` Snapshot backup: ${pc.bold('every Saturday at 22:00')} (keep ${cfg.jobs.snapshots.keep}, local + cloud)\n` +
|
|
135
|
+
` File backups : ${cfg.jobs.files.enabled ? pc.bold('daily 22:00 (enabled)') + ` (keep ${cfg.jobs.files.keep})` : pc.dim('disabled by default — opt-in, storage-conscious')}\n` +
|
|
136
|
+
` Missed backups : caught up automatically in order when WiFi returns`,
|
|
137
|
+
'Step 3/5 — schedule',
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// 4. Always-on service
|
|
141
|
+
const install = await p.confirm({
|
|
142
|
+
message: 'Install the always-on background service (systemd / cron fallback)?',
|
|
143
|
+
initialValue: true,
|
|
144
|
+
});
|
|
145
|
+
if (p.isCancel(install)) { p.cancel('Aborted.'); process.exit(0); }
|
|
146
|
+
if (install) {
|
|
147
|
+
const backend = await installService();
|
|
148
|
+
p.log.success(`Always-on service installed via ${pc.cyan(backend)}.`);
|
|
149
|
+
p.log.message(pc.dim('It survives reboots — a missed Saturday 22:00 run fires as soon as the machine is back online.'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 5. First snapshot now?
|
|
153
|
+
const first = await p.confirm({
|
|
154
|
+
message: 'Run the FIRST snapshot backup right now? (recommended before a fresh install)',
|
|
155
|
+
initialValue: true,
|
|
156
|
+
});
|
|
157
|
+
if (p.isCancel(first)) { p.cancel('Aborted.'); process.exit(0); }
|
|
158
|
+
if (first) {
|
|
159
|
+
/** Register discovered rclone remotes as accounts (multi-select). */
|
|
160
|
+
async function registerAccountsFlow() {
|
|
161
|
+
const remotes = await listRemotes();
|
|
162
|
+
if (remotes.length === 0) {
|
|
163
|
+
p.log.warn('No rclone remotes found yet. Create one with `rclone config` or re-run setup.');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const toAdd = await p.multiselect({
|
|
167
|
+
message: 'Select which rclone remotes to add to the backup pool (one per account):',
|
|
168
|
+
options: remotes.map((r) => ({ value: r, label: r })),
|
|
169
|
+
required: false,
|
|
170
|
+
});
|
|
171
|
+
if (p.isCancel(toAdd) || toAdd.length === 0) {
|
|
172
|
+
p.log.message(pc.dim('No accounts added.'));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const remote of toAdd) {
|
|
176
|
+
const provider = await p.select({
|
|
177
|
+
message: `Provider for "${remote}"?`,
|
|
178
|
+
options: [
|
|
179
|
+
{ value: 'mega', label: 'MEGA (20 GB free tier)' },
|
|
180
|
+
{ value: 'gdrive', label: 'Google Drive (10 GB free tier)' },
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
if (p.isCancel(provider)) continue;
|
|
184
|
+
const res = await addAccount({ provider, remote });
|
|
185
|
+
if (res.ok) p.log.success(`Added ${pc.cyan(remote)} (${provider}).`);
|
|
186
|
+
else p.log.warn(res.error);
|
|
187
|
+
}
|
|
188
|
+
const accounts = await refreshAccounts();
|
|
189
|
+
p.log.success(poolSummary(accounts).text);
|
|
190
|
+
journal('setup', `accounts registered: ${accounts.map((a) => a.remote).join(',')}`);
|
|
191
|
+
}
|
|
192
|
+
if (!(await isOnline())) {
|
|
193
|
+
p.log.warn('Offline right now — the backup stays pending and will run automatically when online.');
|
|
194
|
+
} else {
|
|
195
|
+
const s = p.spinner();
|
|
196
|
+
s.start('Creating snapshot…');
|
|
197
|
+
s.stop('');
|
|
198
|
+
try {
|
|
199
|
+
const res = await runDueJobs({ force: true, privileged: 'interactive' });
|
|
200
|
+
for (const r of res.report) {
|
|
201
|
+
if (r.ok) p.log.success(r.snapshot
|
|
202
|
+
? `Snapshot ${r.snapshot} created & uploaded${r.size ? ` (${pc.cyan(bytesHuman(r.size))})` : ''}.`
|
|
203
|
+
: `Backup ${r.due} stored.`);
|
|
204
|
+
else if (r.deferred) p.log.warn(`Snapshot deferred (sudo needed) — run \`parrot-blackbox snapshot now\`.`);
|
|
205
|
+
else p.log.warn(`Backup failed: ${r.error}`);
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
p.log.warn(`Backup failed: ${e.message}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
p.outro(pc.green('Setup complete. Run `parrot-blackbox status` to see everything, or `parrot-blackbox help` for all commands.'));
|
|
214
|
+
}
|
package/src/core/lock.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler lock — prevents a manual `force` from colliding with the daemon.
|
|
3
|
+
* Locks are reclaimed automatically when their PID is dead or the TTL passed.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { lockFile, ensureStateDirs } from './paths.js';
|
|
8
|
+
import { readJsonSafe } from './store.js';
|
|
9
|
+
|
|
10
|
+
export class LockError extends Error {}
|
|
11
|
+
|
|
12
|
+
export function releaseLock() {
|
|
13
|
+
try {
|
|
14
|
+
fs.rmSync(lockFile(), { force: true });
|
|
15
|
+
} catch {
|
|
16
|
+
/* best effort */
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pidAlive(pid) {
|
|
21
|
+
if (!pid || pid <= 0) return false;
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Acquire the lock. Throws LockError when another live run holds it. */
|
|
31
|
+
export function acquireLock({ ttlMs = Number(process.env.PBB_LOCK_TTL_MS || 6 * 3_600_000) } = {}) {
|
|
32
|
+
ensureStateDirs();
|
|
33
|
+
let attempts = 0;
|
|
34
|
+
while (attempts < 2) {
|
|
35
|
+
attempts += 1;
|
|
36
|
+
try {
|
|
37
|
+
const fd = fs.openSync(lockFile(), 'wx');
|
|
38
|
+
const record = { pid: process.pid, at: Date.now(), host: process.env.HOSTNAME || 'localhost' };
|
|
39
|
+
fs.writeFileSync(fd, JSON.stringify(record));
|
|
40
|
+
fs.closeSync(fd);
|
|
41
|
+
return record;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code !== 'EEXIST') throw new LockError(`cannot create lock: ${err.message}`);
|
|
44
|
+
const existing = readJsonSafe(lockFile(), {});
|
|
45
|
+
const stale =
|
|
46
|
+
!existing.pid || !pidAlive(existing.pid) || Number(existing.at) + ttlMs < Date.now();
|
|
47
|
+
if (stale) {
|
|
48
|
+
fs.rmSync(lockFile(), { force: true });
|
|
49
|
+
continue; // one reclaim attempt
|
|
50
|
+
}
|
|
51
|
+
throw new LockError(
|
|
52
|
+
`another run is already in progress (pid ${existing.pid} since ${new Date(existing.at).toISOString()})`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw new LockError('could not acquire lock');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Run `fn` inside the lock; always releases afterwards. */
|
|
60
|
+
export async function withLock(fn, opts) {
|
|
61
|
+
const lock = acquireLock(opts);
|
|
62
|
+
try {
|
|
63
|
+
return await fn(lock);
|
|
64
|
+
} finally {
|
|
65
|
+
releaseLock();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parrot-blackbox — paths & environment.
|
|
3
|
+
*
|
|
4
|
+
* Every mutable location is overridable so the whole tool can run inside a
|
|
5
|
+
* sandbox (fake HOME / fake cloud / fake timeshift) without ever touching the
|
|
6
|
+
* real filesystem or cloud accounts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
|
|
13
|
+
export const PKG_NAME = 'parrot-blackbox';
|
|
14
|
+
|
|
15
|
+
export function sandboxMode() {
|
|
16
|
+
return process.env.PBB_SANDBOX === '1';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Root of all runtime state (journal, state, staging, manifests, logs). */
|
|
20
|
+
export function stateDir() {
|
|
21
|
+
return (
|
|
22
|
+
process.env.PBB_STATE_DIR ||
|
|
23
|
+
path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), PKG_NAME)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** User-editable config file. */
|
|
28
|
+
export function configFile() {
|
|
29
|
+
return (
|
|
30
|
+
process.env.PBB_CONFIG_FILE ||
|
|
31
|
+
path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), PKG_NAME, 'config.json')
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function journalFile() {
|
|
36
|
+
return path.join(stateDir(), 'journal.log');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function lockFile() {
|
|
40
|
+
return path.join(stateDir(), 'lock.json');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function stateFile() {
|
|
44
|
+
return path.join(stateDir(), 'state.json');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function stagingDir() {
|
|
48
|
+
return path.join(stateDir(), 'staging');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function chunkDir() {
|
|
52
|
+
return path.join(stateDir(), 'chunks');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function manifestsDir() {
|
|
56
|
+
return path.join(stateDir(), 'manifests');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function daemonPidFile() {
|
|
60
|
+
return path.join(stateDir(), 'daemon.pid');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function daemonLogFile() {
|
|
64
|
+
return path.join(stateDir(), 'daemon.log');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function serviceFile() {
|
|
68
|
+
return path.join(
|
|
69
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
|
|
70
|
+
'systemd',
|
|
71
|
+
'user',
|
|
72
|
+
`${PKG_NAME}.service`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Timeshift snapshot directory (used by the real tool and by sandbox stubs). */
|
|
77
|
+
export function timeshiftDir() {
|
|
78
|
+
return process.env.PBB_TIMESHIFT_DIR || '/timeshift';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function ensureStateDirs() {
|
|
82
|
+
for (const dir of [
|
|
83
|
+
stateDir(),
|
|
84
|
+
configFileDir(configFile()),
|
|
85
|
+
stagingDir(),
|
|
86
|
+
chunkDir(),
|
|
87
|
+
manifestsDir(),
|
|
88
|
+
]) {
|
|
89
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function configFileDir(file) {
|
|
94
|
+
return path.dirname(file);
|
|
95
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-proof persistence: config, state and append-only journal.
|
|
3
|
+
*
|
|
4
|
+
* State writes are atomic (tmp + rename) so a crash mid-write can never leave
|
|
5
|
+
* a truncated file. Every job opens a journal entry on start and only closes
|
|
6
|
+
* it on success, so interrupted runs are always discoverable and retried.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { execaSync } from 'execa';
|
|
12
|
+
import { configFile, ensureStateDirs, journalFile, stateFile } from './paths.js';
|
|
13
|
+
import { iso, clock } from './time.js';
|
|
14
|
+
import deepMerge from '../util/misc.js';
|
|
15
|
+
|
|
16
|
+
const GiB = 1024 ** 3;
|
|
17
|
+
|
|
18
|
+
/** Factory defaults — safe, conservative, self-documenting. */
|
|
19
|
+
export function defaultConfig() {
|
|
20
|
+
return {
|
|
21
|
+
version: 1,
|
|
22
|
+
jobs: {
|
|
23
|
+
// The weekly snapshot is THE default backup. Daily file backups are an
|
|
24
|
+
// OPT-IN (enabled:true) so cloud + local storage are respected by default
|
|
25
|
+
// — a full system snapshot already contains your data.
|
|
26
|
+
files: {
|
|
27
|
+
enabled: false,
|
|
28
|
+
schedule: { kind: 'daily', at: { hour: 22, minute: 0 } },
|
|
29
|
+
keep: 3, // latest + 2 previous days
|
|
30
|
+
catchUpLimit: 3,
|
|
31
|
+
sources: ['~/Desktop', '~/Documents', '~/Pictures'],
|
|
32
|
+
exclude: [
|
|
33
|
+
'**/.cache/**',
|
|
34
|
+
'**/.git/**',
|
|
35
|
+
'**/node_modules/**',
|
|
36
|
+
'**/__pycache__/**',
|
|
37
|
+
'**/*.tmp',
|
|
38
|
+
'**/*.swp',
|
|
39
|
+
'**/lost+found/**',
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
snapshots: {
|
|
43
|
+
enabled: true, // default: one weekly system snapshot keeps storage sane
|
|
44
|
+
schedule: { kind: 'weekly', on: 6, at: { hour: 22, minute: 0 } }, // Saturday 22:00
|
|
45
|
+
keep: 3, // latest + 2 previous (the middle one is the sanity safety net)
|
|
46
|
+
catchUpLimit: 3,
|
|
47
|
+
chunkSize: 2 * GiB,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
storage: {
|
|
51
|
+
remoteRoot: 'parrot-blackbox',
|
|
52
|
+
chunkSize: 2 * GiB,
|
|
53
|
+
providers: {
|
|
54
|
+
mega: { defaultQuotaGiB: 20 },
|
|
55
|
+
gdrive: { defaultQuotaGiB: 10 },
|
|
56
|
+
},
|
|
57
|
+
accounts: [], // {id, provider, label, remote, quotaGiB?}
|
|
58
|
+
},
|
|
59
|
+
network: {
|
|
60
|
+
pingHost: 'https://api.mega.nz',
|
|
61
|
+
retryEveryMinutes: 15,
|
|
62
|
+
},
|
|
63
|
+
daemon: {
|
|
64
|
+
pollIntervalSeconds: 60,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function readJsonSafe(file, fallback = null) {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
72
|
+
} catch {
|
|
73
|
+
return fallback;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Atomic JSON write: tmp + rename. Never yields a half-written file. */
|
|
78
|
+
export function writeJsonAtomic(file, data) {
|
|
79
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
80
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
81
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
82
|
+
fs.renameSync(tmp, file);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function loadConfig() {
|
|
86
|
+
const cfg = deepMerge(defaultConfig(), readJsonSafe(configFile(), {}));
|
|
87
|
+
if (!Array.isArray(cfg.storage.accounts)) cfg.storage.accounts = [];
|
|
88
|
+
return cfg;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function saveConfig(cfg) {
|
|
92
|
+
writeJsonAtomic(configFile(), cfg);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function defaultState() {
|
|
96
|
+
const now = iso(clock());
|
|
97
|
+
return {
|
|
98
|
+
version: 1,
|
|
99
|
+
createdAt: now,
|
|
100
|
+
jobs: {
|
|
101
|
+
files: { since: now, lastDue: null, lastCompletedDue: null, lastRunAt: null, lastStatus: null, lastError: null, completed: [], pending: [] },
|
|
102
|
+
snapshots: { since: now, lastDue: null, lastCompletedDue: null, lastRunAt: null, lastStatus: null, lastError: null, completed: [], pending: [] },
|
|
103
|
+
},
|
|
104
|
+
manifests: {},
|
|
105
|
+
storage: { lastRefreshAt: null },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function loadState() {
|
|
110
|
+
const file = stateFile();
|
|
111
|
+
if (!fs.existsSync(file)) {
|
|
112
|
+
// Persist the initial defaults so the "since" anchor survives across
|
|
113
|
+
// separate process runs (daemon, cron, force) — otherwise every fresh CLI
|
|
114
|
+
// invocation would re-anchor the schedule to *its* now and never see dues.
|
|
115
|
+
const fresh = defaultState();
|
|
116
|
+
saveState(fresh);
|
|
117
|
+
return fresh;
|
|
118
|
+
}
|
|
119
|
+
return deepMerge(defaultState(), readJsonSafe(file, {}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function saveState(st) {
|
|
123
|
+
writeJsonAtomic(stateFile(), st);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* ------------------------------------------------------------------ */
|
|
127
|
+
/* Journal */
|
|
128
|
+
/* ------------------------------------------------------------------ */
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Append one event to the journal. Lines look like:
|
|
132
|
+
* `2026-08-29T22:00:01|info|files|started due=2026-08-29T22:00:00`
|
|
133
|
+
*/
|
|
134
|
+
export function journal(event, detail = '', level = 'info') {
|
|
135
|
+
try {
|
|
136
|
+
fs.mkdirSync(path.dirname(journalFile()), { recursive: true });
|
|
137
|
+
const line = `${iso(clock())}|${level}|${event}|${String(detail).replace(/\n/g, ' ').slice(0, 400)}\n`;
|
|
138
|
+
fs.appendFileSync(journalFile(), line);
|
|
139
|
+
} catch {
|
|
140
|
+
/* journaling must never take the tool down */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Read the last n journal lines, optionally filtering by substring. */
|
|
145
|
+
export function lastJournal(n = 20, filter = '') {
|
|
146
|
+
try {
|
|
147
|
+
const lines = fs.existsSync(journalFile()) ? fs.readFileSync(journalFile(), 'utf8').split('\n').filter(Boolean) : [];
|
|
148
|
+
return lines.filter((l) => !filter || l.includes(filter)).slice(-n);
|
|
149
|
+
} catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ------------------------------------------------------------------ */
|
|
155
|
+
/* Runtime helpers used by setup / service / daemon control */
|
|
156
|
+
/* ------------------------------------------------------------------ */
|
|
157
|
+
|
|
158
|
+
export function currentTimeIso() {
|
|
159
|
+
return iso(clock());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function hasCommandSync(name) {
|
|
163
|
+
try {
|
|
164
|
+
const res = execaSync('bash', ['-c', `command -v "${name}"`], { reject: false });
|
|
165
|
+
return Boolean(res.stdout.trim());
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function setStateDirs() {
|
|
172
|
+
ensureStateDirs();
|
|
173
|
+
}
|
package/src/core/time.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time & scheduling math.
|
|
3
|
+
*
|
|
4
|
+
* The whole scheduler is built around calendar "due times" (e.g. every day at
|
|
5
|
+
* 22:00, or every Saturday at 22:00). Catch-up works by listing every due time
|
|
6
|
+
* strictly after the last one we considered and strictly before/at *now*, then
|
|
7
|
+
* running those we have not completed yet — oldest first. A machine that was
|
|
8
|
+
* off or offline simply finds its missed dues on the next wake-up and drains
|
|
9
|
+
* them in order.
|
|
10
|
+
*
|
|
11
|
+
* `clock()` is injectable via PBB_TEST_NOW so tests can fake the wall clock.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const MS_HOUR = 3_600_000;
|
|
15
|
+
export const MS_DAY = 24 * MS_HOUR;
|
|
16
|
+
|
|
17
|
+
/** Injectable wall clock. @returns {Date} */
|
|
18
|
+
export function clock() {
|
|
19
|
+
if (process.env.PBB_TEST_NOW) {
|
|
20
|
+
return new Date(process.env.PBB_TEST_NOW);
|
|
21
|
+
}
|
|
22
|
+
return new Date();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function startOfLocalDay(d) {
|
|
26
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build a Date at `at:{hour,minute}` on the local calendar day of `dayBase`. */
|
|
30
|
+
function localAt(dayBase, at) {
|
|
31
|
+
const d = new Date(dayBase.getFullYear(), dayBase.getMonth(), dayBase.getDate());
|
|
32
|
+
d.setHours(at.hour ?? 0, at.minute ?? 0, 0, 0);
|
|
33
|
+
return d;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Every due time in (from, to] for a daily schedule.
|
|
38
|
+
* @param {Date} from exclusive lower bound
|
|
39
|
+
* @param {Date} to inclusive upper bound
|
|
40
|
+
* @param {{hour:number,minute:number}} at
|
|
41
|
+
* @returns {Date[]} ascending
|
|
42
|
+
*/
|
|
43
|
+
export function dailyDues(from, to, at) {
|
|
44
|
+
const out = [];
|
|
45
|
+
if (to.getTime() <= from.getTime()) return out;
|
|
46
|
+
let day = startOfLocalDay(new Date(from.getTime() + 1));
|
|
47
|
+
const lastDay = startOfLocalDay(to);
|
|
48
|
+
while (day.getTime() <= lastDay.getTime()) {
|
|
49
|
+
const due = localAt(day, at);
|
|
50
|
+
if (due.getTime() > from.getTime() && due.getTime() <= to.getTime()) out.push(due);
|
|
51
|
+
day = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every due time in (from, to] for a weekly schedule.
|
|
58
|
+
* @param {Date} from exclusive lower bound
|
|
59
|
+
* @param {Date} to inclusive upper bound
|
|
60
|
+
* @param {0|1|2|3|4|5|6} on day of week, 0=Sunday .. 6=Saturday
|
|
61
|
+
* @param {{hour:number,minute:number}} at
|
|
62
|
+
* @returns {Date[]} ascending
|
|
63
|
+
*/
|
|
64
|
+
export function weeklyDues(from, to, on, at) {
|
|
65
|
+
const out = [];
|
|
66
|
+
if (to.getTime() <= from.getTime()) return out;
|
|
67
|
+
let day = startOfLocalDay(new Date(from.getTime() + 1));
|
|
68
|
+
const lastDay = startOfLocalDay(to);
|
|
69
|
+
// Advance to the first matching weekday at-or-after `day`.
|
|
70
|
+
while (day.getDay() !== on) day = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1);
|
|
71
|
+
while (day.getTime() <= lastDay.getTime()) {
|
|
72
|
+
const due = localAt(day, at);
|
|
73
|
+
if (due.getTime() > from.getTime() && due.getTime() <= to.getTime()) out.push(due);
|
|
74
|
+
day = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 7);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a job schedule object into a due-time generator.
|
|
81
|
+
* @param {{kind:'daily'|'weekly', at:{hour,minute}, on?:number}} schedule
|
|
82
|
+
*/
|
|
83
|
+
export function dueList(schedule, from, to) {
|
|
84
|
+
if (schedule.kind === 'weekly') {
|
|
85
|
+
return weeklyDues(from, to, schedule.on ?? 6, schedule.at ?? { hour: 22, minute: 0 });
|
|
86
|
+
}
|
|
87
|
+
return dailyDues(from, to, schedule.at ?? { hour: 22, minute: 0 });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** RFC3339-ish local ISO stamp without milliseconds (stable for keys). */
|
|
91
|
+
export function iso(d) {
|
|
92
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
93
|
+
return (
|
|
94
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T` +
|
|
95
|
+
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `2026-08-29T22:00:00` -> `2026-08-29` (the calendar day of the due). */
|
|
100
|
+
export function dueDay(isoDue) {
|
|
101
|
+
return (isoDue || '').slice(0, 10);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compute the pending due list for one job and persist window advancement.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} jobType 'files' | 'snapshots'
|
|
108
|
+
* @param {object} jobState state.jobs[jobType]
|
|
109
|
+
* @param {object} jobCfg config.jobs[jobType] (has schedule + catchUpLimit)
|
|
110
|
+
* @param {Date} now
|
|
111
|
+
* @returns {{pending:string[], lastDue:string|null, dropped:number}}
|
|
112
|
+
*/
|
|
113
|
+
export function advancePending(jobState, jobCfg, now) {
|
|
114
|
+
const from = jobState.lastDue ? new Date(jobState.lastDue) : new Date(jobState.since);
|
|
115
|
+
if (now.getTime() <= from.getTime()) {
|
|
116
|
+
return { pending: jobState.pending || [], lastDue: jobState.lastDue, dropped: 0 };
|
|
117
|
+
}
|
|
118
|
+
const candidates = dueList(jobCfg.schedule, from, now);
|
|
119
|
+
if (candidates.length === 0) {
|
|
120
|
+
return { pending: jobState.pending || [], lastDue: jobState.lastDue, dropped: 0 };
|
|
121
|
+
}
|
|
122
|
+
const completedDues = new Set((jobState.completed || []).map((c) => c.due));
|
|
123
|
+
const alreadyPending = new Set(jobState.pending || []);
|
|
124
|
+
const fresh = candidates
|
|
125
|
+
.map((d) => iso(d))
|
|
126
|
+
.filter((due) => !completedDues.has(due) && !alreadyPending.has(due));
|
|
127
|
+
|
|
128
|
+
const limit = jobCfg.catchUpLimit ?? 3;
|
|
129
|
+
const dropped = Math.max(0, fresh.length - limit);
|
|
130
|
+
const kept = fresh.slice(-limit); // keep the most recent `limit` missed dues
|
|
131
|
+
|
|
132
|
+
const merged = [...(jobState.pending || []), ...kept]
|
|
133
|
+
.filter((d, i, arr) => arr.indexOf(d) === i)
|
|
134
|
+
.sort();
|
|
135
|
+
const lastDue = iso(candidates[candidates.length - 1]);
|
|
136
|
+
return { pending: merged, lastDue, dropped };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Do two due stamps correspond to the same calendar slot? (string compare is exact) */
|
|
140
|
+
export function dueEqual(a, b) {
|
|
141
|
+
return a === b;
|
|
142
|
+
}
|