@openparachute/vault 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/CLAUDE.md +2 -2
  3. package/README.md +289 -44
  4. package/core/src/core.test.ts +802 -346
  5. package/core/src/expand.ts +140 -0
  6. package/core/src/hooks.test.ts +27 -27
  7. package/core/src/hooks.ts +1 -1
  8. package/core/src/mcp.ts +102 -39
  9. package/core/src/notes.ts +82 -4
  10. package/core/src/obsidian.test.ts +11 -11
  11. package/core/src/paths.test.ts +46 -46
  12. package/core/src/schema.ts +18 -2
  13. package/core/src/store.ts +51 -51
  14. package/core/src/types.ts +29 -29
  15. package/core/src/wikilinks.test.ts +61 -61
  16. package/docs/HTTP_API.md +4 -2
  17. package/package.json +1 -1
  18. package/src/auth.test.ts +319 -0
  19. package/src/backup-launchd.test.ts +90 -0
  20. package/src/backup-launchd.ts +169 -0
  21. package/src/backup.test.ts +715 -0
  22. package/src/backup.ts +699 -0
  23. package/src/cli.ts +923 -31
  24. package/src/config.test.ts +173 -0
  25. package/src/config.ts +345 -15
  26. package/src/daemon.ts +136 -0
  27. package/src/doctor.test.ts +356 -0
  28. package/src/health.test.ts +201 -0
  29. package/src/health.ts +115 -0
  30. package/src/launchd.test.ts +91 -0
  31. package/src/launchd.ts +37 -40
  32. package/src/mcp-http.ts +1 -1
  33. package/src/mcp-tools.ts +7 -9
  34. package/src/oauth.test.ts +289 -8
  35. package/src/oauth.ts +57 -12
  36. package/src/published.test.ts +21 -21
  37. package/src/routes.ts +152 -70
  38. package/src/routing.test.ts +347 -0
  39. package/src/routing.ts +365 -0
  40. package/src/server.ts +7 -278
  41. package/src/systemd.test.ts +15 -0
  42. package/src/systemd.ts +18 -11
  43. package/src/triggers.test.ts +7 -7
  44. package/src/triggers.ts +6 -6
  45. package/src/vault-store.ts +20 -3
  46. package/src/vault.test.ts +356 -262
  47. package/.claude/settings.local.json +0 -31
  48. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  49. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  50. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  51. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  52. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  53. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  54. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  55. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  56. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  57. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  58. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  59. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  60. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  61. package/religions-abrahamic-filter.png +0 -0
  62. package/religions-buddhism-v2.png +0 -0
  63. package/religions-buddhism.png +0 -0
  64. package/religions-final.png +0 -0
  65. package/religions-v1.png +0 -0
  66. package/religions-v2.png +0 -0
  67. package/religions-zen.png +0 -0
  68. package/web/README.md +0 -73
  69. package/web/bun.lock +0 -827
  70. package/web/eslint.config.js +0 -23
  71. package/web/index.html +0 -15
  72. package/web/package.json +0 -36
  73. package/web/public/favicon.svg +0 -1
  74. package/web/public/icons.svg +0 -24
  75. package/web/src/App.tsx +0 -149
  76. package/web/src/Graph.tsx +0 -200
  77. package/web/src/NoteView.tsx +0 -155
  78. package/web/src/Sidebar.tsx +0 -186
  79. package/web/src/api.ts +0 -21
  80. package/web/src/index.css +0 -50
  81. package/web/src/main.tsx +0 -10
  82. package/web/src/types.ts +0 -37
  83. package/web/src/utils.ts +0 -107
  84. package/web/tsconfig.app.json +0 -25
  85. package/web/tsconfig.json +0 -7
  86. package/web/tsconfig.node.json +0 -24
  87. package/web/vite.config.ts +0 -15
package/src/health.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Healthcheck + error-log helpers for the CLI.
3
+ *
4
+ * Used by `vault status`, `vault restart`, and `vault doctor` to distinguish
5
+ * three distinct failure modes that production wedged us on once already:
6
+ *
7
+ * 1. Launchd says the agent is loaded, but nothing is bound to the port.
8
+ * 2. Something is bound to the port, but /health doesn't return 200.
9
+ * 3. Everything is fine.
10
+ *
11
+ * The original CLI conflated these and reported "running" in cases where
12
+ * start.sh was failing and the daemon was respawning in a crash loop.
13
+ */
14
+
15
+ import { readFileSync, existsSync } from "fs";
16
+
17
+ export type HealthStatus =
18
+ | "healthy" // port bound + /health 200
19
+ | "unhealthy" // port bound but /health not 200
20
+ | "not-listening" // port closed (nothing accepting connections)
21
+ | "error"; // other fetch failure
22
+
23
+ export interface HealthResult {
24
+ status: HealthStatus;
25
+ statusCode?: number;
26
+ error?: string;
27
+ latencyMs?: number;
28
+ }
29
+
30
+ /**
31
+ * Probe http://127.0.0.1:<port>/health once. Short timeout so callers can
32
+ * poll without hanging. Treats ECONNREFUSED as "not listening" explicitly so
33
+ * the CLI can tell "daemon crashed" apart from "daemon running but wedged."
34
+ */
35
+ export async function checkHealth(port: number, timeoutMs = 2000): Promise<HealthResult> {
36
+ const start = Date.now();
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
39
+ try {
40
+ const resp = await fetch(`http://127.0.0.1:${port}/health`, {
41
+ signal: controller.signal,
42
+ });
43
+ const latencyMs = Date.now() - start;
44
+ if (resp.ok) {
45
+ return { status: "healthy", statusCode: resp.status, latencyMs };
46
+ }
47
+ return { status: "unhealthy", statusCode: resp.status, latencyMs };
48
+ } catch (err: any) {
49
+ const latencyMs = Date.now() - start;
50
+ const msg = String(err?.message ?? err);
51
+ // Bun surfaces ECONNREFUSED as "Unable to connect" / error code
52
+ // "ConnectionRefused" depending on the version. Also catch DNS failures.
53
+ if (
54
+ /ECONNREFUSED|ConnectionRefused|Unable to connect|refused/i.test(msg) ||
55
+ err?.code === "ECONNREFUSED"
56
+ ) {
57
+ return { status: "not-listening", error: msg, latencyMs };
58
+ }
59
+ if (err?.name === "AbortError" || /aborted|timeout/i.test(msg)) {
60
+ return { status: "error", error: `timeout after ${timeoutMs}ms`, latencyMs };
61
+ }
62
+ return { status: "error", error: msg, latencyMs };
63
+ } finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Poll /health until it returns healthy or the overall budget expires.
70
+ * Used by `vault restart` to turn a fire-and-forget launchctl bounce into a
71
+ * blocking operation with a clear success/failure signal.
72
+ */
73
+ export async function waitForHealthy(
74
+ port: number,
75
+ opts: { totalMs?: number; intervalMs?: number; perProbeTimeoutMs?: number } = {},
76
+ ): Promise<HealthResult> {
77
+ const totalMs = opts.totalMs ?? 10_000;
78
+ const intervalMs = opts.intervalMs ?? 500;
79
+ const perProbeTimeoutMs = opts.perProbeTimeoutMs ?? 1_500;
80
+ const deadline = Date.now() + totalMs;
81
+
82
+ let last: HealthResult = { status: "error", error: "never probed" };
83
+ while (Date.now() < deadline) {
84
+ // Never let a single probe's timeout push us past the total budget —
85
+ // otherwise the worst case is totalMs + perProbeTimeoutMs, and the
86
+ // "10s" the user sees from the CLI wouldn't match reality.
87
+ const remainingBeforeProbe = deadline - Date.now();
88
+ const probeBudget = Math.min(perProbeTimeoutMs, remainingBeforeProbe);
89
+ if (probeBudget <= 0) break;
90
+ last = await checkHealth(port, probeBudget);
91
+ if (last.status === "healthy") return last;
92
+ const remaining = deadline - Date.now();
93
+ if (remaining <= 0) break;
94
+ await new Promise((r) => setTimeout(r, Math.min(intervalMs, remaining)));
95
+ }
96
+ return last;
97
+ }
98
+
99
+ /**
100
+ * Return the last `n` lines of a file. Safe on missing files (returns null
101
+ * so callers can render "no log file" rather than propagating ENOENT).
102
+ */
103
+ export function tailFile(path: string, n: number): string | null {
104
+ if (!existsSync(path)) return null;
105
+ try {
106
+ const content = readFileSync(path, "utf8");
107
+ if (!content) return "";
108
+ const lines = content.split("\n");
109
+ // Drop trailing empty line from a terminating \n so the tail isn't blank.
110
+ if (lines[lines.length - 1] === "") lines.pop();
111
+ return lines.slice(-n).join("\n");
112
+ } catch (err) {
113
+ return null;
114
+ }
115
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { $ } from "bun";
6
+ import { generateWrapper, WRAPPER_PATH } from "./daemon.ts";
7
+ import { generatePlist } from "./launchd.ts";
8
+
9
+ describe("generateWrapper", () => {
10
+ // The incident that triggered this module: start.sh had the server path
11
+ // baked in and went stale when the repo moved. These tests assert the new
12
+ // contract — NO absolute server.ts path in the wrapper, pointer read at
13
+ // runtime, env override respected, and graceful failure when missing.
14
+
15
+ test("does not hardcode an absolute server.ts path", () => {
16
+ const wrapper = generateWrapper({
17
+ bunPath: "/Users/alice/.bun/bin/bun",
18
+ serverPathFile: "/Users/alice/.parachute/server-path",
19
+ envPath: "/Users/alice/.parachute/.env",
20
+ });
21
+ // No absolute path ending in server.ts — the whole point. Error messages
22
+ // may legitimately mention "server.ts" in text; we only forbid baked-in
23
+ // absolute paths that would go stale when the repo moves.
24
+ expect(wrapper).not.toMatch(/\/[^\s"]+\/server\.ts/);
25
+ });
26
+
27
+ test("reads the pointer file at boot, with PARACHUTE_VAULT_SERVER_PATH override", () => {
28
+ const wrapper = generateWrapper({
29
+ bunPath: "/bin/bun",
30
+ serverPathFile: "/x/.parachute/server-path",
31
+ });
32
+ // Env override precedes the pointer fallback.
33
+ expect(wrapper).toContain('SERVER_PATH="${PARACHUTE_VAULT_SERVER_PATH:-}"');
34
+ expect(wrapper).toContain('[ -f "/x/.parachute/server-path" ]');
35
+ expect(wrapper).toContain('cat "/x/.parachute/server-path"');
36
+ });
37
+
38
+ test("fails loudly (non-zero exit) when neither env nor pointer supplies a path", () => {
39
+ const wrapper = generateWrapper({ bunPath: "/bin/bun" });
40
+ expect(wrapper).toContain('if [ -z "$SERVER_PATH" ]; then');
41
+ expect(wrapper).toContain("exit 1");
42
+ // Actionable message — user needs to know what to run.
43
+ expect(wrapper).toMatch(/parachute vault init/);
44
+ });
45
+
46
+ test("fails loudly when pointer target no longer exists (moved repo)", () => {
47
+ const wrapper = generateWrapper({ bunPath: "/bin/bun" });
48
+ expect(wrapper).toContain('if [ ! -f "$SERVER_PATH" ]; then');
49
+ expect(wrapper).toMatch(/repo may have moved/);
50
+ });
51
+
52
+ test("sources .env at the configured path", () => {
53
+ const wrapper = generateWrapper({
54
+ bunPath: "/bin/bun",
55
+ envPath: "/custom/.env",
56
+ });
57
+ expect(wrapper).toContain('[ -f "/custom/.env" ]');
58
+ expect(wrapper).toContain('source "/custom/.env"');
59
+ });
60
+
61
+ test("execs bun with the resolved path (not a literal server path)", () => {
62
+ const wrapper = generateWrapper({ bunPath: "/Users/alice/.bun/bin/bun" });
63
+ // The exec line uses $SERVER_PATH (dereferenced at boot), not a literal.
64
+ expect(wrapper).toMatch(/exec "\/Users\/alice\/\.bun\/bin\/bun" "\$SERVER_PATH"/);
65
+ });
66
+
67
+ test("output is syntactically valid bash (bash -n passes)", async () => {
68
+ // Catch any shell-quoting regressions before they become crash-looping
69
+ // daemons in production. `bash -n` parses without executing.
70
+ const wrapper = generateWrapper({ bunPath: "/bin/bun" });
71
+ const dir = mkdtempSync(join(tmpdir(), "vault-wrapper-"));
72
+ const path = join(dir, "start.sh");
73
+ try {
74
+ writeFileSync(path, wrapper);
75
+ const result = await $`bash -n ${path}`.quiet().nothrow();
76
+ expect(result.exitCode).toBe(0);
77
+ } finally {
78
+ rmSync(dir, { recursive: true, force: true });
79
+ }
80
+ });
81
+ });
82
+
83
+ describe("generatePlist", () => {
84
+ test("references the shared wrapper script, not server.ts directly", () => {
85
+ const plist = generatePlist();
86
+ // The plist launches /bin/bash with the wrapper — it must not name
87
+ // server.ts so the plist stays valid across repo moves.
88
+ expect(plist).toContain(`<string>${WRAPPER_PATH}</string>`);
89
+ expect(plist).not.toMatch(/server\.ts/);
90
+ });
91
+ });
package/src/launchd.ts CHANGED
@@ -1,42 +1,20 @@
1
1
  /**
2
2
  * macOS launchd agent management for the vault daemon.
3
3
  *
4
- * The plist runs a wrapper script that sources ~/.parachute/.env
5
- * before starting the server, so env vars (API keys, providers)
6
- * are available to the daemon.
4
+ * The plist runs `~/.parachute/start.sh` (the shared wrapper from
5
+ * daemon.ts). The wrapper reads the pointer file at every boot, so
6
+ * moving the repo only requires re-running `parachute vault init`.
7
7
  */
8
8
 
9
9
  import { homedir } from "os";
10
- import { join, resolve, dirname } from "path";
10
+ import { join } from "path";
11
11
  import { writeFile, unlink } from "fs/promises";
12
12
  import { $ } from "bun";
13
- import { CONFIG_DIR, ENV_PATH, LOG_PATH, ERR_PATH } from "./config.ts";
13
+ import { CONFIG_DIR, LOG_PATH, ERR_PATH } from "./config.ts";
14
+ import { WRAPPER_PATH, writeDaemonWrapper } from "./daemon.ts";
14
15
 
15
16
  const LABEL = "computer.parachute.vault";
16
17
  const PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
17
- const WRAPPER_PATH = join(CONFIG_DIR, "start.sh");
18
-
19
- /**
20
- * Generate a shell wrapper that loads .env before starting the server.
21
- */
22
- function generateWrapper(serverPath: string, bunPath: string): string {
23
- return `#!/bin/bash
24
- # Auto-generated by parachute vault init
25
- # Loads user PATH + ~/.parachute/.env then starts the vault server
26
-
27
- # Source user shell profile for PATH (needed for parakeet-mlx, ffmpeg, etc.)
28
- [ -f "$HOME/.zprofile" ] && source "$HOME/.zprofile" 2>/dev/null
29
- [ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc" 2>/dev/null
30
-
31
- if [ -f "${ENV_PATH}" ]; then
32
- set -a
33
- source "${ENV_PATH}"
34
- set +a
35
- fi
36
-
37
- exec "${bunPath}" "${serverPath}"
38
- `;
39
- }
40
18
 
41
19
  export function generatePlist(): string {
42
20
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -64,20 +42,37 @@ export function generatePlist(): string {
64
42
  </plist>`;
65
43
  }
66
44
 
67
- export async function installAgent(): Promise<void> {
45
+ /**
46
+ * Install or re-install the launchd agent. Idempotent: if the agent is
47
+ * already loaded, it's unloaded first so the new wrapper + pointer take
48
+ * effect. This is what makes `parachute vault init` safe to re-run after
49
+ * a folder move — the incident that motivated this PR.
50
+ */
51
+ export async function installAgent(): Promise<{ serverPath: string }> {
68
52
  if (process.platform !== "darwin") {
69
53
  throw new Error("launchd is only available on macOS. Use systemd on Linux.");
70
54
  }
71
- const serverPath = resolve(dirname(import.meta.path), "server.ts");
72
- const bunPath = Bun.which("bun") || join(homedir(), ".bun", "bin", "bun");
73
55
 
74
- // Write the wrapper script
75
- await writeFile(WRAPPER_PATH, generateWrapper(serverPath, bunPath), { mode: 0o755 });
56
+ const { serverPath } = await writeDaemonWrapper();
57
+ await writeFile(PLIST_PATH, generatePlist());
76
58
 
77
- // Write and load the plist
78
- const plist = generatePlist();
79
- await writeFile(PLIST_PATH, plist);
80
- await $`launchctl load ${PLIST_PATH}`.quiet();
59
+ // Bounce launchd so it picks up a refreshed wrapper + pointer. `load`
60
+ // alone fails with "Operation already in progress" if we're already
61
+ // registered, which used to silently leave stale config in place. If
62
+ // two `vault init` calls race, the second `load` may also see the
63
+ // service as still-loaded; swallow it so re-runs don't blow up.
64
+ try {
65
+ await $`launchctl unload ${PLIST_PATH}`.quiet();
66
+ } catch {
67
+ // Not loaded yet — fine.
68
+ }
69
+ try {
70
+ await $`launchctl load ${PLIST_PATH}`.quiet();
71
+ } catch {
72
+ // A concurrent init already reloaded it — fine.
73
+ }
74
+
75
+ return { serverPath };
81
76
  }
82
77
 
83
78
  export async function uninstallAgent(): Promise<void> {
@@ -87,9 +82,11 @@ export async function uninstallAgent(): Promise<void> {
87
82
  try {
88
83
  await unlink(PLIST_PATH);
89
84
  } catch {}
90
- try {
91
- await unlink(WRAPPER_PATH);
92
- } catch {}
85
+ // Wrapper + pointer removal lives in daemon.ts so it's shared with the
86
+ // Linux uninstall path. Callers that want a fully-clean teardown must
87
+ // also call `removeDaemonWrapper()` — the CLI's `uninstall` command in
88
+ // PR 3 wires that up. Leaving them here programmatically would strand
89
+ // orphaned files in `~/.parachute/`.
93
90
  }
94
91
 
95
92
  export async function isAgentLoaded(): Promise<boolean> {
package/src/mcp-http.ts CHANGED
@@ -95,7 +95,7 @@ async function handleMcp(
95
95
  };
96
96
  }
97
97
  try {
98
- const result = tool.execute((args ?? {}) as Record<string, unknown>);
98
+ const result = await tool.execute((args ?? {}) as Record<string, unknown>);
99
99
  return {
100
100
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
101
101
  };
package/src/mcp-tools.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { generateMcpTools } from "../core/src/mcp.ts";
9
9
  import type { McpToolDef } from "../core/src/mcp.ts";
10
- import { readVaultConfig, writeVaultConfig, readGlobalConfig, listVaults as getVaultNames } from "./config.ts";
10
+ import { readVaultConfig, writeVaultConfig, listVaults as getVaultNames, resolveDefaultVault } from "./config.ts";
11
11
  import { getVaultStore } from "./vault-store.ts";
12
12
 
13
13
  /**
@@ -15,8 +15,7 @@ import { getVaultStore } from "./vault-store.ts";
15
15
  * Sent once at session init — not per tool.
16
16
  */
17
17
  export function getServerInstruction(vaultName?: string): string {
18
- const globalConfig = readGlobalConfig();
19
- const name = vaultName ?? globalConfig.default_vault ?? "default";
18
+ const name = vaultName ?? resolveDefaultVault() ?? "default";
20
19
  const config = readVaultConfig(name);
21
20
 
22
21
  const parts: string[] = [
@@ -35,9 +34,8 @@ export function getServerInstruction(vaultName?: string): string {
35
34
  * Each tool has an optional `vault` param that defaults to the default vault.
36
35
  */
37
36
  export function generateUnifiedMcpTools(): McpToolDef[] {
38
- const globalConfig = readGlobalConfig();
39
- const defaultVault = globalConfig.default_vault ?? "default";
40
37
  const vaultNames = getVaultNames();
38
+ const defaultVault = resolveDefaultVault() ?? "default";
41
39
  const multiVault = vaultNames.length > 1;
42
40
 
43
41
  // Get tool definitions from core (using default vault for schema)
@@ -66,7 +64,7 @@ export function generateUnifiedMcpTools(): McpToolDef[] {
66
64
  name: coreTool.name,
67
65
  description,
68
66
  inputSchema,
69
- execute: (params) => {
67
+ execute: async (params) => {
70
68
  const vaultName = (params.vault as string) ?? defaultVault;
71
69
  const config = readVaultConfig(vaultName);
72
70
  if (!config) {
@@ -76,7 +74,7 @@ export function generateUnifiedMcpTools(): McpToolDef[] {
76
74
  const vaultTools = generateMcpTools(store);
77
75
  const tool = vaultTools.find((t) => t.name === coreTool.name)!;
78
76
  const { vault: _, ...rest } = params;
79
- return tool.execute(rest);
77
+ return await tool.execute(rest);
80
78
  },
81
79
  };
82
80
  });
@@ -129,7 +127,7 @@ function overrideVaultInfo(tools: McpToolDef[], defaultVault: string): void {
129
127
  const vaultInfo = tools.find((t) => t.name === "vault-info");
130
128
  if (!vaultInfo) return;
131
129
 
132
- vaultInfo.execute = (params) => {
130
+ vaultInfo.execute = async (params) => {
133
131
  const vaultName = (params.vault as string) ?? defaultVault;
134
132
  const config = readVaultConfig(vaultName);
135
133
  if (!config) throw new Error(`Vault "${vaultName}" not found`);
@@ -147,7 +145,7 @@ function overrideVaultInfo(tools: McpToolDef[], defaultVault: string): void {
147
145
 
148
146
  if (params.include_stats) {
149
147
  const store = getVaultStore(vaultName);
150
- result.stats = store.getVaultStats();
148
+ result.stats = await store.getVaultStats();
151
149
  }
152
150
 
153
151
  return result;