@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.
- package/CHANGELOG.md +87 -0
- package/CLAUDE.md +2 -2
- package/README.md +289 -44
- package/core/src/core.test.ts +802 -346
- package/core/src/expand.ts +140 -0
- package/core/src/hooks.test.ts +27 -27
- package/core/src/hooks.ts +1 -1
- package/core/src/mcp.ts +102 -39
- package/core/src/notes.ts +82 -4
- package/core/src/obsidian.test.ts +11 -11
- package/core/src/paths.test.ts +46 -46
- package/core/src/schema.ts +18 -2
- package/core/src/store.ts +51 -51
- package/core/src/types.ts +29 -29
- package/core/src/wikilinks.test.ts +61 -61
- package/docs/HTTP_API.md +4 -2
- package/package.json +1 -1
- package/src/auth.test.ts +319 -0
- package/src/backup-launchd.test.ts +90 -0
- package/src/backup-launchd.ts +169 -0
- package/src/backup.test.ts +715 -0
- package/src/backup.ts +699 -0
- package/src/cli.ts +923 -31
- package/src/config.test.ts +173 -0
- package/src/config.ts +345 -15
- package/src/daemon.ts +136 -0
- package/src/doctor.test.ts +356 -0
- package/src/health.test.ts +201 -0
- package/src/health.ts +115 -0
- package/src/launchd.test.ts +91 -0
- package/src/launchd.ts +37 -40
- package/src/mcp-http.ts +1 -1
- package/src/mcp-tools.ts +7 -9
- package/src/oauth.test.ts +289 -8
- package/src/oauth.ts +66 -13
- package/src/published.test.ts +21 -21
- package/src/routes.ts +152 -70
- package/src/routing.test.ts +478 -0
- package/src/routing.ts +413 -0
- package/src/server.ts +7 -278
- package/src/systemd.test.ts +15 -0
- package/src/systemd.ts +18 -11
- package/src/triggers.test.ts +7 -7
- package/src/triggers.ts +6 -6
- package/src/vault-store.ts +20 -3
- package/src/vault.test.ts +356 -262
- package/.claude/settings.local.json +0 -31
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -15
package/src/auth.test.ts
ADDED
|
@@ -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
|
+
}
|