@openparachute/vault 0.1.0 → 0.2.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/CLAUDE.md +2 -2
  3. package/README.md +289 -44
  4. package/core/src/core.test.ts +802 -346
  5. package/core/src/expand.ts +140 -0
  6. package/core/src/hooks.test.ts +27 -27
  7. package/core/src/hooks.ts +1 -1
  8. package/core/src/mcp.ts +102 -39
  9. package/core/src/notes.ts +82 -4
  10. package/core/src/obsidian.test.ts +11 -11
  11. package/core/src/paths.test.ts +46 -46
  12. package/core/src/schema.ts +18 -2
  13. package/core/src/store.ts +51 -51
  14. package/core/src/types.ts +29 -29
  15. package/core/src/wikilinks.test.ts +61 -61
  16. package/docs/HTTP_API.md +4 -2
  17. package/package.json +1 -1
  18. package/src/auth.test.ts +319 -0
  19. package/src/backup-launchd.test.ts +90 -0
  20. package/src/backup-launchd.ts +169 -0
  21. package/src/backup.test.ts +715 -0
  22. package/src/backup.ts +699 -0
  23. package/src/cli.ts +923 -31
  24. package/src/config.test.ts +173 -0
  25. package/src/config.ts +345 -15
  26. package/src/daemon.ts +136 -0
  27. package/src/doctor.test.ts +356 -0
  28. package/src/health.test.ts +201 -0
  29. package/src/health.ts +115 -0
  30. package/src/launchd.test.ts +91 -0
  31. package/src/launchd.ts +37 -40
  32. package/src/mcp-http.ts +1 -1
  33. package/src/mcp-tools.ts +7 -9
  34. package/src/oauth.test.ts +289 -8
  35. package/src/oauth.ts +57 -12
  36. package/src/published.test.ts +21 -21
  37. package/src/routes.ts +152 -70
  38. package/src/routing.test.ts +347 -0
  39. package/src/routing.ts +365 -0
  40. package/src/server.ts +7 -278
  41. package/src/systemd.test.ts +15 -0
  42. package/src/systemd.ts +18 -11
  43. package/src/triggers.test.ts +7 -7
  44. package/src/triggers.ts +6 -6
  45. package/src/vault-store.ts +20 -3
  46. package/src/vault.test.ts +356 -262
  47. package/.claude/settings.local.json +0 -31
  48. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  49. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  50. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  51. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  52. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  53. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  54. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  55. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  56. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  57. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  58. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  59. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  60. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  61. package/religions-abrahamic-filter.png +0 -0
  62. package/religions-buddhism-v2.png +0 -0
  63. package/religions-buddhism.png +0 -0
  64. package/religions-final.png +0 -0
  65. package/religions-v1.png +0 -0
  66. package/religions-v2.png +0 -0
  67. package/religions-zen.png +0 -0
  68. package/web/README.md +0 -73
  69. package/web/bun.lock +0 -827
  70. package/web/eslint.config.js +0 -23
  71. package/web/index.html +0 -15
  72. package/web/package.json +0 -36
  73. package/web/public/favicon.svg +0 -1
  74. package/web/public/icons.svg +0 -24
  75. package/web/src/App.tsx +0 -149
  76. package/web/src/Graph.tsx +0 -200
  77. package/web/src/NoteView.tsx +0 -155
  78. package/web/src/Sidebar.tsx +0 -186
  79. package/web/src/api.ts +0 -21
  80. package/web/src/index.css +0 -50
  81. package/web/src/main.tsx +0 -10
  82. package/web/src/types.ts +0 -37
  83. package/web/src/utils.ts +0 -107
  84. package/web/tsconfig.app.json +0 -25
  85. package/web/tsconfig.json +0 -7
  86. package/web/tsconfig.node.json +0 -24
  87. package/web/vite.config.ts +0 -15
package/src/backup.ts ADDED
@@ -0,0 +1,699 @@
1
+ /**
2
+ * Vault backup — atomic SQLite snapshots + tarball assembly + destination
3
+ * dispatch + retention pruning.
4
+ *
5
+ * The pipeline is intentionally split into small, composable stages so that
6
+ * later destinations (`s3`, `rsync`, `cloud`) can be plugged in without
7
+ * rewriting the snapshot/tarball/prune layers. A future encryption hook
8
+ * would slot between `assembleTarball` and `writeToDestinations`.
9
+ *
10
+ * Why `VACUUM INTO` instead of the SQLite Online Backup API: `VACUUM INTO`
11
+ * produces a defragmented copy of the database in a single atomic operation
12
+ * and is safe against concurrent readers and writers under WAL journaling
13
+ * mode — exactly our use case. It is a synchronous server-side SQLite
14
+ * primitive, so we don't need a separate backup thread or library. The only
15
+ * caveat is that it copies the whole DB; at vault sizes we care about
16
+ * (single-digit GB), that's faster than we'd save by doing an incremental
17
+ * backup, and simpler is better for MVP.
18
+ */
19
+
20
+ import { homedir } from "os";
21
+ import { join, basename, resolve } from "path";
22
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, copyFileSync, statSync } from "fs";
23
+ import { tmpdir } from "os";
24
+ import { Database } from "bun:sqlite";
25
+ import { $ } from "bun";
26
+ import {
27
+ CONFIG_DIR,
28
+ VAULTS_DIR,
29
+ GLOBAL_CONFIG_PATH,
30
+ listVaults,
31
+ vaultDir,
32
+ vaultDbPath,
33
+ vaultConfigPath,
34
+ readGlobalConfig,
35
+ writeGlobalConfig,
36
+ defaultBackupConfig,
37
+ defaultRetentionPolicy,
38
+ } from "./config.ts";
39
+ import type { BackupConfig, BackupDestination, BackupSchedule, RetentionPolicy } from "./config.ts";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Public types
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export interface BackupResult {
46
+ /** Absolute path of the assembled tarball on disk (temp staging). */
47
+ tarballPath: string;
48
+ /** ISO8601 timestamp used in the tarball filename. */
49
+ timestamp: string;
50
+ /** Size of the tarball on disk, in bytes. */
51
+ bytes: number;
52
+ /** Per-destination outcome. One destination's failure does not stop others. */
53
+ destinations: DestinationResult[];
54
+ /** What the tarball contained — for verification in tests and `status`. */
55
+ contents: TarballContents;
56
+ }
57
+
58
+ export interface DestinationResult {
59
+ destination: BackupDestination;
60
+ /** Absolute path the tarball ended up at, or null on failure. */
61
+ writtenPath: string | null;
62
+ /** Number of old snapshots pruned after retention was applied. */
63
+ pruned: number;
64
+ /** Non-fatal error, if any. */
65
+ error?: string;
66
+ }
67
+
68
+ export interface TarballContents {
69
+ dbSnapshots: string[]; // filenames inside the tarball
70
+ configFiles: string[]; // filenames inside the tarball
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Path helpers
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /** Expand a leading `~/` or bare `~` in a config path. No-op for absolute. */
78
+ export function expandTilde(p: string): string {
79
+ if (p === "~") return homedir();
80
+ if (p.startsWith("~/")) return join(homedir(), p.slice(2));
81
+ return p;
82
+ }
83
+
84
+ /**
85
+ * Build the backup filename for a given timestamp. Separated so tests can
86
+ * pass a frozen timestamp and assert the format.
87
+ */
88
+ export function backupFilename(timestamp: string): string {
89
+ // ISO8601 has colons — not portable on filesystems (FAT/Windows mounts,
90
+ // some iCloud edge cases). Replace them with hyphens. The result still
91
+ // sorts lexicographically in chronological order.
92
+ const safe = timestamp.replace(/:/g, "-");
93
+ return `parachute-backup-${safe}.tar.gz`;
94
+ }
95
+
96
+ /** Inverse of `backupFilename` for parsing filenames during retention prune. */
97
+ export function parseBackupFilename(name: string): { timestamp: string } | null {
98
+ const m = name.match(/^parachute-backup-(.+)\.tar\.gz$/);
99
+ if (!m) return null;
100
+ return { timestamp: m[1] };
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Snapshot stage — produces SQLite copies + config files in a staging dir
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Take a `VACUUM INTO` snapshot of every `.db` file we can find in
109
+ * `CONFIG_DIR`:
110
+ *
111
+ * 1. Top-level `~/.parachute/*.db` — covers legacy pre-multi-vault installs
112
+ * (e.g. `daily.db`) and any user-placed sidecar DBs. Hits include `.bak`
113
+ * copies, which we intentionally skip since they're already static
114
+ * snapshots that `cp` would duplicate faster than VACUUM.
115
+ * 2. Per-vault `vaults/<name>/vault.db` via `listVaults()`.
116
+ *
117
+ * Returns the staging directory so the next stage can tar it up.
118
+ */
119
+ export async function stageSnapshot(opts?: {
120
+ /** Override config dir. Tests point this at a tempdir. */
121
+ configDir?: string;
122
+ /** Override vaults dir. Tests point this at a tempdir's vaults/. */
123
+ vaultsDir?: string;
124
+ /** Override staging dir. Tests pass a tempdir to inspect contents. */
125
+ stagingDir?: string;
126
+ }): Promise<{ stagingDir: string; contents: TarballContents }> {
127
+ const configDir = opts?.configDir ?? CONFIG_DIR;
128
+ const vaultsDir = opts?.vaultsDir ?? VAULTS_DIR;
129
+ const stagingDir = opts?.stagingDir ?? mkdtempSync(join(tmpdir(), "parachute-backup-"));
130
+
131
+ const dbSnapshots: string[] = [];
132
+ const configFiles: string[] = [];
133
+
134
+ // 1. Top-level *.db files in CONFIG_DIR. Skip .bak and other non-live files.
135
+ if (existsSync(configDir)) {
136
+ for (const entry of readdirSync(configDir)) {
137
+ if (!entry.endsWith(".db")) continue;
138
+ const src = join(configDir, entry);
139
+ // Skip symlinks-to-dirs, subdirs, etc. A `.db` extension on a directory
140
+ // is exotic; `statSync` isolates us from it.
141
+ try {
142
+ const st = statSync(src);
143
+ if (!st.isFile()) continue;
144
+ } catch {
145
+ continue;
146
+ }
147
+ const destName = `config-${entry}`;
148
+ const dest = join(stagingDir, destName);
149
+ vacuumInto(src, dest);
150
+ dbSnapshots.push(destName);
151
+ }
152
+ }
153
+
154
+ // 2. Per-vault DBs. We mirror the vaults/<name>/ layout inside the tarball
155
+ // so a restore can drop the whole directory back in place without renaming.
156
+ // vault.yaml is included alongside vault.db for the same reason.
157
+ if (existsSync(vaultsDir)) {
158
+ const vaultNames = listVaultsIn(vaultsDir);
159
+ for (const name of vaultNames) {
160
+ const dbSrc = join(vaultsDir, name, "vault.db");
161
+ const cfgSrc = join(vaultsDir, name, "vault.yaml");
162
+
163
+ if (existsSync(dbSrc)) {
164
+ const mirrorDir = join(stagingDir, "vaults", name);
165
+ mkdirSync(mirrorDir, { recursive: true });
166
+ const dbDest = join(mirrorDir, "vault.db");
167
+ vacuumInto(dbSrc, dbDest);
168
+ dbSnapshots.push(join("vaults", name, "vault.db"));
169
+ }
170
+ if (existsSync(cfgSrc)) {
171
+ const mirrorDir = join(stagingDir, "vaults", name);
172
+ mkdirSync(mirrorDir, { recursive: true });
173
+ copyFileSync(cfgSrc, join(mirrorDir, "vault.yaml"));
174
+ configFiles.push(join("vaults", name, "vault.yaml"));
175
+ }
176
+ }
177
+ }
178
+
179
+ // 3. Global config.yaml — the heart of "restore my setup" for a new machine.
180
+ const globalCfgSrc = opts?.configDir ? join(opts.configDir, "config.yaml") : GLOBAL_CONFIG_PATH;
181
+ if (existsSync(globalCfgSrc)) {
182
+ copyFileSync(globalCfgSrc, join(stagingDir, "config.yaml"));
183
+ configFiles.push("config.yaml");
184
+ }
185
+
186
+ return { stagingDir, contents: { dbSnapshots, configFiles } };
187
+ }
188
+
189
+ /** Take a VACUUM INTO snapshot. Atomic against concurrent writers under WAL. */
190
+ function vacuumInto(srcDbPath: string, destPath: string): void {
191
+ // VACUUM INTO requires the destination file NOT to exist — SQLite enforces
192
+ // this to avoid clobbering a live DB with a partial vacuum output. Staging
193
+ // dirs are fresh, so this should always hold; belt-and-braces guard below.
194
+ if (existsSync(destPath)) rmSync(destPath);
195
+ // readwrite=true is required — VACUUM INTO is considered a write by SQLite
196
+ // (it creates the output file), so read-only handles are rejected.
197
+ const db = new Database(srcDbPath, { readwrite: true });
198
+ try {
199
+ // Parameter binding: SQLite does NOT allow bound parameters for the
200
+ // VACUUM INTO target path, so we must splice it in. The path comes
201
+ // from our own staging tempdir — no user-controlled input — so
202
+ // string interpolation is safe. We still escape single quotes
203
+ // defensively in case a username contains one.
204
+ const escaped = destPath.replace(/'/g, "''");
205
+ db.run(`VACUUM INTO '${escaped}'`);
206
+ } finally {
207
+ db.close();
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Small internal helper so tests can point us at a vaults dir that isn't
213
+ * the global VAULTS_DIR without plumbing the override through `listVaults()`.
214
+ */
215
+ function listVaultsIn(dir: string): string[] {
216
+ if (!existsSync(dir)) return [];
217
+ try {
218
+ return readdirSync(dir).filter((entry) => {
219
+ const cfg = join(dir, entry, "vault.yaml");
220
+ return existsSync(cfg);
221
+ });
222
+ } catch {
223
+ return [];
224
+ }
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Tarball stage
229
+ // ---------------------------------------------------------------------------
230
+
231
+ /**
232
+ * Wrap a staging directory into a gzip'd tarball at `outPath`. Uses `tar`
233
+ * from PATH — macOS and every Linux distro we target ships bsdtar or GNU
234
+ * tar. We could reimplement the tar format in pure TypeScript, but that's
235
+ * a maintenance tax for no user-visible benefit.
236
+ *
237
+ * The tar is rooted at the staging dir (via `-C`) so the archive contains
238
+ * `config.yaml`, `vaults/...`, and `config-*.db` at the top level rather
239
+ * than burying them under a random tempdir prefix.
240
+ */
241
+ export async function assembleTarball(stagingDir: string, outPath: string): Promise<void> {
242
+ mkdirSync(resolve(outPath, ".."), { recursive: true });
243
+ const entries = readdirSync(stagingDir);
244
+ if (entries.length === 0) {
245
+ // `tar` on some platforms errors on an empty input list; we'd rather
246
+ // produce an empty-but-valid tarball. An empty staging dir means the
247
+ // user has no DBs yet — a legitimate state on a fresh install.
248
+ await $`tar -czf ${outPath} -C ${stagingDir} --files-from /dev/null`.quiet();
249
+ return;
250
+ }
251
+ await $`tar -czf ${outPath} -C ${stagingDir} ${entries}`.quiet();
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Destination stage
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * Write the tarball to each configured destination. Per-destination errors
260
+ * are captured, not thrown — a dead S3 bucket shouldn't prevent the local
261
+ * iCloud copy from succeeding. Callers surface results.
262
+ */
263
+ export async function writeToDestinations(
264
+ tarballPath: string,
265
+ destinations: BackupDestination[],
266
+ retention: RetentionPolicy,
267
+ ): Promise<DestinationResult[]> {
268
+ const results: DestinationResult[] = [];
269
+ for (const dest of destinations) {
270
+ try {
271
+ const res = await writeToDestination(tarballPath, dest, retention);
272
+ results.push(res);
273
+ } catch (err: any) {
274
+ results.push({
275
+ destination: dest,
276
+ writtenPath: null,
277
+ pruned: 0,
278
+ error: String(err?.message ?? err),
279
+ });
280
+ }
281
+ }
282
+ return results;
283
+ }
284
+
285
+ async function writeToDestination(
286
+ tarballPath: string,
287
+ dest: BackupDestination,
288
+ retention: RetentionPolicy,
289
+ ): Promise<DestinationResult> {
290
+ switch (dest.kind) {
291
+ case "local": {
292
+ const target = expandTilde(dest.path);
293
+ mkdirSync(target, { recursive: true });
294
+ const outName = basename(tarballPath);
295
+ const out = join(target, outName);
296
+ copyFileSync(tarballPath, out);
297
+ const pruned = pruneRetention(target, retention);
298
+ return { destination: dest, writtenPath: out, pruned };
299
+ }
300
+ // Exhaustiveness guard — if a future destination kind is added to the
301
+ // type union but not handled here, the compiler fails the build.
302
+ default: {
303
+ const _exhaustive: never = dest;
304
+ throw new Error(`Unsupported destination kind: ${JSON.stringify(_exhaustive)}`);
305
+ }
306
+ }
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Tiered retention — grandfather / father / son
311
+ // ---------------------------------------------------------------------------
312
+
313
+ /**
314
+ * Parse a backup filename's timestamp component back into a Date. We wrote the
315
+ * timestamp as ISO-8601 with colons swapped for hyphens (for filesystem
316
+ * portability), so we have to swap back before handing to `new Date()`.
317
+ *
318
+ * The hyphen-for-colon swap is position-specific: the ISO-8601 date-time
319
+ * separator is `T`, after which there are three hyphens we introduced (HH-MM-SS)
320
+ * but also possibly real hyphens in the timezone offset (…+00:00 → +00-00).
321
+ * We undo every hyphen that appears AFTER the `T`, preserving the three
322
+ * leading hyphens in the YYYY-MM-DD portion.
323
+ */
324
+ function timestampToDate(stamp: string): Date | null {
325
+ const tIdx = stamp.indexOf("T");
326
+ if (tIdx < 0) return null;
327
+ const head = stamp.slice(0, tIdx);
328
+ const tail = stamp.slice(tIdx).replace(/-/g, ":");
329
+ const iso = head + tail;
330
+ const d = new Date(iso);
331
+ return Number.isFinite(d.getTime()) ? d : null;
332
+ }
333
+
334
+ /**
335
+ * Bucket key for the daily tier: ISO calendar date in the local timezone
336
+ * (YYYY-MM-DD). We lean on `Intl.DateTimeFormat` with the system's default
337
+ * timezone because it handles DST transitions correctly, unlike hand-rolling
338
+ * with `getDate()` from a UTC Date that's been shifted by offset math.
339
+ */
340
+ function localDateKey(d: Date): string {
341
+ // `en-CA` gives us ISO-like `YYYY-MM-DD` by default — a happy accident that
342
+ // saves us from assembling the pieces ourselves.
343
+ return new Intl.DateTimeFormat("en-CA", {
344
+ year: "numeric",
345
+ month: "2-digit",
346
+ day: "2-digit",
347
+ }).format(d);
348
+ }
349
+
350
+ function localYearKey(d: Date): string {
351
+ return new Intl.DateTimeFormat("en-CA", { year: "numeric" }).format(d);
352
+ }
353
+
354
+ function localYearMonthKey(d: Date): string {
355
+ return new Intl.DateTimeFormat("en-CA", {
356
+ year: "numeric",
357
+ month: "2-digit",
358
+ }).format(d);
359
+ }
360
+
361
+ /**
362
+ * ISO week bucket: (ISO week year, ISO week number). We compute both in the
363
+ * local timezone so "end-of-week rollover" aligns with what the user sees on
364
+ * their calendar. The ISO week year can differ from the calendar year at
365
+ * year boundaries (a Dec 31 Monday belongs to next year's week 1; a Jan 1
366
+ * Friday belongs to last year's week 53) — we handle that with the standard
367
+ * "week containing the year's first Thursday is week 1" rule.
368
+ */
369
+ function isoWeekKey(d: Date): string {
370
+ // Pull out local-tz Y/M/D so week math stays aligned to the user's calendar
371
+ // instead of UTC.
372
+ const parts = new Intl.DateTimeFormat("en-CA", {
373
+ year: "numeric",
374
+ month: "2-digit",
375
+ day: "2-digit",
376
+ }).formatToParts(d);
377
+ const yStr = parts.find((p) => p.type === "year")?.value ?? "1970";
378
+ const mStr = parts.find((p) => p.type === "month")?.value ?? "01";
379
+ const dStr = parts.find((p) => p.type === "day")?.value ?? "01";
380
+ const year = parseInt(yStr, 10);
381
+ const month = parseInt(mStr, 10);
382
+ const day = parseInt(dStr, 10);
383
+
384
+ // Work in a UTC Date that carries our local Y/M/D, so further arithmetic
385
+ // doesn't get perturbed by DST.
386
+ const target = new Date(Date.UTC(year, month - 1, day));
387
+ // Shift target to the Thursday of its week (ISO weeks are anchored there).
388
+ // getUTCDay(): 0=Sun, 1=Mon, …, 6=Sat. ISO wants Mon=1, so (day+6)%7 maps
389
+ // Sun→6, Mon→0, …, Sat→5, which is the "days since Monday."
390
+ const dayNum = (target.getUTCDay() + 6) % 7;
391
+ target.setUTCDate(target.getUTCDate() - dayNum + 3);
392
+ // Week 1 is the one containing Jan 4 (equivalently, the year's first Thursday).
393
+ const week1 = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
394
+ const week = 1 + Math.round(
395
+ ((target.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getUTCDay() + 6) % 7)) / 7,
396
+ );
397
+ const isoYear = target.getUTCFullYear();
398
+ // Pad week to 2 digits so string-sort matches chronological order.
399
+ return `${isoYear}-W${String(week).padStart(2, "0")}`;
400
+ }
401
+
402
+ export interface SnapshotEntry {
403
+ name: string;
404
+ timestamp: string;
405
+ date: Date;
406
+ }
407
+
408
+ /**
409
+ * Enumerate the `parachute-backup-*.tar.gz` files in a directory, parse each
410
+ * timestamp, and return them sorted ascending (oldest first) — the order we
411
+ * rely on for bucket-last-wins and for test determinism.
412
+ */
413
+ export function listSnapshots(dir: string): SnapshotEntry[] {
414
+ if (!existsSync(dir)) return [];
415
+ const entries: SnapshotEntry[] = [];
416
+ for (const name of readdirSync(dir)) {
417
+ const parsed = parseBackupFilename(name);
418
+ if (!parsed) continue;
419
+ const d = timestampToDate(parsed.timestamp);
420
+ if (!d) continue;
421
+ entries.push({ name, timestamp: parsed.timestamp, date: d });
422
+ }
423
+ // Because our filename timestamps are lexicographically sortable, sorting
424
+ // by name and sorting by date yield the same order. We sort by timestamp
425
+ // string because it's cheaper and deterministic across clock skew.
426
+ entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
427
+ return entries;
428
+ }
429
+
430
+ /**
431
+ * Compute the subset of snapshots that the tiered policy keeps. Public for
432
+ * testability — `pruneRetention` is the mutating variant.
433
+ *
434
+ * Algorithm:
435
+ * 1. Group entries by bucket key for each tier (daily / weekly / monthly /
436
+ * yearly). Since inputs are sorted ascending, the last entry overwriting
437
+ * a given bucket key is the most-recent-in-bucket — exactly the one we
438
+ * want to keep for that tier.
439
+ * 2. For weekly/monthly/yearly, take the N most recent bucket keys (or all
440
+ * of them if yearly is null) and union their keepers.
441
+ * 3. For daily, skip the bucketing step — just keep the last N entries.
442
+ * 4. Return the union as a Set of filenames.
443
+ *
444
+ * A tier set to 0 contributes no keepers but doesn't disable the others.
445
+ * Sparse data (gaps) just means fewer buckets; no special-casing required.
446
+ */
447
+ export function computeKeepSet(
448
+ entries: SnapshotEntry[],
449
+ policy: RetentionPolicy,
450
+ ): Set<string> {
451
+ const keep = new Set<string>();
452
+
453
+ // Daily: take the last N entries outright. No bucketing by day needed;
454
+ // if two backups land on the same day, they both count toward the daily
455
+ // tier's "last N" — this is the intuitive "keep my 7 most recent" promise.
456
+ if (policy.daily > 0) {
457
+ for (const entry of entries.slice(-policy.daily)) keep.add(entry.name);
458
+ }
459
+
460
+ // Weekly / monthly / yearly — all follow the same pattern: bucket by key,
461
+ // take the most recent entry per bucket, then cap to the N most recent
462
+ // buckets. Implemented once here with a small helper to avoid drift.
463
+ const tierByBucket = (
464
+ keyFn: (d: Date) => string,
465
+ limit: number | null,
466
+ ) => {
467
+ if (limit === 0) return;
468
+ const buckets = new Map<string, SnapshotEntry>();
469
+ for (const entry of entries) {
470
+ // Last-write-wins: because entries are sorted ascending, the final
471
+ // overwrite for a given key IS the most recent entry in that bucket.
472
+ buckets.set(keyFn(entry.date), entry);
473
+ }
474
+ // Sort bucket keys descending (most recent first) then cap. Our keys
475
+ // are all lex-sortable (ISO-like), so string compare == chronological.
476
+ const keysDesc = [...buckets.keys()].sort().reverse();
477
+ const chosen = limit === null ? keysDesc : keysDesc.slice(0, limit);
478
+ for (const k of chosen) keep.add(buckets.get(k)!.name);
479
+ };
480
+
481
+ tierByBucket(isoWeekKey, policy.weekly);
482
+ tierByBucket(localYearMonthKey, policy.monthly);
483
+ tierByBucket(localYearKey, policy.yearly);
484
+
485
+ return keep;
486
+ }
487
+
488
+ /**
489
+ * Per-tier breakdown of a destination's current keep set — how many snapshots
490
+ * each tier contributes. Sums are with respect to the un-pruned contents of
491
+ * `dir` (i.e., snapshot-of-current-state, not "what would we prune next"),
492
+ * which is what `backup status` wants to render.
493
+ *
494
+ * `total` is the number of snapshot files present on disk. Per-tier counts
495
+ * sum to the size of the union; because a single snapshot can satisfy
496
+ * multiple tiers, they can sum to more than `total`.
497
+ */
498
+ export interface TierTally {
499
+ total: number;
500
+ daily: number;
501
+ weekly: number;
502
+ monthly: number;
503
+ yearly: number;
504
+ }
505
+
506
+ export function tierTally(dir: string, policy: RetentionPolicy): TierTally {
507
+ const entries = listSnapshots(dir);
508
+ const total = entries.length;
509
+ // Re-run each tier in isolation to count its individual contribution.
510
+ // Tiny N (usually < 100 snapshots), so the duplicated bucketing is fine.
511
+ const isolate = (tier: Partial<RetentionPolicy>): number => {
512
+ const p: RetentionPolicy = { daily: 0, weekly: 0, monthly: 0, yearly: 0, ...tier };
513
+ return computeKeepSet(entries, p).size;
514
+ };
515
+ return {
516
+ total,
517
+ daily: isolate({ daily: policy.daily }),
518
+ weekly: isolate({ weekly: policy.weekly }),
519
+ monthly: isolate({ monthly: policy.monthly }),
520
+ yearly: isolate({ yearly: policy.yearly }),
521
+ };
522
+ }
523
+
524
+ /**
525
+ * Tiered retention: keep the union of daily/weekly/monthly/yearly tiers,
526
+ * delete everything else. Returns the number of files deleted.
527
+ *
528
+ * Uses filename-embedded timestamps (NOT file mtime — mtime is unreliable
529
+ * after move/sync, especially under iCloud which rewrites timestamps).
530
+ */
531
+ export function pruneRetention(dir: string, policy: RetentionPolicy): number {
532
+ if (!existsSync(dir)) return 0;
533
+ const entries = listSnapshots(dir);
534
+ const keep = computeKeepSet(entries, policy);
535
+ let deleted = 0;
536
+ for (const entry of entries) {
537
+ if (keep.has(entry.name)) continue;
538
+ try {
539
+ rmSync(join(dir, entry.name));
540
+ deleted++;
541
+ } catch {
542
+ // Prune failure is non-fatal; we'd rather keep making new backups
543
+ // than abort because one stale file is locked.
544
+ }
545
+ }
546
+ return deleted;
547
+ }
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // Top-level orchestration
551
+ // ---------------------------------------------------------------------------
552
+
553
+ /**
554
+ * Run a single backup end-to-end: stage, tar, ship to destinations. This is
555
+ * what `parachute vault backup` and the launchd-scheduled job both invoke.
556
+ *
557
+ * The staging dir is cleaned up on exit; the tarball itself is kept (copied
558
+ * to each destination) and also left behind in the staging dir's parent —
559
+ * actually, no: we drop the staging dir entirely and rely on the
560
+ * destination-side copy as the durable artifact.
561
+ */
562
+ export async function runBackup(opts?: {
563
+ configDir?: string;
564
+ vaultsDir?: string;
565
+ backup?: BackupConfig;
566
+ /** Freeze "now" for deterministic tests. ISO8601 string. */
567
+ now?: string;
568
+ }): Promise<BackupResult> {
569
+ const backup = opts?.backup ?? readGlobalConfig().backup ?? defaultBackupConfig();
570
+ const timestamp = opts?.now ?? new Date().toISOString();
571
+
572
+ const { stagingDir, contents } = await stageSnapshot({
573
+ configDir: opts?.configDir,
574
+ vaultsDir: opts?.vaultsDir,
575
+ });
576
+
577
+ try {
578
+ const tarName = backupFilename(timestamp);
579
+ const tarballPath = join(stagingDir, "__out__", tarName);
580
+ await assembleTarball(stagingDir, tarballPath);
581
+ const bytes = statSync(tarballPath).size;
582
+
583
+ const results = await writeToDestinations(tarballPath, backup.destinations, backup.retention);
584
+
585
+ // Record last-backup metadata for `status`. Stored in a small JSON file
586
+ // inside CONFIG_DIR so it survives across daemons and doesn't require
587
+ // plumbing through config.yaml (which is hand-edited by users).
588
+ recordLastBackup({
589
+ timestamp,
590
+ bytes,
591
+ destinations: results.map((r) => ({
592
+ path: r.writtenPath,
593
+ error: r.error ?? null,
594
+ })),
595
+ }, opts?.configDir);
596
+
597
+ return { tarballPath, timestamp, bytes, destinations: results, contents };
598
+ } finally {
599
+ // The staging dir has the only copy of the tarball that isn't at a
600
+ // destination; destinations have already been written. Safe to clean.
601
+ try { rmSync(stagingDir, { recursive: true, force: true }); } catch {}
602
+ }
603
+ }
604
+
605
+ // ---------------------------------------------------------------------------
606
+ // Last-run metadata (for `status`)
607
+ // ---------------------------------------------------------------------------
608
+
609
+ export interface LastBackupMeta {
610
+ timestamp: string;
611
+ bytes: number;
612
+ destinations: Array<{ path: string | null; error: string | null }>;
613
+ }
614
+
615
+ export function lastBackupPath(configDir?: string): string {
616
+ return join(configDir ?? CONFIG_DIR, "backup-last.json");
617
+ }
618
+
619
+ function recordLastBackup(meta: LastBackupMeta, configDir?: string): void {
620
+ try {
621
+ mkdirSync(configDir ?? CONFIG_DIR, { recursive: true });
622
+ Bun.write(lastBackupPath(configDir), JSON.stringify(meta, null, 2) + "\n");
623
+ } catch {
624
+ // Non-fatal — losing last-run metadata is a UX regression, not a data loss.
625
+ }
626
+ }
627
+
628
+ export function readLastBackup(configDir?: string): LastBackupMeta | null {
629
+ const p = lastBackupPath(configDir);
630
+ if (!existsSync(p)) return null;
631
+ try {
632
+ return JSON.parse(require("fs").readFileSync(p, "utf-8"));
633
+ } catch {
634
+ return null;
635
+ }
636
+ }
637
+
638
+ // ---------------------------------------------------------------------------
639
+ // Config ergonomics
640
+ // ---------------------------------------------------------------------------
641
+
642
+ /**
643
+ * Atomically update the `backup` section of global config. Creates the
644
+ * section with defaults if missing, then applies `patch`. Used by the
645
+ * `--schedule` flag and by tests.
646
+ */
647
+ export function updateBackupConfig(patch: Partial<BackupConfig>): BackupConfig {
648
+ const cfg = readGlobalConfig();
649
+ const next: BackupConfig = { ...defaultBackupConfig(), ...(cfg.backup ?? {}), ...patch };
650
+ cfg.backup = next;
651
+ writeGlobalConfig(cfg);
652
+ return next;
653
+ }
654
+
655
+ /**
656
+ * Probe whether a destination is ready to receive a backup. For `local`,
657
+ * this is a mkdir + write-probe roundtrip. Used by `doctor` so users learn
658
+ * about an unwritable iCloud path BEFORE the scheduled run silently fails.
659
+ */
660
+ export function checkDestinationWritable(dest: BackupDestination): {
661
+ ok: boolean;
662
+ path: string;
663
+ error?: string;
664
+ } {
665
+ if (dest.kind !== "local") {
666
+ // Other destination kinds don't exist yet. When they do, each will
667
+ // implement its own writability probe (e.g., an S3 bucket HeadBucket).
668
+ return { ok: false, path: JSON.stringify(dest), error: "unsupported destination kind" };
669
+ }
670
+ const target = expandTilde(dest.path);
671
+ try {
672
+ mkdirSync(target, { recursive: true });
673
+ const probe = join(target, `.parachute-write-probe-${process.pid}`);
674
+ Bun.write(probe, "");
675
+ // Clean up the probe. Failure to remove it is not a writability failure
676
+ // — the directory is writable or the probe write above would have thrown.
677
+ try { rmSync(probe); } catch {}
678
+ return { ok: true, path: target };
679
+ } catch (err: any) {
680
+ return { ok: false, path: target, error: String(err?.message ?? err) };
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Calendar-arithmetic "next run" estimate for `status`. This is deliberately
686
+ * approximate — launchd is our source of truth for actual firing — but it's
687
+ * what every cron-style UI shows and it's better than "unknown."
688
+ */
689
+ export function nextRunEstimate(schedule: BackupSchedule, lastRun?: Date): Date | null {
690
+ if (schedule === "manual") return null;
691
+ const base = lastRun ?? new Date();
692
+ const next = new Date(base);
693
+ switch (schedule) {
694
+ case "hourly": next.setHours(next.getHours() + 1); break;
695
+ case "daily": next.setDate(next.getDate() + 1); break;
696
+ case "weekly": next.setDate(next.getDate() + 7); break;
697
+ }
698
+ return next;
699
+ }