@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/server.ts
CHANGED
|
@@ -6,21 +6,19 @@
|
|
|
6
6
|
* GET /health — health check
|
|
7
7
|
* * /mcp — unified MCP (all vaults, vault param)
|
|
8
8
|
* * /vaults/{name}/mcp — scoped MCP (single vault, no vault param)
|
|
9
|
-
* GET /vaults — list vaults
|
|
9
|
+
* GET /vaults — list vaults with metadata (authenticated)
|
|
10
|
+
* GET /vaults/list — list vault names (public; disable via config.discovery)
|
|
10
11
|
* * /vaults/{name}/api/... — per-vault REST API
|
|
12
|
+
*
|
|
13
|
+
* The request pipeline lives in ./routing.ts (exported for unit testing).
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey } from "./config.ts";
|
|
14
|
-
import { authenticateVaultRequest, authenticateGlobalRequest, isMethodAllowed, extractApiKey } from "./auth.ts";
|
|
15
|
-
import type { AuthResult } from "./auth.ts";
|
|
16
|
-
import type { VaultConfig } from "./config.ts";
|
|
17
17
|
import { migrateVaultKeys } from "./token-store.ts";
|
|
18
18
|
import { getVaultStore } from "./vault-store.ts";
|
|
19
|
-
import { handleUnifiedMcp, handleScopedMcp } from "./mcp-http.ts";
|
|
20
|
-
import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks, handleStorage, handleViewNote } from "./routes.ts";
|
|
21
19
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
22
20
|
import { registerTriggers } from "./triggers.ts";
|
|
23
|
-
import {
|
|
21
|
+
import { route } from "./routing.ts";
|
|
24
22
|
|
|
25
23
|
// Register webhook triggers from global config. Replaces the old hardcoded
|
|
26
24
|
// tts-hook and transcription-hook with config-driven webhooks.
|
|
@@ -76,11 +74,11 @@ for (const vaultName of listVaults()) {
|
|
|
76
74
|
const vaultConfig = readVaultConfig(vaultName);
|
|
77
75
|
if (vaultConfig?.tag_schemas && Object.keys(vaultConfig.tag_schemas).length > 0) {
|
|
78
76
|
const store = getVaultStore(vaultName);
|
|
79
|
-
const existingTags = new Set(store.listTagSchemas().map((s) => s.tag));
|
|
77
|
+
const existingTags = new Set((await store.listTagSchemas()).map((s) => s.tag));
|
|
80
78
|
let migrated = 0;
|
|
81
79
|
for (const [tag, schema] of Object.entries(vaultConfig.tag_schemas)) {
|
|
82
80
|
if (!existingTags.has(tag)) {
|
|
83
|
-
store.upsertTagSchema(tag, schema);
|
|
81
|
+
await store.upsertTagSchema(tag, schema);
|
|
84
82
|
migrated++;
|
|
85
83
|
}
|
|
86
84
|
}
|
|
@@ -179,272 +177,3 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
179
177
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
180
178
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
181
179
|
|
|
182
|
-
/**
|
|
183
|
-
* Check if a /view request has a valid API key (header or ?key= query param).
|
|
184
|
-
* Returns true if authenticated, false if not. Never rejects — unauthenticated
|
|
185
|
-
* requests still get public notes.
|
|
186
|
-
*/
|
|
187
|
-
function isViewAuthenticated(req: Request, vaultConfig: VaultConfig | null, vaultDb?: import("bun:sqlite").Database): boolean {
|
|
188
|
-
if (!vaultConfig) return false;
|
|
189
|
-
// extractApiKey now checks headers AND ?key= query param
|
|
190
|
-
const key = extractApiKey(req);
|
|
191
|
-
if (!key) return false;
|
|
192
|
-
const auth = authenticateVaultRequest(req, vaultConfig, vaultDb);
|
|
193
|
-
return !("error" in auth);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function route(req: Request, path: string, clientIp?: string): Promise<Response> {
|
|
197
|
-
// OAuth discovery endpoints (no auth required)
|
|
198
|
-
if (path === "/.well-known/oauth-protected-resource") {
|
|
199
|
-
return handleProtectedResource(req);
|
|
200
|
-
}
|
|
201
|
-
if (path === "/.well-known/oauth-authorization-server") {
|
|
202
|
-
return handleAuthorizationServer(req);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// OAuth flow endpoints (no auth — these ARE the auth)
|
|
206
|
-
if (path === "/oauth/register" || path === "/oauth/authorize" || path === "/oauth/token") {
|
|
207
|
-
const defaultVault = readGlobalConfig().default_vault ?? "default";
|
|
208
|
-
const vaultConfig = readVaultConfig(defaultVault);
|
|
209
|
-
if (!vaultConfig) {
|
|
210
|
-
return Response.json({ error: "server_error", error_description: "Default vault not configured" }, { status: 500 });
|
|
211
|
-
}
|
|
212
|
-
const store = getVaultStore(defaultVault);
|
|
213
|
-
|
|
214
|
-
if (path === "/oauth/register") {
|
|
215
|
-
return handleRegister(req, store.db);
|
|
216
|
-
}
|
|
217
|
-
if (path === "/oauth/authorize") {
|
|
218
|
-
const gc = readGlobalConfig();
|
|
219
|
-
const ownerPasswordHash = gc.owner_password_hash ?? null;
|
|
220
|
-
const totpSecret = gc.totp_secret ?? null;
|
|
221
|
-
const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
|
|
222
|
-
if (req.method === "GET") {
|
|
223
|
-
return handleAuthorizeGet(req, store.db, vaultConfig.name, ownerPasswordHash, totpEnrolled);
|
|
224
|
-
}
|
|
225
|
-
if (req.method === "POST") {
|
|
226
|
-
return handleAuthorizePost(req, store.db, {
|
|
227
|
-
vaultName: vaultConfig.name,
|
|
228
|
-
clientIp,
|
|
229
|
-
ownerPasswordHash,
|
|
230
|
-
totpSecret,
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
234
|
-
}
|
|
235
|
-
if (path === "/oauth/token") {
|
|
236
|
-
return handleToken(req, store.db);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Health check — vault names only for authenticated requests
|
|
241
|
-
if (path === "/health") {
|
|
242
|
-
const auth = authenticateGlobalRequest(req);
|
|
243
|
-
if ("error" in auth) {
|
|
244
|
-
return Response.json({ status: "ok" });
|
|
245
|
-
}
|
|
246
|
-
return Response.json({ status: "ok", vaults: listVaults() });
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Unified MCP (all vaults, global auth)
|
|
250
|
-
if (path === "/mcp" || path.startsWith("/mcp/")) {
|
|
251
|
-
const auth = authenticateGlobalRequest(req);
|
|
252
|
-
if ("error" in auth) return auth.error;
|
|
253
|
-
return handleUnifiedMcp(req, auth);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// View endpoint — serves notes as HTML (auth-aware, supports ID or path)
|
|
257
|
-
const viewMatch = path.match(/^\/view\/(.+)$/);
|
|
258
|
-
if (viewMatch && req.method === "GET") {
|
|
259
|
-
const defaultVault = readGlobalConfig().default_vault ?? "default";
|
|
260
|
-
const vaultConfig = readVaultConfig(defaultVault);
|
|
261
|
-
if (!vaultConfig) {
|
|
262
|
-
return Response.json({ error: "Default vault not found" }, { status: 404 });
|
|
263
|
-
}
|
|
264
|
-
const store = getVaultStore(defaultVault);
|
|
265
|
-
const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
|
|
266
|
-
return handleViewNote(store, decodeURIComponent(viewMatch[1]), {
|
|
267
|
-
authenticated,
|
|
268
|
-
publishedTag: vaultConfig.published_tag,
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Backward compat: /public/:noteId → /view/:noteId (preserving query params)
|
|
273
|
-
const publicMatch = path.match(/^\/public\/([^/]+)$/);
|
|
274
|
-
if (publicMatch && req.method === "GET") {
|
|
275
|
-
const dest = new URL(`/view/${publicMatch[1]}`, req.url);
|
|
276
|
-
dest.search = new URL(req.url).search;
|
|
277
|
-
return Response.redirect(dest.toString(), 301);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// List vaults — requires auth
|
|
282
|
-
if (path === "/vaults" && req.method === "GET") {
|
|
283
|
-
const auth = authenticateGlobalRequest(req);
|
|
284
|
-
if ("error" in auth) return auth.error;
|
|
285
|
-
const names = listVaults();
|
|
286
|
-
const vaults = names.map((name) => {
|
|
287
|
-
const config = readVaultConfig(name);
|
|
288
|
-
return {
|
|
289
|
-
name,
|
|
290
|
-
description: config?.description,
|
|
291
|
-
created_at: config?.created_at,
|
|
292
|
-
};
|
|
293
|
-
});
|
|
294
|
-
return Response.json({ vaults });
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Backward-compatible: /api/* routes to default vault
|
|
298
|
-
if (path.startsWith("/api/")) {
|
|
299
|
-
const defaultVault = readGlobalConfig().default_vault ?? "default";
|
|
300
|
-
const vaultConfig = readVaultConfig(defaultVault);
|
|
301
|
-
if (!vaultConfig) {
|
|
302
|
-
return Response.json({ error: "Default vault not found" }, { status: 404 });
|
|
303
|
-
}
|
|
304
|
-
const store = getVaultStore(defaultVault);
|
|
305
|
-
const auth = authenticateVaultRequest(req, vaultConfig, store.db);
|
|
306
|
-
if ("error" in auth) return auth.error;
|
|
307
|
-
if (!isMethodAllowed(req.method, auth.permission)) {
|
|
308
|
-
return Response.json({ error: "Forbidden", message: "Insufficient permissions" }, { status: 403 });
|
|
309
|
-
}
|
|
310
|
-
const apiPath = path.slice(4); // strip "/api"
|
|
311
|
-
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6));
|
|
312
|
-
if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5));
|
|
313
|
-
if (apiPath === "/find-path") return handleFindPath(req, store);
|
|
314
|
-
if (apiPath === "/vault") return handleVault(req, store, vaultConfig, (desc) => {
|
|
315
|
-
vaultConfig.description = desc;
|
|
316
|
-
writeVaultConfig(vaultConfig);
|
|
317
|
-
});
|
|
318
|
-
if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
|
|
319
|
-
if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), defaultVault);
|
|
320
|
-
if (apiPath === "/health") return Response.json({ status: "ok", vault: defaultVault });
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Vault-scoped routes: /vaults/{name}/...
|
|
324
|
-
const vaultMatch = path.match(/^\/vaults\/([^/]+)(\/.*)?$/);
|
|
325
|
-
if (!vaultMatch) {
|
|
326
|
-
return Response.json({ error: "Not found" }, { status: 404 });
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const vaultName = vaultMatch[1];
|
|
330
|
-
const subpath = vaultMatch[2] ?? "";
|
|
331
|
-
|
|
332
|
-
const vaultConfig = readVaultConfig(vaultName);
|
|
333
|
-
if (!vaultConfig) {
|
|
334
|
-
return Response.json(
|
|
335
|
-
{ error: "Vault not found", vault: vaultName },
|
|
336
|
-
{ status: 404 },
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Backward compat: /vaults/{name}/public/:noteId → /view/:noteId
|
|
341
|
-
const vaultPublicMatch = subpath.match(/^\/public\/([^/]+)$/);
|
|
342
|
-
if (vaultPublicMatch && req.method === "GET") {
|
|
343
|
-
const dest = new URL(`/vaults/${vaultName}/view/${vaultPublicMatch[1]}`, req.url);
|
|
344
|
-
dest.search = new URL(req.url).search;
|
|
345
|
-
return Response.redirect(dest.toString(), 301);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// View endpoint — serves notes as HTML (auth-aware, vault-scoped, supports ID or path)
|
|
349
|
-
const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
|
|
350
|
-
if (vaultViewMatch && req.method === "GET") {
|
|
351
|
-
const store = getVaultStore(vaultName);
|
|
352
|
-
const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
|
|
353
|
-
return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]), {
|
|
354
|
-
authenticated,
|
|
355
|
-
publishedTag: vaultConfig.published_tag,
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Vault-scoped OAuth endpoints (no auth — these ARE the auth)
|
|
360
|
-
if (subpath === "/oauth/register" || subpath === "/oauth/authorize" || subpath === "/oauth/token") {
|
|
361
|
-
const store = getVaultStore(vaultName);
|
|
362
|
-
if (subpath === "/oauth/register") return handleRegister(req, store.db);
|
|
363
|
-
if (subpath === "/oauth/authorize") {
|
|
364
|
-
const gc = readGlobalConfig();
|
|
365
|
-
const ownerPasswordHash = gc.owner_password_hash ?? null;
|
|
366
|
-
const totpSecret = gc.totp_secret ?? null;
|
|
367
|
-
const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
|
|
368
|
-
if (req.method === "GET") return handleAuthorizeGet(req, store.db, vaultConfig.name, ownerPasswordHash, totpEnrolled);
|
|
369
|
-
if (req.method === "POST") return handleAuthorizePost(req, store.db, {
|
|
370
|
-
vaultName: vaultConfig.name,
|
|
371
|
-
clientIp,
|
|
372
|
-
ownerPasswordHash,
|
|
373
|
-
totpSecret,
|
|
374
|
-
});
|
|
375
|
-
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
376
|
-
}
|
|
377
|
-
if (subpath === "/oauth/token") return handleToken(req, store.db);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Vault-scoped discovery endpoints
|
|
381
|
-
if (subpath === "/.well-known/oauth-protected-resource") return handleProtectedResource(req, `/vaults/${vaultName}/mcp`);
|
|
382
|
-
if (subpath === "/.well-known/oauth-authorization-server") return handleAuthorizationServer(req);
|
|
383
|
-
|
|
384
|
-
// Auth: per-vault key OR global key
|
|
385
|
-
const store = getVaultStore(vaultName);
|
|
386
|
-
const auth = authenticateVaultRequest(req, vaultConfig, store.db);
|
|
387
|
-
if ("error" in auth) return auth.error;
|
|
388
|
-
|
|
389
|
-
// Per-vault scoped MCP
|
|
390
|
-
if (subpath === "/mcp" || subpath.startsWith("/mcp/")) {
|
|
391
|
-
return handleScopedMcp(req, vaultName, auth);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// Bare /vaults/{name} — single-vault root. Returns name, description,
|
|
395
|
-
// createdAt, and stats. One round trip for a viz landing page.
|
|
396
|
-
if (subpath === "" || subpath === "/") {
|
|
397
|
-
if (req.method !== "GET") {
|
|
398
|
-
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
399
|
-
}
|
|
400
|
-
const stats = store.getVaultStats();
|
|
401
|
-
return Response.json({
|
|
402
|
-
name: vaultName,
|
|
403
|
-
description: vaultConfig.description,
|
|
404
|
-
createdAt: vaultConfig.created_at,
|
|
405
|
-
stats,
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// REST API — enforce permission level
|
|
410
|
-
if (!isMethodAllowed(req.method, auth.permission)) {
|
|
411
|
-
return Response.json(
|
|
412
|
-
{ error: "Forbidden", message: "Insufficient permissions" },
|
|
413
|
-
{ status: 403 },
|
|
414
|
-
);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const apiMatch = subpath.match(/^\/api(\/.*)?$/);
|
|
418
|
-
if (!apiMatch) {
|
|
419
|
-
return Response.json({ error: "Not found" }, { status: 404 });
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const apiPath = apiMatch[1] ?? "";
|
|
423
|
-
|
|
424
|
-
if (apiPath.startsWith("/notes")) {
|
|
425
|
-
return handleNotes(req, store, apiPath.slice(6));
|
|
426
|
-
}
|
|
427
|
-
if (apiPath.startsWith("/tags")) {
|
|
428
|
-
return handleTags(req, store, apiPath.slice(5));
|
|
429
|
-
}
|
|
430
|
-
if (apiPath === "/find-path") {
|
|
431
|
-
return handleFindPath(req, store);
|
|
432
|
-
}
|
|
433
|
-
if (apiPath === "/vault") {
|
|
434
|
-
return handleVault(req, store, vaultConfig, (desc) => {
|
|
435
|
-
vaultConfig.description = desc;
|
|
436
|
-
writeVaultConfig(vaultConfig);
|
|
437
|
-
});
|
|
438
|
-
}
|
|
439
|
-
if (apiPath === "/unresolved-wikilinks") {
|
|
440
|
-
return handleUnresolvedWikilinks(req, store);
|
|
441
|
-
}
|
|
442
|
-
if (apiPath.startsWith("/storage")) {
|
|
443
|
-
return handleStorage(req, apiPath.slice(8), vaultName);
|
|
444
|
-
}
|
|
445
|
-
if (apiPath === "/health") {
|
|
446
|
-
return Response.json({ status: "ok", vault: vaultName });
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
return Response.json({ error: "Not found" }, { status: 404 });
|
|
450
|
-
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { generateUnit } from "./systemd.ts";
|
|
3
|
+
import { WRAPPER_PATH } from "./daemon.ts";
|
|
4
|
+
|
|
5
|
+
describe("generateUnit", () => {
|
|
6
|
+
test("invokes the shared wrapper rather than hardcoding server.ts", () => {
|
|
7
|
+
const unit = generateUnit();
|
|
8
|
+
// Same incident on Linux: the old unit hardcoded the absolute path to
|
|
9
|
+
// server.ts inside ExecStart. Now ExecStart points at the wrapper, and
|
|
10
|
+
// the wrapper resolves the path from a pointer file at boot.
|
|
11
|
+
expect(unit).toContain(`ExecStart=/bin/bash ${WRAPPER_PATH}`);
|
|
12
|
+
expect(unit).not.toMatch(/server\.ts/);
|
|
13
|
+
expect(unit).toContain("Restart=on-failure");
|
|
14
|
+
});
|
|
15
|
+
});
|
package/src/systemd.ts
CHANGED
|
@@ -6,28 +6,33 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { homedir } from "os";
|
|
9
|
-
import { join
|
|
9
|
+
import { join } from "path";
|
|
10
10
|
import { writeFile, mkdir, unlink } from "fs/promises";
|
|
11
11
|
import { existsSync } from "fs";
|
|
12
12
|
import { $ } from "bun";
|
|
13
|
-
import { CONFIG_DIR,
|
|
13
|
+
import { CONFIG_DIR, LOG_PATH, ERR_PATH } from "./config.ts";
|
|
14
|
+
import { WRAPPER_PATH, writeDaemonWrapper } from "./daemon.ts";
|
|
14
15
|
|
|
15
16
|
const SERVICE_NAME = "parachute-vault";
|
|
16
17
|
const SERVICE_DIR = join(homedir(), ".config", "systemd", "user");
|
|
17
18
|
const SERVICE_PATH = join(SERVICE_DIR, `${SERVICE_NAME}.service`);
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* systemd unit invokes the shared start.sh wrapper. Env + server path
|
|
22
|
+
* resolution lives in the wrapper (see daemon.ts) — keeping systemd and
|
|
23
|
+
* launchd aligned on a single source of truth.
|
|
24
|
+
*/
|
|
25
|
+
export function generateUnit(): string {
|
|
20
26
|
return `[Unit]
|
|
21
27
|
Description=Parachute Vault
|
|
22
28
|
After=network.target
|
|
23
29
|
|
|
24
30
|
[Service]
|
|
25
31
|
Type=simple
|
|
26
|
-
WorkingDirectory=${
|
|
27
|
-
ExecStart
|
|
32
|
+
WorkingDirectory=${CONFIG_DIR}
|
|
33
|
+
ExecStart=/bin/bash ${WRAPPER_PATH}
|
|
28
34
|
Restart=on-failure
|
|
29
35
|
RestartSec=5
|
|
30
|
-
EnvironmentFile=${ENV_PATH}
|
|
31
36
|
StandardOutput=append:${LOG_PATH}
|
|
32
37
|
StandardError=append:${ERR_PATH}
|
|
33
38
|
|
|
@@ -36,12 +41,11 @@ WantedBy=default.target
|
|
|
36
41
|
`;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
export async function installSystemdService(): Promise<
|
|
40
|
-
const serverPath =
|
|
41
|
-
const bunPath = (await $`which bun`.text()).trim();
|
|
44
|
+
export async function installSystemdService(): Promise<{ serverPath: string }> {
|
|
45
|
+
const { serverPath } = await writeDaemonWrapper();
|
|
42
46
|
|
|
43
47
|
await mkdir(SERVICE_DIR, { recursive: true });
|
|
44
|
-
await writeFile(SERVICE_PATH, generateUnit(
|
|
48
|
+
await writeFile(SERVICE_PATH, generateUnit());
|
|
45
49
|
|
|
46
50
|
// Enable lingering so user services run without login session
|
|
47
51
|
try {
|
|
@@ -52,7 +56,10 @@ export async function installSystemdService(): Promise<void> {
|
|
|
52
56
|
|
|
53
57
|
await $`systemctl --user daemon-reload`.quiet();
|
|
54
58
|
await $`systemctl --user enable ${SERVICE_NAME}`.quiet();
|
|
55
|
-
|
|
59
|
+
// Idempotent: `restart` works whether or not the service was running.
|
|
60
|
+
await $`systemctl --user restart ${SERVICE_NAME}`.quiet();
|
|
61
|
+
|
|
62
|
+
return { serverPath };
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
export async function uninstallSystemdService(): Promise<void> {
|
package/src/triggers.test.ts
CHANGED
|
@@ -97,13 +97,13 @@ describe("buildPredicate", () => {
|
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
describe("registerTriggers — dispatch modes", () => {
|
|
100
|
+
describe("registerTriggers — dispatch modes", async () => {
|
|
101
101
|
let webhookServer: ReturnType<typeof Bun.serve>;
|
|
102
102
|
let webhookPort: number;
|
|
103
103
|
let lastRequest: { method: string; url: string; headers: Headers; body: unknown; formData?: FormData } | null = null;
|
|
104
104
|
let webhookHandler: (req: Request) => Response | Promise<Response>;
|
|
105
105
|
|
|
106
|
-
beforeAll(() => {
|
|
106
|
+
beforeAll(async () => {
|
|
107
107
|
webhookHandler = () => Response.json({});
|
|
108
108
|
webhookServer = Bun.serve({
|
|
109
109
|
hostname: "127.0.0.1",
|
|
@@ -224,7 +224,7 @@ describe("registerTriggers — dispatch modes", () => {
|
|
|
224
224
|
expect((file as File).name).toBe("recording.wav");
|
|
225
225
|
|
|
226
226
|
// Verify note content was updated
|
|
227
|
-
const updated = store.getNote("n2");
|
|
227
|
+
const updated = await store.getNote("n2");
|
|
228
228
|
expect(updated?.content).toBe("transcribed content");
|
|
229
229
|
|
|
230
230
|
// Cleanup
|
|
@@ -272,12 +272,12 @@ describe("registerTriggers — dispatch modes", () => {
|
|
|
272
272
|
expect(body.input).toBe("Hello world");
|
|
273
273
|
|
|
274
274
|
// Verify attachment was created
|
|
275
|
-
const attachments = store.getAttachments("n3");
|
|
275
|
+
const attachments = await store.getAttachments("n3");
|
|
276
276
|
expect(attachments.length).toBe(1);
|
|
277
277
|
expect(attachments[0].mimeType).toBe("audio/ogg");
|
|
278
278
|
|
|
279
279
|
// Verify metadata includes provider info
|
|
280
|
-
const updated = store.getNote("n3");
|
|
280
|
+
const updated = await store.getNote("n3");
|
|
281
281
|
const meta = updated?.metadata as Record<string, unknown>;
|
|
282
282
|
expect(meta.tts_provider).toBe("kokoro");
|
|
283
283
|
expect(meta.tts_voice).toBe("af_heart");
|
|
@@ -307,7 +307,7 @@ describe("registerTriggers — dispatch modes", () => {
|
|
|
307
307
|
await hooks.dispatch("created", note, store);
|
|
308
308
|
await new Promise(r => setTimeout(r, 50));
|
|
309
309
|
|
|
310
|
-
const updated = store.getNote("n4");
|
|
310
|
+
const updated = await store.getNote("n4");
|
|
311
311
|
const meta = updated?.metadata as Record<string, unknown>;
|
|
312
312
|
expect(meta.skip_test_skipped_reason).toBe("no audio attachment found");
|
|
313
313
|
});
|
|
@@ -330,7 +330,7 @@ describe("registerTriggers — dispatch modes", () => {
|
|
|
330
330
|
await hooks.dispatch("created", note, store);
|
|
331
331
|
await new Promise(r => setTimeout(r, 50));
|
|
332
332
|
|
|
333
|
-
const updated = store.getNote("n5");
|
|
333
|
+
const updated = await store.getNote("n5");
|
|
334
334
|
const meta = updated?.metadata as Record<string, unknown>;
|
|
335
335
|
expect(meta.empty_test_skipped_reason).toBe("note has no content to synthesize");
|
|
336
336
|
});
|
package/src/triggers.ts
CHANGED
|
@@ -313,7 +313,7 @@ export function registerTriggers(
|
|
|
313
313
|
|
|
314
314
|
// Phase 1: claim
|
|
315
315
|
try {
|
|
316
|
-
store.updateNote(note.id, {
|
|
316
|
+
await store.updateNote(note.id, {
|
|
317
317
|
metadata: { ...existingMeta, [pendingKey]: pendingAt },
|
|
318
318
|
skipUpdatedAt: true,
|
|
319
319
|
});
|
|
@@ -324,7 +324,7 @@ export function registerTriggers(
|
|
|
324
324
|
|
|
325
325
|
// Fire the webhook using the configured send mode
|
|
326
326
|
let webhookResult: WebhookResponse;
|
|
327
|
-
const attachments = store.getAttachments(note.id);
|
|
327
|
+
const attachments = await store.getAttachments(note.id);
|
|
328
328
|
const controller = new AbortController();
|
|
329
329
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
330
330
|
try {
|
|
@@ -358,7 +358,7 @@ export function registerTriggers(
|
|
|
358
358
|
// trigger an infinite webhook loop on every update.
|
|
359
359
|
if (webhookResult.skipped_reason) {
|
|
360
360
|
try {
|
|
361
|
-
store.updateNote(note.id, {
|
|
361
|
+
await store.updateNote(note.id, {
|
|
362
362
|
metadata: {
|
|
363
363
|
...existingMeta,
|
|
364
364
|
[pendingKey]: undefined,
|
|
@@ -378,16 +378,16 @@ export function registerTriggers(
|
|
|
378
378
|
// Add attachments first
|
|
379
379
|
if (webhookResult.attachments?.length) {
|
|
380
380
|
for (const att of webhookResult.attachments) {
|
|
381
|
-
store.addAttachment(note.id, att.path, att.mimeType, att.meta);
|
|
381
|
+
await store.addAttachment(note.id, att.path, att.mimeType, att.meta);
|
|
382
382
|
}
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
// Read fresh metadata to avoid clobbering concurrent edits
|
|
386
|
-
const fresh = store.getNote(note.id);
|
|
386
|
+
const fresh = await store.getNote(note.id);
|
|
387
387
|
const freshMeta = (fresh?.metadata as Record<string, unknown> | undefined) ?? existingMeta;
|
|
388
388
|
const { [pendingKey]: _drop, ...restMeta } = freshMeta;
|
|
389
389
|
|
|
390
|
-
store.updateNote(note.id, {
|
|
390
|
+
await store.updateNote(note.id, {
|
|
391
391
|
...(webhookResult.content !== undefined ? { content: webhookResult.content } : {}),
|
|
392
392
|
metadata: {
|
|
393
393
|
...restMeta,
|
package/src/vault-store.ts
CHANGED
|
@@ -38,10 +38,27 @@ export function getVaultNameForStore(store: SqliteStore): string | undefined {
|
|
|
38
38
|
return storeToVault.get(store);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Close all open stores. When `silent` is true, swallow errors from
|
|
43
|
+
* `db.close()` — used by test cleanup where the underlying DB files may
|
|
44
|
+
* already be gone.
|
|
45
|
+
*/
|
|
46
|
+
export function closeAllStores(silent = false): void {
|
|
43
47
|
for (const [, store] of stores) {
|
|
44
|
-
|
|
48
|
+
if (silent) {
|
|
49
|
+
try { store.db.close(); } catch {}
|
|
50
|
+
} else {
|
|
51
|
+
store.db.close();
|
|
52
|
+
}
|
|
45
53
|
}
|
|
46
54
|
stores.clear();
|
|
47
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Test-only: close and clear all cached stores. Used between tests that
|
|
59
|
+
* swap `PARACHUTE_HOME` out from under the cache, which would otherwise
|
|
60
|
+
* hold handles to DBs whose files no longer exist.
|
|
61
|
+
*/
|
|
62
|
+
export function clearVaultStoreCache(): void {
|
|
63
|
+
closeAllStores(true);
|
|
64
|
+
}
|