@openparachute/vault 0.1.0 → 0.2.1

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 +87 -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 +66 -13
  36. package/src/published.test.ts +21 -21
  37. package/src/routes.ts +152 -70
  38. package/src/routing.test.ts +478 -0
  39. package/src/routing.ts +413 -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
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Auth invariants — routing coherence between unscoped and scoped paths.
3
+ *
4
+ * See Fix 2 in the OAuth-to-Daily launch work: a vault token minted by one
5
+ * path (unscoped `/oauth/token` or scoped `/vaults/X/oauth/token`) must
6
+ * authenticate identically at every endpoint that addresses the same vault,
7
+ * regardless of whether the URL uses `/api/*` (default-vault shortcut) or
8
+ * `/vaults/X/api/*` (explicit). Same for `/mcp` vs `/vaults/X/mcp`.
9
+ *
10
+ * These tests isolate `PARACHUTE_HOME` so they don't touch the user's real
11
+ * config. Each test builds 1-2 vaults from scratch.
12
+ */
13
+
14
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
15
+ import { mkdirSync, rmSync, existsSync } from "fs";
16
+ import { join } from "path";
17
+ import { tmpdir } from "os";
18
+ import {
19
+ writeVaultConfig,
20
+ writeGlobalConfig,
21
+ readVaultConfig,
22
+ readGlobalConfig,
23
+ generateApiKey,
24
+ hashKey,
25
+ } from "./config.ts";
26
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
27
+ import { generateToken, createToken } from "./token-store.ts";
28
+ import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
29
+ import { handleRegister, handleAuthorizePost, handleToken } from "./oauth.ts";
30
+ import crypto from "node:crypto";
31
+
32
+ let tmpHome: string;
33
+ let prevHome: string | undefined;
34
+
35
+ beforeEach(() => {
36
+ tmpHome = join(tmpdir(), `vault-auth-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
37
+ mkdirSync(join(tmpHome, "vaults"), { recursive: true });
38
+ prevHome = process.env.PARACHUTE_HOME;
39
+ process.env.PARACHUTE_HOME = tmpHome;
40
+ clearVaultStoreCache();
41
+ });
42
+
43
+ afterEach(() => {
44
+ clearVaultStoreCache();
45
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
46
+ else process.env.PARACHUTE_HOME = prevHome;
47
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
48
+ });
49
+
50
+ function seedVault(name: string, opts: { isDefault?: boolean } = {}): void {
51
+ const { fullKey, keyId } = generateApiKey();
52
+ writeVaultConfig({
53
+ name,
54
+ api_keys: [
55
+ {
56
+ id: keyId,
57
+ label: "bootstrap",
58
+ scope: "write",
59
+ key_hash: hashKey(fullKey),
60
+ created_at: new Date().toISOString(),
61
+ },
62
+ ],
63
+ created_at: new Date().toISOString(),
64
+ });
65
+ if (opts.isDefault) {
66
+ const gc = readGlobalConfig();
67
+ gc.default_vault = name;
68
+ writeGlobalConfig(gc);
69
+ }
70
+ }
71
+
72
+ /** Mint a fresh OAuth-style token directly into the named vault's DB. */
73
+ function mintTokenInVault(vaultName: string): string {
74
+ const store = getVaultStore(vaultName);
75
+ const { fullToken } = generateToken();
76
+ createToken(store.db, fullToken, { label: "test", permission: "full" });
77
+ return fullToken;
78
+ }
79
+
80
+ function bearer(token: string): Request {
81
+ return new Request("https://vault.test/x", {
82
+ headers: { Authorization: `Bearer ${token}` },
83
+ });
84
+ }
85
+
86
+ describe("auth — default-vault routing coherence", () => {
87
+ test("token minted in default vault authenticates at both unscoped and scoped paths", () => {
88
+ seedVault("default", { isDefault: true });
89
+ const token = mintTokenInVault("default");
90
+ const defaultConfig = readVaultConfig("default")!;
91
+ const defaultStore = getVaultStore("default");
92
+
93
+ // Unscoped `/api/*` flow: server resolves default vault, calls
94
+ // authenticateVaultRequest with default's config + DB. Token must resolve.
95
+ const unscoped = authenticateVaultRequest(bearer(token), defaultConfig, defaultStore.db);
96
+ expect("error" in unscoped).toBe(false);
97
+ if (!("error" in unscoped)) expect(unscoped.permission).toBe("full");
98
+
99
+ // Scoped `/vaults/default/api/*` flow: same defaultConfig + DB. Must also
100
+ // resolve — this is the invariant Aaron's complaint hinges on.
101
+ const scoped = authenticateVaultRequest(bearer(token), defaultConfig, defaultStore.db);
102
+ expect("error" in scoped).toBe(false);
103
+
104
+ // Unified `/mcp` flow: authenticateGlobalRequest scans every vault's DB.
105
+ // Since the token is in default's DB, this must also resolve.
106
+ const global = authenticateGlobalRequest(bearer(token));
107
+ expect("error" in global).toBe(false);
108
+ });
109
+
110
+ // HTTP-level routing stand-in. Mirrors server.ts's vault-resolution step:
111
+ // unscoped `/api/*` resolves to the default vault; scoped `/vaults/X/api/*`
112
+ // extracts the name from the URL. After resolution both paths funnel into
113
+ // `authenticateVaultRequest` with that vault's config + DB. The earlier
114
+ // version of this test called `authenticateVaultRequest` twice with the same
115
+ // args and labelled the calls "scoped"/"unscoped" — tautological, because
116
+ // routing was never exercised. This variant drives the resolver from the
117
+ // URL, so the routing step is the thing under test.
118
+ function dispatchAuthFromPath(path: string, req: Request): {
119
+ status: number;
120
+ permission?: string;
121
+ } {
122
+ let vaultName: string;
123
+ if (path.startsWith("/vaults/")) {
124
+ vaultName = path.split("/")[2];
125
+ } else if (path.startsWith("/api/")) {
126
+ const gc = readGlobalConfig();
127
+ vaultName = gc.default_vault ?? "default";
128
+ } else {
129
+ return { status: 404 };
130
+ }
131
+ const vaultConfig = readVaultConfig(vaultName);
132
+ if (!vaultConfig) return { status: 404 };
133
+ const store = getVaultStore(vaultName);
134
+ const res = authenticateVaultRequest(req, vaultConfig, store.db);
135
+ if ("error" in res) return { status: res.error.status };
136
+ return { status: 200, permission: res.permission };
137
+ }
138
+
139
+ test("routing coherence: unscoped and scoped /api/health accept a default-vault token identically", () => {
140
+ seedVault("default", { isDefault: true });
141
+ const token = mintTokenInVault("default");
142
+
143
+ // (a) unscoped /api/health with default-vault token
144
+ const unscoped = dispatchAuthFromPath("/api/health", bearer(token));
145
+ expect(unscoped.status).toBe(200);
146
+
147
+ // (b) scoped /vaults/default/api/health with the same token
148
+ const scoped = dispatchAuthFromPath("/vaults/default/api/health", bearer(token));
149
+ expect(scoped.status).toBe(200);
150
+
151
+ // Both paths resolve the same vault → same permission level comes back.
152
+ expect(unscoped.permission).toBe(scoped.permission);
153
+ });
154
+
155
+ test("routing coherence: scoped /vaults/X/api/health rejects a token issued for vault Y", () => {
156
+ // The privilege-escalation barrier: a valid token for vault A must not
157
+ // authenticate at vault B's scoped endpoint, even though the URL is
158
+ // well-formed and the token itself is valid for *some* vault.
159
+ seedVault("default", { isDefault: true });
160
+ seedVault("work");
161
+ const workToken = mintTokenInVault("work");
162
+
163
+ const crossVault = dispatchAuthFromPath("/vaults/default/api/health", bearer(workToken));
164
+ expect(crossVault.status).toBe(401);
165
+ });
166
+ });
167
+
168
+ describe("auth — named-vault routing coherence", () => {
169
+ test("token minted in a non-default vault authenticates via scoped and global paths", () => {
170
+ seedVault("default", { isDefault: true });
171
+ seedVault("work");
172
+ const workToken = mintTokenInVault("work");
173
+ const workConfig = readVaultConfig("work")!;
174
+ const workStore = getVaultStore("work");
175
+
176
+ // Scoped `/vaults/work/api/*` — must resolve against work's DB.
177
+ const scoped = authenticateVaultRequest(bearer(workToken), workConfig, workStore.db);
178
+ expect("error" in scoped).toBe(false);
179
+
180
+ // Unified `/mcp` — global auth scans all vaults, must find it.
181
+ const global = authenticateGlobalRequest(bearer(workToken));
182
+ expect("error" in global).toBe(false);
183
+ });
184
+
185
+ test("a work-vault token does NOT authenticate against the default vault's /api/*", () => {
186
+ // This is the correct isolation behavior: a token scoped to vault X has no
187
+ // business being accepted at endpoints that address vault Y. If this ever
188
+ // regressed, we'd have a privilege-escalation bug (read a different vault
189
+ // by just sending a valid token at the wrong URL).
190
+ seedVault("default", { isDefault: true });
191
+ seedVault("work");
192
+ const workToken = mintTokenInVault("work");
193
+ const defaultConfig = readVaultConfig("default")!;
194
+ const defaultStore = getVaultStore("default");
195
+
196
+ const res = authenticateVaultRequest(bearer(workToken), defaultConfig, defaultStore.db);
197
+ expect("error" in res).toBe(true);
198
+ });
199
+ });
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // End-to-end: OAuth flow → resulting token authenticates at expected paths
203
+ // ---------------------------------------------------------------------------
204
+
205
+ describe("OAuth-minted tokens — cross-endpoint coherence", () => {
206
+ // These tests drive the OAuth handlers directly (no HTTP), then take the
207
+ // resulting access_token and verify it resolves at every endpoint that
208
+ // addresses its issuing vault. This is the key coherence invariant for
209
+ // Aaron's launch complaint.
210
+
211
+ async function runOAuthFlow(vaultName: string): Promise<string> {
212
+ const store = getVaultStore(vaultName);
213
+ const db = store.db;
214
+
215
+ // Seed an owner token so consent passes in legacy-token mode.
216
+ const { fullToken: ownerToken } = generateToken();
217
+ createToken(db, ownerToken, { label: "owner", permission: "full" });
218
+
219
+ // 1. Register client
220
+ const regRes = await handleRegister(
221
+ new Request("https://vault.test/oauth/register", {
222
+ method: "POST",
223
+ headers: { "Content-Type": "application/json" },
224
+ body: JSON.stringify({
225
+ client_name: "Daily",
226
+ redirect_uris: ["parachute://oauth/callback"],
227
+ }),
228
+ }),
229
+ db,
230
+ );
231
+ const { client_id } = (await regRes.json()) as { client_id: string };
232
+
233
+ // 2. PKCE + authorize
234
+ const codeVerifier = crypto.randomBytes(32).toString("base64url");
235
+ const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
236
+ const authRes = await handleAuthorizePost(
237
+ new Request("https://vault.test/oauth/authorize", {
238
+ method: "POST",
239
+ body: new URLSearchParams({
240
+ action: "authorize",
241
+ client_id,
242
+ redirect_uri: "parachute://oauth/callback",
243
+ code_challenge: codeChallenge,
244
+ code_challenge_method: "S256",
245
+ scope: "full",
246
+ owner_token: ownerToken,
247
+ }),
248
+ }),
249
+ db,
250
+ { vaultName },
251
+ );
252
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
253
+
254
+ // 3. Token exchange
255
+ const tokRes = await handleToken(
256
+ new Request("https://vault.test/oauth/token", {
257
+ method: "POST",
258
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
259
+ body: new URLSearchParams({
260
+ grant_type: "authorization_code",
261
+ code,
262
+ code_verifier: codeVerifier,
263
+ client_id,
264
+ redirect_uri: "parachute://oauth/callback",
265
+ }).toString(),
266
+ }),
267
+ db,
268
+ vaultName,
269
+ );
270
+ const tokBody = (await tokRes.json()) as { access_token: string; vault: string };
271
+ expect(tokBody.vault).toBe(vaultName);
272
+ return tokBody.access_token;
273
+ }
274
+
275
+ test("default-vault OAuth: token works at /api/*, /mcp, /vaults/default/api/*, /vaults/default/mcp", async () => {
276
+ seedVault("default", { isDefault: true });
277
+ const token = await runOAuthFlow("default");
278
+ const cfg = readVaultConfig("default")!;
279
+ const store = getVaultStore("default");
280
+
281
+ // `/api/*` — unscoped path resolves default vault, calls authenticateVaultRequest.
282
+ const apiUnscoped = authenticateVaultRequest(bearer(token), cfg, store.db);
283
+ expect("error" in apiUnscoped).toBe(false);
284
+
285
+ // `/vaults/default/api/*` — scoped path resolves same default, same DB, same call.
286
+ const apiScoped = authenticateVaultRequest(bearer(token), cfg, store.db);
287
+ expect("error" in apiScoped).toBe(false);
288
+
289
+ // `/mcp` — unified endpoint uses authenticateGlobalRequest which scans all DBs.
290
+ const mcpUnscoped = authenticateGlobalRequest(bearer(token));
291
+ expect("error" in mcpUnscoped).toBe(false);
292
+
293
+ // `/vaults/default/mcp` — scoped MCP uses authenticateVaultRequest (same as api).
294
+ const mcpScoped = authenticateVaultRequest(bearer(token), cfg, store.db);
295
+ expect("error" in mcpScoped).toBe(false);
296
+ });
297
+
298
+ test("named-vault OAuth: token works at /vaults/X/api/*, /vaults/X/mcp, /mcp", async () => {
299
+ seedVault("default", { isDefault: true });
300
+ seedVault("work");
301
+ const token = await runOAuthFlow("work");
302
+ const workCfg = readVaultConfig("work")!;
303
+ const workStore = getVaultStore("work");
304
+
305
+ // Scoped endpoints addressing vault work — must resolve.
306
+ const apiScoped = authenticateVaultRequest(bearer(token), workCfg, workStore.db);
307
+ expect("error" in apiScoped).toBe(false);
308
+
309
+ // Unified /mcp scans all vaults, must find the token in work's DB.
310
+ const mcpUnified = authenticateGlobalRequest(bearer(token));
311
+ expect("error" in mcpUnified).toBe(false);
312
+
313
+ // Defensive: the same token is NOT usable against the default vault's /api/*.
314
+ const defaultCfg = readVaultConfig("default")!;
315
+ const defaultStore = getVaultStore("default");
316
+ const crossCheck = authenticateVaultRequest(bearer(token), defaultCfg, defaultStore.db);
317
+ expect("error" in crossCheck).toBe(true);
318
+ });
319
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Plist shape tests for the scheduled-backup launchd agent.
3
+ *
4
+ * These mirror `launchd.test.ts` in spirit — we don't actually register
5
+ * the plist with launchctl (that would mutate the developer's machine).
6
+ * We only verify:
7
+ *
8
+ * 1. The plist contains the bun path, the cli path, and the right
9
+ * `vault backup` ProgramArguments.
10
+ * 2. Each schedule value produces the right scheduling key
11
+ * (StartInterval vs StartCalendarInterval with Hour + Weekday).
12
+ * 3. The XML is superficially well-formed (opens and closes matching tags).
13
+ *
14
+ * The actual `installBackupAgent` / `uninstallBackupAgent` flow is tested
15
+ * indirectly via `cmdBackupSchedule` in higher-level CLI integration tests.
16
+ */
17
+
18
+ import { describe, test, expect } from "bun:test";
19
+ import { generateBackupPlist, BACKUP_LABEL } from "./backup-launchd.ts";
20
+
21
+ describe("generateBackupPlist", () => {
22
+ const basic = {
23
+ bunPath: "/Users/alice/.bun/bin/bun",
24
+ cliPath: "/Users/alice/repo/parachute-vault/src/cli.ts",
25
+ };
26
+
27
+ test("hourly → StartInterval 3600", () => {
28
+ const plist = generateBackupPlist({ ...basic, schedule: "hourly" });
29
+ expect(plist).toContain("<key>StartInterval</key>");
30
+ expect(plist).toContain("<integer>3600</integer>");
31
+ expect(plist).not.toContain("<key>StartCalendarInterval</key>");
32
+ });
33
+
34
+ test("daily → StartCalendarInterval with Hour=3 (no Weekday)", () => {
35
+ const plist = generateBackupPlist({ ...basic, schedule: "daily" });
36
+ expect(plist).toContain("<key>StartCalendarInterval</key>");
37
+ expect(plist).toContain("<key>Hour</key>");
38
+ expect(plist).toContain("<integer>3</integer>");
39
+ expect(plist).not.toContain("<key>Weekday</key>");
40
+ });
41
+
42
+ test("weekly → StartCalendarInterval with Hour=3, Weekday=0 (Sunday)", () => {
43
+ const plist = generateBackupPlist({ ...basic, schedule: "weekly" });
44
+ expect(plist).toContain("<key>StartCalendarInterval</key>");
45
+ expect(plist).toContain("<key>Hour</key>");
46
+ expect(plist).toContain("<key>Weekday</key>");
47
+ // The plist has both Hour=3 AND Weekday=0 — verify both by locating
48
+ // their surrounding keys.
49
+ const weekdayMatch = plist.match(/<key>Weekday<\/key>\s*<integer>(\d+)<\/integer>/);
50
+ expect(weekdayMatch).not.toBeNull();
51
+ expect(weekdayMatch![1]).toBe("0");
52
+ });
53
+
54
+ test("ProgramArguments runs `bun <cli.ts> vault backup`", () => {
55
+ const plist = generateBackupPlist({ ...basic, schedule: "daily" });
56
+ expect(plist).toContain(`<string>${basic.bunPath}</string>`);
57
+ expect(plist).toContain(`<string>${basic.cliPath}</string>`);
58
+ expect(plist).toContain("<string>vault</string>");
59
+ expect(plist).toContain("<string>backup</string>");
60
+ });
61
+
62
+ test("uses the backup-specific label, not the daemon label", () => {
63
+ const plist = generateBackupPlist({ ...basic, schedule: "daily" });
64
+ expect(plist).toContain(`<string>${BACKUP_LABEL}</string>`);
65
+ // Different from the daemon label. If somebody ever unified them, this
66
+ // test fires so the change is intentional.
67
+ expect(BACKUP_LABEL).toBe("computer.parachute.vault.backup");
68
+ });
69
+
70
+ test("RunAtLoad is false — we do not want a backup to fire on every login", () => {
71
+ // Opposite of the daemon (which has RunAtLoad=true to keep the server
72
+ // running at login). For the backup agent, running at login is user-
73
+ // hostile: it delays login and churns iCloud on every cold boot.
74
+ const plist = generateBackupPlist({ ...basic, schedule: "daily" });
75
+ expect(plist).toContain("<key>RunAtLoad</key>");
76
+ expect(plist).toMatch(/<key>RunAtLoad<\/key>\s*<false\/>/);
77
+ });
78
+
79
+ test("XML opens and closes plist dict", () => {
80
+ // Superficial well-formedness — catches stray typos in the template.
81
+ // A full XML validator would be overkill; matching open/close counts
82
+ // is sufficient for the single hand-rolled plist.
83
+ const plist = generateBackupPlist({ ...basic, schedule: "daily" });
84
+ expect(plist).toMatch(/<plist version="1\.0">/);
85
+ expect(plist).toMatch(/<\/plist>\s*$/);
86
+ const opens = (plist.match(/<dict>/g) ?? []).length;
87
+ const closes = (plist.match(/<\/dict>/g) ?? []).length;
88
+ expect(opens).toBe(closes);
89
+ });
90
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * macOS launchd agent for the scheduled backup job.
3
+ *
4
+ * Parallels `launchd.ts` (which manages the vault daemon). Differences:
5
+ *
6
+ * - StartInterval / StartCalendarInterval instead of KeepAlive — this is
7
+ * a one-shot-on-a-schedule job, not a long-running daemon.
8
+ * - Separate label + plist path so the two agents don't collide in
9
+ * launchctl.
10
+ * - The program executes `bun <cli.ts> vault backup` against the same
11
+ * server-path pointer the daemon uses, which keeps "which bun, which
12
+ * repo" in sync across both agents automatically.
13
+ *
14
+ * Linux systemd-timer variant is deliberately out-of-scope for the MVP; see
15
+ * the scoping note in the PR description.
16
+ */
17
+
18
+ import { homedir } from "os";
19
+ import { join } from "path";
20
+ import { writeFile, unlink } from "fs/promises";
21
+ import { existsSync } from "fs";
22
+ import { $ } from "bun";
23
+ import {
24
+ CONFIG_DIR,
25
+ LOG_PATH,
26
+ ERR_PATH,
27
+ } from "./config.ts";
28
+ import type { BackupSchedule } from "./config.ts";
29
+ import { resolveServerPath } from "./daemon.ts";
30
+
31
+ export const BACKUP_LABEL = "computer.parachute.vault.backup";
32
+ export const BACKUP_PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${BACKUP_LABEL}.plist`);
33
+
34
+ /**
35
+ * Resolve the CLI path the backup job should invoke. Sibling-to-server.ts
36
+ * — we reuse `resolveServerPath()`'s dirname-of-module approach so a move
37
+ * of the repo updates both the daemon and the backup agent on the next
38
+ * `parachute vault backup --schedule <f>` run.
39
+ */
40
+ export function resolveCliPath(): string {
41
+ const serverPath = resolveServerPath(); // <repo>/src/server.ts
42
+ return serverPath.replace(/server\.ts$/, "cli.ts");
43
+ }
44
+
45
+ /**
46
+ * Build plist XML for a given schedule. Pure string builder for test-ability
47
+ * — `backup-launchd.test.ts` locks the schedule → plist shape contract.
48
+ */
49
+ export function generateBackupPlist(opts: {
50
+ schedule: Exclude<BackupSchedule, "manual">;
51
+ bunPath: string;
52
+ cliPath: string;
53
+ label?: string;
54
+ logPath?: string;
55
+ errPath?: string;
56
+ workingDir?: string;
57
+ }): string {
58
+ const label = opts.label ?? BACKUP_LABEL;
59
+ const logPath = opts.logPath ?? LOG_PATH;
60
+ const errPath = opts.errPath ?? ERR_PATH;
61
+ const workingDir = opts.workingDir ?? CONFIG_DIR;
62
+
63
+ const intervalXml = scheduleToPlistXml(opts.schedule);
64
+
65
+ return `<?xml version="1.0" encoding="UTF-8"?>
66
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
67
+ <plist version="1.0">
68
+ <dict>
69
+ <key>Label</key>
70
+ <string>${label}</string>
71
+ <key>ProgramArguments</key>
72
+ <array>
73
+ <string>${opts.bunPath}</string>
74
+ <string>${opts.cliPath}</string>
75
+ <string>vault</string>
76
+ <string>backup</string>
77
+ </array>
78
+ ${intervalXml}
79
+ <key>RunAtLoad</key>
80
+ <false/>
81
+ <key>StandardOutPath</key>
82
+ <string>${logPath}</string>
83
+ <key>StandardErrorPath</key>
84
+ <string>${errPath}</string>
85
+ <key>WorkingDirectory</key>
86
+ <string>${workingDir}</string>
87
+ </dict>
88
+ </plist>`;
89
+ }
90
+
91
+ /**
92
+ * Map a schedule string to the appropriate launchd key.
93
+ *
94
+ * hourly → StartInterval 3600 (seconds)
95
+ * daily → StartCalendarInterval at 03:00 local
96
+ * weekly → StartCalendarInterval at 03:00 Sunday
97
+ *
98
+ * Why 03:00: the classic "everybody's asleep, iCloud Drive isn't fighting
99
+ * the active user for bandwidth" slot. If the machine is asleep at that
100
+ * time, launchd fires the job on the next wake — so a laptop user who
101
+ * sleeps at midnight still gets their backup.
102
+ */
103
+ function scheduleToPlistXml(schedule: "hourly" | "daily" | "weekly"): string {
104
+ if (schedule === "hourly") {
105
+ return ` <key>StartInterval</key>
106
+ <integer>3600</integer>`;
107
+ }
108
+ // daily + weekly: StartCalendarInterval dict.
109
+ const hour = 3;
110
+ const minute = 0;
111
+ const lines: string[] = [];
112
+ lines.push(` <key>StartCalendarInterval</key>`);
113
+ lines.push(` <dict>`);
114
+ lines.push(` <key>Hour</key>`);
115
+ lines.push(` <integer>${hour}</integer>`);
116
+ lines.push(` <key>Minute</key>`);
117
+ lines.push(` <integer>${minute}</integer>`);
118
+ if (schedule === "weekly") {
119
+ // 0 = Sunday per Apple's docs.
120
+ lines.push(` <key>Weekday</key>`);
121
+ lines.push(` <integer>0</integer>`);
122
+ }
123
+ lines.push(` </dict>`);
124
+ return lines.join("\n");
125
+ }
126
+
127
+ /**
128
+ * Install (or re-install) the backup agent for the given schedule. Idempotent
129
+ * — same pattern as `installAgent()` in `launchd.ts`: unload first so a
130
+ * re-registration takes effect even if the prior plist is loaded.
131
+ *
132
+ * `schedule: "manual"` uninstalls the agent — no plist means no scheduled
133
+ * runs. This is what the spec asks for: `manual` is the off-switch.
134
+ */
135
+ export async function installBackupAgent(schedule: BackupSchedule): Promise<void> {
136
+ if (process.platform !== "darwin") {
137
+ throw new Error("launchd backup agent is only available on macOS. systemd timer variant is a follow-up PR.");
138
+ }
139
+
140
+ if (schedule === "manual") {
141
+ await uninstallBackupAgent();
142
+ return;
143
+ }
144
+
145
+ const bunPath = Bun.which("bun") || join(homedir(), ".bun", "bin", "bun");
146
+ const cliPath = resolveCliPath();
147
+ const plist = generateBackupPlist({ schedule, bunPath, cliPath });
148
+ await writeFile(BACKUP_PLIST_PATH, plist);
149
+
150
+ // Bounce in the same pattern as the daemon agent.
151
+ try { await $`launchctl unload ${BACKUP_PLIST_PATH}`.quiet(); } catch {}
152
+ try { await $`launchctl load ${BACKUP_PLIST_PATH}`.quiet(); } catch {}
153
+ }
154
+
155
+ export async function uninstallBackupAgent(): Promise<void> {
156
+ try { await $`launchctl unload ${BACKUP_PLIST_PATH}`.quiet(); } catch {}
157
+ try {
158
+ if (existsSync(BACKUP_PLIST_PATH)) await unlink(BACKUP_PLIST_PATH);
159
+ } catch {}
160
+ }
161
+
162
+ export async function isBackupAgentLoaded(): Promise<boolean> {
163
+ try {
164
+ const result = await $`launchctl list ${BACKUP_LABEL}`.quiet();
165
+ return result.exitCode === 0;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }