@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
@@ -2,6 +2,8 @@ import { describe, test, expect } from "bun:test";
2
2
  import {
3
3
  writeVaultConfig,
4
4
  readVaultConfig,
5
+ writeGlobalConfig,
6
+ readGlobalConfig,
5
7
  generateApiKey,
6
8
  hashKey,
7
9
  verifyKey,
@@ -122,4 +124,175 @@ describe("config", () => {
122
124
  expect(loaded).not.toBeNull();
123
125
  expect(loaded!.tag_schemas).toBeUndefined();
124
126
  });
127
+
128
+ test("round-trips discovery: enabled|disabled", () => {
129
+ // Default: absent means enabled (endpoint serves names).
130
+ writeGlobalConfig({ port: 1940 });
131
+ expect(readGlobalConfig().discovery).toBeUndefined();
132
+
133
+ // Explicit enabled.
134
+ writeGlobalConfig({ port: 1940, discovery: "enabled" });
135
+ expect(readGlobalConfig().discovery).toBe("enabled");
136
+
137
+ // Explicit disabled — this is the opt-out flag operators set when they
138
+ // don't want /vaults/list to reveal vault names publicly.
139
+ writeGlobalConfig({ port: 1940, discovery: "disabled" });
140
+ expect(readGlobalConfig().discovery).toBe("disabled");
141
+ });
142
+ });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Backup config — round-trip through writeGlobalConfig + readGlobalConfig
146
+ // ---------------------------------------------------------------------------
147
+
148
+ import { describe as describe2, test as test2, expect as expect2, beforeEach, afterEach } from "bun:test";
149
+ import { mkdtempSync, rmSync } from "fs";
150
+ import { tmpdir } from "os";
151
+ import { join } from "path";
152
+
153
+ describe2("backup config round-trip", () => {
154
+ // We can't just call writeGlobalConfig / readGlobalConfig like above: they
155
+ // write into CONFIG_DIR, which is derived from PARACHUTE_HOME at import
156
+ // time. So we spawn a child process with an isolated PARACHUTE_HOME to
157
+ // test the full read/write cycle, matching the pattern in doctor.test.ts.
158
+ let dir: string;
159
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "backup-cfg-")); });
160
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
161
+
162
+ test2("writes and reads a backup section with tiered retention + local dest", async () => {
163
+ // Child script writes a config, re-reads it, and prints the normalized
164
+ // result. This exercises the full YAML round-trip without mocking.
165
+ const script = `
166
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
167
+ const { writeGlobalConfig, readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
168
+ writeGlobalConfig({
169
+ port: 1940,
170
+ default_vault: "default",
171
+ backup: {
172
+ schedule: "daily",
173
+ retention: { daily: 7, weekly: 4, monthly: 12, yearly: null },
174
+ destinations: [{ kind: "local", path: "~/parachute-backups" }],
175
+ },
176
+ });
177
+ const read = readGlobalConfig();
178
+ console.log(JSON.stringify(read.backup));
179
+ `;
180
+ const proc = Bun.spawnSync({
181
+ cmd: ["bun", "-e", script],
182
+ stdout: "pipe",
183
+ stderr: "pipe",
184
+ });
185
+ const stdout = new TextDecoder().decode(proc.stdout);
186
+ const stderr = new TextDecoder().decode(proc.stderr);
187
+ expect2(proc.exitCode, stderr).toBe(0);
188
+
189
+ const parsed = JSON.parse(stdout.trim());
190
+ expect2(parsed.schedule).toBe("daily");
191
+ expect2(parsed.retention).toEqual({ daily: 7, weekly: 4, monthly: 12, yearly: null });
192
+ expect2(parsed.destinations.length).toBe(1);
193
+ expect2(parsed.destinations[0].kind).toBe("local");
194
+ expect2(parsed.destinations[0].path).toBe("~/parachute-backups");
195
+ });
196
+
197
+ test2("retention defaults when the user omits the retention block entirely", async () => {
198
+ // A backup: with schedule only (no retention block) should pick up the
199
+ // shipped defaults: 7/4/12/null.
200
+ const script = `
201
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
202
+ const fs = await import("fs");
203
+ const path = await import("path");
204
+ fs.writeFileSync(path.join(${JSON.stringify(dir)}, "config.yaml"),
205
+ "port: 1940\\nbackup:\\n schedule: daily\\n destinations: []\\n");
206
+ const { readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
207
+ console.log(JSON.stringify(readGlobalConfig().backup));
208
+ `;
209
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
210
+ const out = new TextDecoder().decode(proc.stdout);
211
+ expect2(proc.exitCode, new TextDecoder().decode(proc.stderr)).toBe(0);
212
+ const parsed = JSON.parse(out.trim());
213
+ expect2(parsed.retention).toEqual({ daily: 7, weekly: 4, monthly: 12, yearly: null });
214
+ });
215
+
216
+ test2("partial retention block: unspecified tiers default to 0 (explicit > merged)", async () => {
217
+ // If the user supplies a retention block with only `daily: 3`, the
218
+ // remaining tiers read as 0 rather than merging with shipped defaults.
219
+ // Predictable: what you write is what you get.
220
+ const script = `
221
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
222
+ const fs = await import("fs");
223
+ const path = await import("path");
224
+ fs.writeFileSync(path.join(${JSON.stringify(dir)}, "config.yaml"),
225
+ "port: 1940\\nbackup:\\n schedule: daily\\n retention:\\n daily: 3\\n destinations: []\\n");
226
+ const { readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
227
+ console.log(JSON.stringify(readGlobalConfig().backup));
228
+ `;
229
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
230
+ const out = new TextDecoder().decode(proc.stdout);
231
+ expect2(proc.exitCode, new TextDecoder().decode(proc.stderr)).toBe(0);
232
+ const parsed = JSON.parse(out.trim());
233
+ expect2(parsed.retention.daily).toBe(3);
234
+ expect2(parsed.retention.weekly).toBe(0);
235
+ expect2(parsed.retention.monthly).toBe(0);
236
+ // yearly stays at 0 because the user didn't say null.
237
+ expect2(parsed.retention.yearly).toBe(0);
238
+ });
239
+
240
+ test2("yearly: null round-trips through write/read as JSON null", async () => {
241
+ const script = `
242
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
243
+ const { writeGlobalConfig, readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
244
+ writeGlobalConfig({
245
+ port: 1940,
246
+ backup: {
247
+ schedule: "manual",
248
+ retention: { daily: 0, weekly: 0, monthly: 0, yearly: null },
249
+ destinations: [],
250
+ },
251
+ });
252
+ console.log(JSON.stringify(readGlobalConfig().backup));
253
+ `;
254
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
255
+ const out = new TextDecoder().decode(proc.stdout);
256
+ expect2(proc.exitCode).toBe(0);
257
+ const parsed = JSON.parse(out.trim());
258
+ expect2(parsed.retention.yearly).toBeNull();
259
+ });
260
+
261
+ test2("config without a backup section reads back with backup === undefined", async () => {
262
+ const script = `
263
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
264
+ const { writeGlobalConfig, readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
265
+ writeGlobalConfig({ port: 1940, default_vault: "default" });
266
+ const read = readGlobalConfig();
267
+ console.log(JSON.stringify({ backup: read.backup ?? null }));
268
+ `;
269
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
270
+ const out = new TextDecoder().decode(proc.stdout);
271
+ expect2(proc.exitCode).toBe(0);
272
+ const parsed = JSON.parse(out.trim());
273
+ expect2(parsed.backup).toBeNull();
274
+ });
275
+
276
+ test2("empty destinations round-trips as empty list (not missing key)", async () => {
277
+ const script = `
278
+ process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
279
+ const { writeGlobalConfig, readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
280
+ writeGlobalConfig({
281
+ port: 1940,
282
+ backup: {
283
+ schedule: "manual",
284
+ retention: { daily: 7, weekly: 4, monthly: 12, yearly: null },
285
+ destinations: [],
286
+ },
287
+ });
288
+ const read = readGlobalConfig();
289
+ console.log(JSON.stringify(read.backup));
290
+ `;
291
+ const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
292
+ const out = new TextDecoder().decode(proc.stdout);
293
+ expect2(proc.exitCode).toBe(0);
294
+ const parsed = JSON.parse(out.trim());
295
+ expect2(parsed.schedule).toBe("manual");
296
+ expect2(parsed.destinations).toEqual([]);
297
+ });
125
298
  });
package/src/config.ts CHANGED
@@ -19,9 +19,34 @@ import crypto from "node:crypto";
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
21
  // Paths
22
+ //
23
+ // Historical note: the exported `CONFIG_DIR`, `VAULTS_DIR`, etc. used to be
24
+ // `const` captured at module load. That made tests flaky: anything setting
25
+ // `process.env.PARACHUTE_HOME` after import would be ignored, and when `bun
26
+ // test` shares one process across files, whichever test loaded first froze
27
+ // the path for the rest. Internal read/write now go through the `*Path()`
28
+ // getters so `PARACHUTE_HOME` is re-read per call. The top-level constants
29
+ // are kept for backward-compat (other modules import them) and reflect the
30
+ // value at load time.
22
31
  // ---------------------------------------------------------------------------
23
32
 
24
- export const CONFIG_DIR = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
33
+ function configDirPath(): string {
34
+ return process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
35
+ }
36
+
37
+ function vaultsDirPath(): string {
38
+ return join(configDirPath(), "vaults");
39
+ }
40
+
41
+ function globalConfigPath(): string {
42
+ return join(configDirPath(), "config.yaml");
43
+ }
44
+
45
+ function envFilePath(): string {
46
+ return join(configDirPath(), ".env");
47
+ }
48
+
49
+ export const CONFIG_DIR = configDirPath();
25
50
  export const VAULTS_DIR = join(CONFIG_DIR, "vaults");
26
51
  export const GLOBAL_CONFIG_PATH = join(CONFIG_DIR, "config.yaml");
27
52
  export const ENV_PATH = join(CONFIG_DIR, ".env");
@@ -31,7 +56,7 @@ export const DEFAULT_PORT = 1940;
31
56
  export const ASSETS_DIR = join(CONFIG_DIR, "assets");
32
57
 
33
58
  export function vaultDir(name: string): string {
34
- return join(VAULTS_DIR, name);
59
+ return join(vaultsDirPath(), name);
35
60
  }
36
61
 
37
62
  export function vaultDbPath(name: string): string {
@@ -137,6 +162,90 @@ export interface GlobalConfig {
137
162
  totp_secret?: string;
138
163
  /** Bcrypt hashes of single-use backup codes for 2FA recovery. */
139
164
  backup_codes?: string[];
165
+ /**
166
+ * Controls the public `GET /vaults/list` endpoint.
167
+ * - `"enabled"` (default): returns vault names (no other metadata).
168
+ * - `"disabled"`: returns 404, hiding vault existence from unauthenticated
169
+ * callers.
170
+ */
171
+ discovery?: "enabled" | "disabled";
172
+ /** Backup configuration: schedule, retention, destinations. */
173
+ backup?: BackupConfig;
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Backup configuration
178
+ // ---------------------------------------------------------------------------
179
+
180
+ export type BackupSchedule = "hourly" | "daily" | "weekly" | "manual";
181
+
182
+ /**
183
+ * Discriminated union over destination kinds. For the MVP we ship `local`
184
+ * only; `s3`, `rsync`, and `cloud` kinds will be added as additional variants
185
+ * without breaking existing configs. Unknown kinds are preserved verbatim so
186
+ * a forward-rolled config edited by a newer CLI isn't silently downgraded by
187
+ * an older CLI rewriting the file.
188
+ */
189
+ export interface LocalBackupDestination {
190
+ kind: "local";
191
+ /** Absolute or `~/`-prefixed path. `~/` is expanded at use-time. */
192
+ path: string;
193
+ }
194
+
195
+ export type BackupDestination = LocalBackupDestination;
196
+
197
+ /**
198
+ * Tiered (grandfather-father-son) retention policy. After each backup we keep
199
+ * the union of four tier queries:
200
+ *
201
+ * daily — the N most recent snapshots (unconditionally).
202
+ * weekly — one snapshot per ISO week, for the N most recent such weeks.
203
+ * monthly — one snapshot per calendar month, for the N most recent months.
204
+ * yearly — one snapshot per calendar year; `null` means keep every year
205
+ * (never prune by age — the long-tail archive).
206
+ *
207
+ * A tier set to 0 is disabled (it contributes no keepers, but the other tiers
208
+ * still apply). All bucketing uses the local timezone so calendar alignment
209
+ * matches the user's expectations, not UTC.
210
+ */
211
+ export interface RetentionPolicy {
212
+ /** Keep the last N daily snapshots. 0 disables the daily tier. */
213
+ daily: number;
214
+ /** Keep the last snapshot from each of the last N ISO weeks. 0 disables. */
215
+ weekly: number;
216
+ /** Keep the last snapshot from each of the last N months. 0 disables. */
217
+ monthly: number;
218
+ /**
219
+ * Keep the last snapshot from each of the last N years. `null` or `undefined`
220
+ * means unbounded — keep one snapshot per year, forever, across the full
221
+ * history. 0 disables the yearly tier entirely.
222
+ */
223
+ yearly: number | null;
224
+ }
225
+
226
+ export interface BackupConfig {
227
+ /** How often the scheduler fires. "manual" = scheduler is not registered. */
228
+ schedule: BackupSchedule;
229
+ /** Tiered retention policy — grandfather/father/son. */
230
+ retention: RetentionPolicy;
231
+ /** Pluggable destinations. Runs in order; a destination error logs + continues. */
232
+ destinations: BackupDestination[];
233
+ }
234
+
235
+ export function defaultRetentionPolicy(): RetentionPolicy {
236
+ // Defaults balance "I want to roll back yesterday's accidental delete"
237
+ // against "my iCloud folder shouldn't blow up in a year." The yearly tier
238
+ // is unbounded by default — the whole point of the tiered policy is that
239
+ // one-per-year is cheap forever.
240
+ return { daily: 7, weekly: 4, monthly: 12, yearly: null };
241
+ }
242
+
243
+ export function defaultBackupConfig(): BackupConfig {
244
+ return {
245
+ schedule: "manual",
246
+ retention: defaultRetentionPolicy(),
247
+ destinations: [],
248
+ };
140
249
  }
141
250
 
142
251
  // ---------------------------------------------------------------------------
@@ -426,18 +535,192 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
426
535
  return triggers.length > 0 ? triggers : undefined;
427
536
  }
428
537
 
538
+ // ---------------------------------------------------------------------------
539
+ // Backup YAML parsing / serialization
540
+ // ---------------------------------------------------------------------------
541
+
542
+ /**
543
+ * Parse the `backup:` section. Returns undefined if no section is present so
544
+ * callers can tell "user hasn't configured backups" apart from "user asked
545
+ * for the default (schedule: manual, empty destinations)."
546
+ *
547
+ * Shape:
548
+ * backup:
549
+ * schedule: daily
550
+ * retention:
551
+ * daily: 7
552
+ * weekly: 4
553
+ * monthly: 12
554
+ * yearly: null # or omit for unbounded; 0 disables the tier
555
+ * destinations:
556
+ * - kind: local
557
+ * path: ~/Library/Mobile Documents/com~apple~CloudDocs/parachute-backups
558
+ */
559
+ function parseBackup(yaml: string): BackupConfig | undefined {
560
+ const startMatch = yaml.match(/^backup:\s*$/m);
561
+ if (!startMatch) return undefined;
562
+
563
+ const startIdx = (startMatch.index ?? 0) + startMatch[0].length;
564
+ const lines = yaml.slice(startIdx).split("\n");
565
+
566
+ const backup: BackupConfig = defaultBackupConfig();
567
+ let section: "top" | "retention" | "destinations" = "top";
568
+ let currentDest: Partial<BackupDestination> & { kind?: string } = {};
569
+ let hasDest = false;
570
+ // Track whether the user supplied a retention block at all. If they did,
571
+ // we start from a clean slate (all tiers default 0, yearly null) so that
572
+ // an explicit partial policy overrides defaults rather than merging with
573
+ // them — predictable semantics beat magical merging.
574
+ let retentionSeen = false;
575
+
576
+ const pushDest = () => {
577
+ if (!hasDest) return;
578
+ // For the MVP we only ship `local`. Unknown/malformed destination kinds
579
+ // are skipped rather than rejected: a forward-rolled config authored by
580
+ // a newer CLI mustn't break backup for the features this CLI does
581
+ // understand. The backup command itself warns about skipped kinds.
582
+ if (currentDest.kind === "local" && typeof currentDest.path === "string") {
583
+ backup.destinations.push({ kind: "local", path: currentDest.path });
584
+ }
585
+ currentDest = {};
586
+ hasDest = false;
587
+ };
588
+
589
+ for (const line of lines) {
590
+ // Stop at next top-level key.
591
+ if (line.match(/^\S/) && line.trim().length > 0) break;
592
+ if (line.trim().length === 0) continue;
593
+
594
+ const trimmed = line.trim();
595
+
596
+ // A 2-space-indented line starting a new sub-section closes the previous
597
+ // section. The only 2-space keys under `backup:` today are `schedule`,
598
+ // `retention`, and `destinations`, so we key off indent depth here.
599
+ const indent = line.match(/^ */)?.[0].length ?? 0;
600
+
601
+ if (indent === 2) {
602
+ if (/^retention:\s*$/.test(trimmed)) {
603
+ section = "retention";
604
+ retentionSeen = true;
605
+ // Zero-out tiers so partially specified blocks don't silently merge
606
+ // with defaults in surprising ways.
607
+ backup.retention = { daily: 0, weekly: 0, monthly: 0, yearly: 0 };
608
+ continue;
609
+ }
610
+ if (/^destinations:\s*$/.test(trimmed)) {
611
+ pushDest();
612
+ section = "destinations";
613
+ continue;
614
+ }
615
+ // Any other 2-space top field terminates the current sub-section.
616
+ if (section !== "top") {
617
+ pushDest();
618
+ section = "top";
619
+ }
620
+ }
621
+
622
+ if (section === "top" && indent === 2) {
623
+ const schedMatch = trimmed.match(/^schedule:\s*(\S+)/);
624
+ if (schedMatch) {
625
+ const v = schedMatch[1];
626
+ if (v === "hourly" || v === "daily" || v === "weekly" || v === "manual") {
627
+ backup.schedule = v;
628
+ }
629
+ continue;
630
+ }
631
+ }
632
+
633
+ if (section === "retention" && indent === 4) {
634
+ const tierMatch = trimmed.match(/^(daily|weekly|monthly|yearly):\s*(\S+)/);
635
+ if (tierMatch) {
636
+ const tier = tierMatch[1] as keyof RetentionPolicy;
637
+ const raw = tierMatch[2].trim();
638
+ // "null" / "~" / "unbounded" all mean "keep every year" for the
639
+ // yearly tier. For the other tiers they'd be meaningless; we
640
+ // silently treat them as disabled (0) rather than erroring.
641
+ if (raw === "null" || raw === "~" || raw === "unbounded") {
642
+ if (tier === "yearly") backup.retention.yearly = null;
643
+ // Other tiers stay at 0.
644
+ continue;
645
+ }
646
+ const n = parseInt(raw, 10);
647
+ if (Number.isFinite(n) && n >= 0) {
648
+ backup.retention[tier] = n as never;
649
+ }
650
+ continue;
651
+ }
652
+ }
653
+
654
+ if (section === "destinations") {
655
+ // Start of a new list item: "- kind: local" or just "- kind:"
656
+ const itemMatch = trimmed.match(/^-\s+(\w+):\s*(.*)$/);
657
+ if (itemMatch) {
658
+ pushDest();
659
+ hasDest = true;
660
+ currentDest = {};
661
+ (currentDest as Record<string, string>)[itemMatch[1]] = itemMatch[2].trim();
662
+ continue;
663
+ }
664
+ // Continuation line inside the current list item.
665
+ const fieldMatch = trimmed.match(/^(\w+):\s*(.*)$/);
666
+ if (fieldMatch && hasDest) {
667
+ (currentDest as Record<string, string>)[fieldMatch[1]] = fieldMatch[2].trim();
668
+ continue;
669
+ }
670
+ }
671
+ }
672
+ pushDest();
673
+
674
+ // If the user left retention out entirely, fall back to the shipped default.
675
+ if (!retentionSeen) {
676
+ backup.retention = defaultRetentionPolicy();
677
+ }
678
+
679
+ return backup;
680
+ }
681
+
682
+ function serializeBackup(backup: BackupConfig): string[] {
683
+ const lines: string[] = [];
684
+ lines.push("backup:");
685
+ lines.push(` schedule: ${backup.schedule}`);
686
+ lines.push(" retention:");
687
+ lines.push(` daily: ${backup.retention.daily}`);
688
+ lines.push(` weekly: ${backup.retention.weekly}`);
689
+ lines.push(` monthly: ${backup.retention.monthly}`);
690
+ // `null` is serialized as the YAML literal `null` so round-trips preserve
691
+ // "unbounded." Numbers render as-is, including 0 (disabled).
692
+ lines.push(` yearly: ${backup.retention.yearly === null ? "null" : backup.retention.yearly}`);
693
+ if (backup.destinations.length > 0) {
694
+ lines.push(" destinations:");
695
+ for (const dest of backup.destinations) {
696
+ lines.push(` - kind: ${dest.kind}`);
697
+ // Paths may contain ~, spaces, and `.` — the whole line being on its
698
+ // own key line means we don't need to quote unless the value begins
699
+ // with a YAML-special character. Quoting defensively keeps the
700
+ // hand-rolled parser happy under future path-with-colons pressure.
701
+ if ("path" in dest) {
702
+ const needsQuote = /[:#]/.test(dest.path);
703
+ lines.push(` path: ${needsQuote ? `"${dest.path}"` : dest.path}`);
704
+ }
705
+ }
706
+ } else {
707
+ lines.push(" destinations: []");
708
+ }
709
+ return lines;
710
+ }
711
+
429
712
  // ---------------------------------------------------------------------------
430
713
  // Directory management
431
714
  // ---------------------------------------------------------------------------
432
715
 
433
716
  export async function ensureConfigDir(): Promise<void> {
434
- await mkdir(CONFIG_DIR, { recursive: true });
435
- await mkdir(VAULTS_DIR, { recursive: true });
717
+ await mkdir(configDirPath(), { recursive: true });
718
+ await mkdir(vaultsDirPath(), { recursive: true });
436
719
  }
437
720
 
438
721
  export function ensureConfigDirSync(): void {
439
- mkdirSync(CONFIG_DIR, { recursive: true });
440
- mkdirSync(VAULTS_DIR, { recursive: true });
722
+ mkdirSync(configDirPath(), { recursive: true });
723
+ mkdirSync(vaultsDirPath(), { recursive: true });
441
724
  }
442
725
 
443
726
  // ---------------------------------------------------------------------------
@@ -446,18 +729,23 @@ export function ensureConfigDirSync(): void {
446
729
 
447
730
  export function readGlobalConfig(): GlobalConfig {
448
731
  try {
449
- if (existsSync(GLOBAL_CONFIG_PATH)) {
450
- const yaml = readFileSync(GLOBAL_CONFIG_PATH, "utf-8");
732
+ const gcPath = globalConfigPath();
733
+ if (existsSync(gcPath)) {
734
+ const yaml = readFileSync(gcPath, "utf-8");
451
735
  const portMatch = yaml.match(/^port:\s*(\d+)/m);
452
736
  const defaultVaultMatch = yaml.match(/^default_vault:\s*(\S+)/m);
453
737
  const passwordHashMatch = yaml.match(/^owner_password_hash:\s*"([^"]+)"/m);
454
738
  const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
739
+ const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
455
740
  const config: GlobalConfig = {
456
741
  port: portMatch ? parseInt(portMatch[1], 10) : DEFAULT_PORT,
457
742
  default_vault: defaultVaultMatch?.[1],
458
743
  owner_password_hash: passwordHashMatch?.[1],
459
744
  totp_secret: totpSecretMatch?.[1],
460
745
  };
746
+ if (discoveryMatch) {
747
+ config.discovery = discoveryMatch[1] as "enabled" | "disabled";
748
+ }
461
749
 
462
750
  // Parse backup_codes: a YAML list of quoted bcrypt hashes under
463
751
  // backup_codes:
@@ -501,6 +789,9 @@ export function readGlobalConfig(): GlobalConfig {
501
789
  // Parse triggers
502
790
  config.triggers = parseTriggers(yaml);
503
791
 
792
+ // Parse backup section
793
+ config.backup = parseBackup(yaml);
794
+
504
795
  return config;
505
796
  }
506
797
  } catch {}
@@ -511,6 +802,7 @@ export function writeGlobalConfig(config: GlobalConfig): void {
511
802
  ensureConfigDirSync();
512
803
  const lines = [`port: ${config.port}`];
513
804
  if (config.default_vault) lines.push(`default_vault: ${config.default_vault}`);
805
+ if (config.discovery) lines.push(`discovery: ${config.discovery}`);
514
806
  if (config.owner_password_hash) {
515
807
  lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
516
808
  }
@@ -568,12 +860,16 @@ export function writeGlobalConfig(config: GlobalConfig): void {
568
860
  }
569
861
  }
570
862
 
863
+ if (config.backup) {
864
+ lines.push(...serializeBackup(config.backup));
865
+ }
866
+
571
867
  // 0600 — owner read/write only. This file may contain the bcrypt password
572
868
  // hash and plaintext TOTP secret; it must not be world- or group-readable.
573
- writeFileSync(GLOBAL_CONFIG_PATH, lines.join("\n") + "\n", { mode: 0o600 });
869
+ writeFileSync(globalConfigPath(), lines.join("\n") + "\n", { mode: 0o600 });
574
870
  // writeFileSync's `mode` only applies on file creation, so chmod an existing
575
871
  // file explicitly in case it was written by an older version at 0644.
576
- try { chmodSync(GLOBAL_CONFIG_PATH, 0o600); } catch {}
872
+ try { chmodSync(globalConfigPath(), 0o600); } catch {}
577
873
  }
578
874
 
579
875
  // ---------------------------------------------------------------------------
@@ -629,8 +925,9 @@ export function generateApiKey(): { fullKey: string; keyId: string } {
629
925
  export function readEnvFile(): Record<string, string> {
630
926
  const env: Record<string, string> = {};
631
927
  try {
632
- if (!existsSync(ENV_PATH)) return env;
633
- const content = readFileSync(ENV_PATH, "utf-8");
928
+ const p = envFilePath();
929
+ if (!existsSync(p)) return env;
930
+ const content = readFileSync(p, "utf-8");
634
931
  for (const line of content.split("\n")) {
635
932
  const trimmed = line.trim();
636
933
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -665,7 +962,7 @@ export function writeEnvFile(env: Record<string, string>): void {
665
962
  lines.push(`${key}=${val}`);
666
963
  }
667
964
  }
668
- writeFileSync(ENV_PATH, lines.join("\n") + "\n");
965
+ writeFileSync(envFilePath(), lines.join("\n") + "\n");
669
966
  }
670
967
 
671
968
  /**
@@ -704,8 +1001,9 @@ export function loadEnvFile(): void {
704
1001
 
705
1002
  export function listVaults(): string[] {
706
1003
  try {
707
- if (!existsSync(VAULTS_DIR)) return [];
708
- const entries = Bun.spawnSync(["ls", VAULTS_DIR]).stdout.toString().trim();
1004
+ const dir = vaultsDirPath();
1005
+ if (!existsSync(dir)) return [];
1006
+ const entries = Bun.spawnSync(["ls", dir]).stdout.toString().trim();
709
1007
  if (!entries) return [];
710
1008
  return entries.split("\n").filter((name) => {
711
1009
  return existsSync(vaultConfigPath(name));
@@ -714,3 +1012,35 @@ export function listVaults(): string[] {
714
1012
  return [];
715
1013
  }
716
1014
  }
1015
+
1016
+ /**
1017
+ * Resolve the vault that unscoped routes (`/mcp`, `/api/*`, `/oauth/*`,
1018
+ * `/view/*`) should target.
1019
+ *
1020
+ * Resolution order:
1021
+ * 1. If `default_vault` is set in config.yaml AND that vault exists → use it.
1022
+ * 2. Else if exactly one vault exists → use that vault regardless of its name.
1023
+ * This is the "single-vault auto-default": if you only have `journal`,
1024
+ * `/mcp` transparently targets `journal` without requiring you to visit
1025
+ * `/vaults/journal/mcp`.
1026
+ * 3. Otherwise → return `null` (multi-vault deployment with no/bad default;
1027
+ * the caller should surface an explicit error rather than guess).
1028
+ *
1029
+ * Notes:
1030
+ * - If `default_vault` points to a deleted vault, step 2 still kicks in so
1031
+ * operators aren't stranded after `vault remove`.
1032
+ * - The name "default" has no special meaning here; it's just whatever
1033
+ * `vault init` happens to create on first run. A single vault named
1034
+ * "journal" behaves identically.
1035
+ */
1036
+ export function resolveDefaultVault(): string | null {
1037
+ const globalConfig = readGlobalConfig();
1038
+ const vaults = listVaults();
1039
+ if (globalConfig.default_vault && vaults.includes(globalConfig.default_vault)) {
1040
+ return globalConfig.default_vault;
1041
+ }
1042
+ if (vaults.length === 1) {
1043
+ return vaults[0];
1044
+ }
1045
+ return null;
1046
+ }