@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
package/src/daemon.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Shared daemon-wrapper plumbing used by the macOS launchd and Linux
3
+ * systemd install paths.
4
+ *
5
+ * Rationale: both platforms historically hardcoded the absolute path to
6
+ * `src/server.ts` into their respective service/wrapper files at init
7
+ * time. When the repo moved, the service kept respawning bun on a missing
8
+ * file and crash-looped silently into `~/.parachute/vault.err`.
9
+ *
10
+ * The fix: a small bash wrapper (`~/.parachute/start.sh`) and a pointer
11
+ * file (`~/.parachute/server-path`) that the wrapper reads at every boot.
12
+ * Moving the repo now only requires re-running `parachute vault init` —
13
+ * no plist/unit re-registration needed. Callers can override the path for
14
+ * a single boot via `PARACHUTE_VAULT_SERVER_PATH` in `~/.parachute/.env`.
15
+ */
16
+
17
+ import { homedir } from "os";
18
+ import { join, resolve, dirname } from "path";
19
+ import { writeFile, unlink } from "fs/promises";
20
+ import { existsSync, readFileSync } from "fs";
21
+ import { CONFIG_DIR, ENV_PATH } from "./config.ts";
22
+
23
+ /** Start-up script sourced by launchd and systemd alike. */
24
+ export const WRAPPER_PATH = join(CONFIG_DIR, "start.sh");
25
+ /** Pointer file. Contents: absolute path to `server.ts`, one line. */
26
+ export const SERVER_PATH_FILE = join(CONFIG_DIR, "server-path");
27
+
28
+ /**
29
+ * Build the start.sh contents. Pure string builder — easy to assert on.
30
+ *
31
+ * The generated script:
32
+ * 1. Sources the user's login shell profiles to pick up PATH (needed
33
+ * when the daemon shells out to tools like ffmpeg, parakeet-mlx).
34
+ * 2. Sources ~/.parachute/.env.
35
+ * 3. Resolves the server path: env override first, then pointer file.
36
+ * 4. Fails loudly with an actionable message if the path is missing or
37
+ * the target file doesn't exist — instead of silently respawning
38
+ * into a crash loop the way a bare `bun /missing/path` would.
39
+ * 5. Execs bun against the resolved server path.
40
+ */
41
+ export function generateWrapper(opts: {
42
+ bunPath: string;
43
+ envPath?: string;
44
+ serverPathFile?: string;
45
+ }): string {
46
+ const envPath = opts.envPath ?? ENV_PATH;
47
+ const pointer = opts.serverPathFile ?? SERVER_PATH_FILE;
48
+ return `#!/bin/bash
49
+ # Auto-generated by \`parachute vault init\`. Do not edit by hand — edits
50
+ # are clobbered on the next init. To override the server path for a
51
+ # single boot, set PARACHUTE_VAULT_SERVER_PATH in ~/.parachute/.env.
52
+ # To point at a different repo permanently, re-run \`parachute vault init\`
53
+ # from that repo.
54
+
55
+ set -u
56
+
57
+ # Source user shell profile for PATH (needed for parakeet-mlx, ffmpeg, etc.)
58
+ [ -f "$HOME/.zprofile" ] && source "$HOME/.zprofile" 2>/dev/null
59
+ [ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc" 2>/dev/null
60
+
61
+ if [ -f "${envPath}" ]; then
62
+ set -a
63
+ source "${envPath}"
64
+ set +a
65
+ fi
66
+
67
+ # Resolve server path: env override wins, then pointer file written by init.
68
+ SERVER_PATH="\${PARACHUTE_VAULT_SERVER_PATH:-}"
69
+ if [ -z "$SERVER_PATH" ] && [ -f "${pointer}" ]; then
70
+ SERVER_PATH=$(cat "${pointer}")
71
+ fi
72
+
73
+ if [ -z "$SERVER_PATH" ]; then
74
+ echo "parachute-vault: server path not configured (no ${pointer} and PARACHUTE_VAULT_SERVER_PATH is unset)." >&2
75
+ echo "parachute-vault: run \\\`parachute vault init\\\` to configure it." >&2
76
+ exit 1
77
+ fi
78
+
79
+ if [ ! -f "$SERVER_PATH" ]; then
80
+ echo "parachute-vault: server.ts not found at $SERVER_PATH" >&2
81
+ echo "parachute-vault: the repo may have moved. Run \\\`parachute vault init\\\` from the current repo location." >&2
82
+ exit 1
83
+ fi
84
+
85
+ exec "${opts.bunPath}" "$SERVER_PATH"
86
+ `;
87
+ }
88
+
89
+ /**
90
+ * Resolve the absolute path to server.ts for the currently-running CLI.
91
+ * Works for both `bun src/cli.ts` (dev) and `bun x @openparachute/vault`
92
+ * (published) — whichever path the CLI loaded from, server.ts sits next to it.
93
+ *
94
+ * NOTE: this relies on `server.ts` shipping next to `cli.ts` in the
95
+ * published package. If a `"files"` allowlist is ever added to
96
+ * package.json, `src/server.ts` must be included explicitly.
97
+ */
98
+ export function resolveServerPath(): string {
99
+ return resolve(dirname(import.meta.path), "server.ts");
100
+ }
101
+
102
+ /** Write the wrapper script and pointer file together. Idempotent. */
103
+ export async function writeDaemonWrapper(): Promise<{ serverPath: string; bunPath: string }> {
104
+ const serverPath = resolveServerPath();
105
+ const bunPath = Bun.which("bun") || join(homedir(), ".bun", "bin", "bun");
106
+
107
+ await writeFile(SERVER_PATH_FILE, serverPath + "\n");
108
+ await writeFile(WRAPPER_PATH, generateWrapper({ bunPath }), { mode: 0o755 });
109
+
110
+ return { serverPath, bunPath };
111
+ }
112
+
113
+ /** Remove the wrapper and pointer. Leaves .env, DB, logs alone. */
114
+ export async function removeDaemonWrapper(): Promise<void> {
115
+ for (const p of [WRAPPER_PATH, SERVER_PATH_FILE]) {
116
+ try {
117
+ await unlink(p);
118
+ } catch {
119
+ // Not there — fine.
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Read the pointer file, returning null if it's missing or unreadable.
126
+ * Used by the `vault doctor` / `vault uninstall` commands in PR 3 to tell
127
+ * whether the daemon is pointing at a real file.
128
+ */
129
+ export function readServerPathPointer(): string | null {
130
+ try {
131
+ if (!existsSync(SERVER_PATH_FILE)) return null;
132
+ return readFileSync(SERVER_PATH_FILE, "utf8").trim() || null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Integration tests for `parachute vault doctor` and `parachute vault url`.
3
+ *
4
+ * We spawn the CLI as a subprocess with `PARACHUTE_HOME` pointed at a
5
+ * fresh tempdir — so each test exercises the real code path (config +
6
+ * daemon path resolution + exit codes) against a known filesystem state.
7
+ * This catches wiring bugs that pure-function tests can't (e.g., a
8
+ * missing import or a broken switch case).
9
+ *
10
+ * We deliberately avoid asserting on daemon-manager check output — that
11
+ * depends on the host machine's live launchctl/systemd state and isn't
12
+ * part of the PR's contract.
13
+ */
14
+
15
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
16
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, chmodSync } from "fs";
17
+ import { join, resolve } from "path";
18
+ import { tmpdir } from "os";
19
+
20
+ const CLI = resolve(import.meta.dir, "cli.ts");
21
+
22
+ /**
23
+ * Run the CLI as a subprocess. `parachuteHome` is always passed as the
24
+ * isolated config dir; `extraEnv` is merged last so tests can override
25
+ * `HOME` (for the `~/.claude.json` MCP-entry checks) or unset `PATH`
26
+ * (for the bun-on-PATH check) without affecting unrelated tests.
27
+ */
28
+ function runCli(
29
+ args: string[],
30
+ parachuteHome: string,
31
+ extraEnv: Record<string, string | undefined> = {},
32
+ ): { exitCode: number; stdout: string; stderr: string } {
33
+ const proc = Bun.spawnSync({
34
+ cmd: ["bun", CLI, ...args],
35
+ env: { ...process.env, PARACHUTE_HOME: parachuteHome, ...extraEnv },
36
+ stdout: "pipe",
37
+ stderr: "pipe",
38
+ });
39
+ return {
40
+ exitCode: proc.exitCode ?? -1,
41
+ stdout: new TextDecoder().decode(proc.stdout),
42
+ stderr: new TextDecoder().decode(proc.stderr),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Write a minimal ~/.claude.json with the given vault MCP URL. Returns the
48
+ * full path. Used by the MCP-entry-present tests.
49
+ */
50
+ function writeClaudeJson(home: string, url: string): string {
51
+ const path = join(home, ".claude.json");
52
+ writeFileSync(
53
+ path,
54
+ JSON.stringify(
55
+ { mcpServers: { "parachute-vault": { type: "http", url } } },
56
+ null,
57
+ 2,
58
+ ),
59
+ );
60
+ return path;
61
+ }
62
+
63
+ describe("vault doctor", () => {
64
+ let dir: string;
65
+ beforeEach(() => {
66
+ dir = mkdtempSync(join(tmpdir(), "vault-doctor-"));
67
+ });
68
+ afterEach(() => {
69
+ rmSync(dir, { recursive: true, force: true });
70
+ });
71
+
72
+ test("fails with a clear message when the pointer file is missing", () => {
73
+ const res = runCli(["doctor"], dir);
74
+ expect(res.exitCode).toBe(1);
75
+ expect(res.stdout).toMatch(/server-path pointer/);
76
+ expect(res.stdout).toMatch(/missing/);
77
+ expect(res.stdout).toMatch(/parachute vault init/);
78
+ });
79
+
80
+ test("fails when the pointer targets a non-existent file (moved repo)", () => {
81
+ writeFileSync(join(dir, "server-path"), "/does/not/exist/server.ts\n");
82
+ writeFileSync(join(dir, "start.sh"), "#!/bin/bash\n");
83
+ const res = runCli(["doctor"], dir);
84
+ expect(res.exitCode).toBe(1);
85
+ expect(res.stdout).toMatch(/points to/);
86
+ expect(res.stdout).toMatch(/repo location|init/i);
87
+ });
88
+
89
+ test("passes the pointer check when the target exists", () => {
90
+ // Use the CLI file itself as a stand-in existing target — it is
91
+ // guaranteed to exist since we just spawned bun with it.
92
+ writeFileSync(join(dir, "server-path"), CLI + "\n");
93
+ writeFileSync(join(dir, "start.sh"), "#!/bin/bash\n");
94
+ const res = runCli(["doctor"], dir);
95
+ expect(res.stdout).toMatch(/✓ server-path pointer/);
96
+ expect(res.stdout).toMatch(/✓ wrapper script/);
97
+ });
98
+ });
99
+
100
+ /**
101
+ * Extended doctor checks: bun-on-PATH, MCP entry presence + port match +
102
+ * reachability, and port-collision detection. All tests use an isolated
103
+ * HOME (so the user's real ~/.claude.json isn't consulted) and an isolated
104
+ * PARACHUTE_HOME, so they're reproducible on any machine.
105
+ *
106
+ * We intentionally do NOT test the "held by our daemon" (ours) branch of
107
+ * the port-collision check: it requires running a process whose cmdline
108
+ * contains our server.ts path, and we have no hook to fake that without
109
+ * spawning the real server. The foreign branch exercises the collision
110
+ * detection path end-to-end, which is what actually matters for warning
111
+ * OSS users; the ours branch is covered by the unit-level fact that
112
+ * `describeProcess` runs `ps` against the PID lsof returns.
113
+ */
114
+ describe("vault doctor — extended checks", () => {
115
+ let dir: string;
116
+ beforeEach(() => {
117
+ dir = mkdtempSync(join(tmpdir(), "vault-doctor-ext-"));
118
+ });
119
+ afterEach(() => {
120
+ rmSync(dir, { recursive: true, force: true });
121
+ });
122
+
123
+ test("reports bun on PATH when `bun` is resolvable", () => {
124
+ // The test harness itself runs under bun, so bun is guaranteed to be
125
+ // on PATH here. This confirms the happy path renders correctly.
126
+ const res = runCli(["doctor"], dir, { HOME: dir });
127
+ expect(res.stdout).toMatch(/✓ bun on PATH/);
128
+ });
129
+
130
+ test("warns when `bun` is not on PATH", () => {
131
+ // We need two things at once:
132
+ // a) the child's PATH must NOT resolve `bun` (so the doctor check fails)
133
+ // b) we still need to launch the child under bun (so the test runs)
134
+ // Solution: launch the child via bun's absolute path directly, and set
135
+ // its PATH to an empty tempdir. `Bun.spawnSync`'s cmd[0] with an
136
+ // absolute path bypasses PATH lookup entirely.
137
+ const bunAbs = process.execPath; // the bun executable running this test
138
+ const emptyPathDir = mkdtempSync(join(tmpdir(), "empty-path-"));
139
+ try {
140
+ const proc = Bun.spawnSync({
141
+ cmd: [bunAbs, CLI, "doctor"],
142
+ env: { ...process.env, PARACHUTE_HOME: dir, HOME: dir, PATH: emptyPathDir },
143
+ stdout: "pipe",
144
+ stderr: "pipe",
145
+ });
146
+ const stdout = new TextDecoder().decode(proc.stdout);
147
+ expect(stdout).toMatch(/! bun on PATH/);
148
+ expect(stdout).toMatch(/not resolvable/);
149
+ expect(stdout).toMatch(/bun\.sh\/install/);
150
+ } finally {
151
+ rmSync(emptyPathDir, { recursive: true, force: true });
152
+ }
153
+ });
154
+
155
+ test("warns when ~/.claude.json has no parachute-vault MCP entry", () => {
156
+ // Isolated HOME with no ~/.claude.json at all — the most common
157
+ // pre-`mcp-install` state for new users.
158
+ const res = runCli(["doctor"], dir, { HOME: dir });
159
+ expect(res.stdout).toMatch(/! MCP entry in ~\/\.claude\.json/);
160
+ expect(res.stdout).toMatch(/does not exist|no mcpServers/);
161
+ expect(res.stdout).toMatch(/mcp-install/);
162
+ });
163
+
164
+ test("warns when ~/.claude.json exists but has no parachute-vault entry", () => {
165
+ writeFileSync(join(dir, ".claude.json"), JSON.stringify({ mcpServers: {} }));
166
+ const res = runCli(["doctor"], dir, { HOME: dir });
167
+ expect(res.stdout).toMatch(/! MCP entry in ~\/\.claude\.json/);
168
+ expect(res.stdout).toMatch(/no mcpServers\["parachute-vault"\] entry/);
169
+ });
170
+
171
+ test("passes MCP entry + port-match checks when URL points at the configured port", () => {
172
+ // Use a non-default port to prove we're actually reading config.yaml,
173
+ // not just matching against DEFAULT_PORT.
174
+ writeFileSync(join(dir, "config.yaml"), "port: 4321\n");
175
+ writeClaudeJson(dir, "http://127.0.0.1:4321/vaults/default/mcp");
176
+ const res = runCli(["doctor"], dir, { HOME: dir });
177
+ expect(res.stdout).toMatch(/✓ MCP entry in ~\/\.claude\.json/);
178
+ expect(res.stdout).toMatch(/✓ MCP URL port matches vault\s+\(port 4321\)/);
179
+ // Reachability will warn because nothing is bound to 4321 in the test
180
+ // env — this is the "entry present, port matches, daemon unreachable"
181
+ // state the handoff explicitly called out as useful to surface.
182
+ expect(res.stdout).toMatch(/! MCP URL reachable/);
183
+ });
184
+
185
+ test("warns when MCP URL port does not match the vault's configured port", () => {
186
+ writeFileSync(join(dir, "config.yaml"), "port: 4321\n");
187
+ writeClaudeJson(dir, "http://127.0.0.1:9999/vaults/default/mcp");
188
+ const res = runCli(["doctor"], dir, { HOME: dir });
189
+ expect(res.stdout).toMatch(/✓ MCP entry in ~\/\.claude\.json/);
190
+ expect(res.stdout).toMatch(/! MCP URL port matches vault/);
191
+ expect(res.stdout).toMatch(/MCP URL port 9999 ≠ vault port 4321/);
192
+ });
193
+
194
+ test("warns when the configured port is held by an unrelated process", async () => {
195
+ // Find a free port, bind to it with a plain Bun.serve that has nothing
196
+ // to do with our server.ts — so the collision check will classify it
197
+ // as foreign. Using port 0 gets the OS to pick; we then write that
198
+ // port into config.yaml and let doctor discover the clash.
199
+ const server = Bun.serve({ port: 0, fetch: () => new Response("other") });
200
+ try {
201
+ const port = server.port;
202
+ writeFileSync(join(dir, "config.yaml"), `port: ${port}\n`);
203
+ const res = runCli(["doctor"], dir, { HOME: dir });
204
+ // The rendered name includes the port number, so match loosely.
205
+ expect(res.stdout).toMatch(new RegExp(`! port ${port} availability`));
206
+ expect(res.stdout).toMatch(/port in use by non-vault process/);
207
+ } finally {
208
+ server.stop(true);
209
+ }
210
+ });
211
+
212
+ test("reports port as free when nothing is bound to it", () => {
213
+ // Hardcoded ports are a portability trap — e.g. OrbStack grabs 54321
214
+ // on macOS, which would spuriously trip the foreign branch. Instead,
215
+ // ask the OS for a free port (bind to 0, then release) and point
216
+ // doctor at that port. The race window between stop() and doctor's
217
+ // lsof is tiny; for the stability of this test we accept it as the
218
+ // best available cross-platform "free-ish port" signal.
219
+ const probe = Bun.serve({ port: 0, fetch: () => new Response("ok") });
220
+ const port = probe.port;
221
+ probe.stop(true);
222
+ writeFileSync(join(dir, "config.yaml"), `port: ${port}\n`);
223
+ const res = runCli(["doctor"], dir, { HOME: dir });
224
+ expect(res.stdout).toMatch(new RegExp(`✓ port ${port} availability`));
225
+ expect(res.stdout).toMatch(/no listener \(ready to bind\)/);
226
+ });
227
+ });
228
+
229
+ describe("vault uninstall", () => {
230
+ let dir: string;
231
+ beforeEach(() => {
232
+ dir = mkdtempSync(join(tmpdir(), "vault-uninstall-"));
233
+ });
234
+ afterEach(() => {
235
+ rmSync(dir, { recursive: true, force: true });
236
+ });
237
+
238
+ test("--yes --wipe removes user data non-interactively", async () => {
239
+ // Scripted uninstall contract. --yes is an explicit opt-in to the
240
+ // destructive path — skipping the interactive guard is the whole
241
+ // point. We keep this behavior, but the help text warns.
242
+ // Note: this test exercises filesystem wipe only. It still invokes
243
+ // uninstallAgent(), which is safe because launchd.ts wraps each
244
+ // step in try/catch and we're on a tempdir that was never registered.
245
+ const vaultsDir = join(dir, "vaults");
246
+ const envFile = join(dir, ".env");
247
+ mkdirSync(vaultsDir, { recursive: true });
248
+ writeFileSync(join(vaultsDir, "marker"), "doomed");
249
+ writeFileSync(envFile, "PORT=1940\n");
250
+
251
+ const res = runCli(["uninstall", "--yes", "--wipe"], dir);
252
+ expect(res.exitCode).toBe(0);
253
+
254
+ const { existsSync } = await import("fs");
255
+ expect(existsSync(vaultsDir)).toBe(false);
256
+ expect(existsSync(envFile)).toBe(false);
257
+ });
258
+
259
+ test("--yes --wipe removes config.yaml and daemon logs alongside vaults + .env", async () => {
260
+ // Regression: the help text advertised "vaults + .env" but config.yaml,
261
+ // vault.log, and vault.err were being left behind. `uninstall --wipe`
262
+ // should fully reset ~/.parachute, or the next `init` picks up stale
263
+ // state (in particular a stale config.yaml port). Keep this test in
264
+ // sync with the path list in cmdUninstall and the usage help.
265
+ const vaultsDir = join(dir, "vaults");
266
+ const envFile = join(dir, ".env");
267
+ const configYaml = join(dir, "config.yaml");
268
+ const logFile = join(dir, "vault.log");
269
+ const errFile = join(dir, "vault.err");
270
+ mkdirSync(vaultsDir, { recursive: true });
271
+ writeFileSync(join(vaultsDir, "marker"), "doomed");
272
+ writeFileSync(envFile, "PORT=1940\n");
273
+ writeFileSync(configYaml, "port: 1940\n");
274
+ writeFileSync(logFile, "some log\n");
275
+ writeFileSync(errFile, "some err\n");
276
+
277
+ const res = runCli(["uninstall", "--yes", "--wipe"], dir);
278
+ expect(res.exitCode).toBe(0);
279
+
280
+ const { existsSync } = await import("fs");
281
+ expect(existsSync(vaultsDir)).toBe(false);
282
+ expect(existsSync(envFile)).toBe(false);
283
+ expect(existsSync(configYaml)).toBe(false);
284
+ expect(existsSync(logFile)).toBe(false);
285
+ expect(existsSync(errFile)).toBe(false);
286
+ });
287
+
288
+ test("--yes --wipe prints a destructive-wipe audit line before acting", async () => {
289
+ // `--yes --wipe` bypasses both interactive confirms. It must not be
290
+ // silent: a scripted uninstaller should leave one grep-able line in
291
+ // stdout documenting the destructive run (ISO timestamp + target paths).
292
+ writeFileSync(join(dir, ".env"), "PORT=1940\n");
293
+
294
+ const res = runCli(["uninstall", "--yes", "--wipe"], dir);
295
+ expect(res.exitCode).toBe(0);
296
+ expect(res.stdout).toMatch(/scripted destructive wipe/);
297
+ // ISO-8601 timestamp shape (year-month-dayTtime). Loose enough to
298
+ // avoid flaking on sub-second precision differences.
299
+ expect(res.stdout).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
300
+ // Targets should be enumerated by path.
301
+ expect(res.stdout).toMatch(/vaults/);
302
+ expect(res.stdout).toMatch(/\.env/);
303
+ expect(res.stdout).toMatch(/config\.yaml/);
304
+ });
305
+
306
+ test("answering 'no' at the prompt does not touch daemon/filesystem", async () => {
307
+ // Set up a fake install: wrapper + pointer in the temp PARACHUTE_HOME.
308
+ const wrapper = join(dir, "start.sh");
309
+ const pointer = join(dir, "server-path");
310
+ writeFileSync(wrapper, "#!/bin/bash\n");
311
+ writeFileSync(pointer, "/tmp/fake.ts\n");
312
+
313
+ const proc = Bun.spawn({
314
+ cmd: ["bun", CLI, "uninstall"],
315
+ env: { ...process.env, PARACHUTE_HOME: dir },
316
+ stdin: "pipe",
317
+ stdout: "pipe",
318
+ stderr: "pipe",
319
+ });
320
+ proc.stdin.write("n\n");
321
+ await proc.stdin.end();
322
+ const exitCode = await proc.exited;
323
+ const out = await new Response(proc.stdout).text();
324
+
325
+ expect(exitCode).toBe(0);
326
+ expect(out).toMatch(/Cancelled/);
327
+ // Files should still exist — the bail-out must happen before any
328
+ // destructive op.
329
+ const { existsSync } = await import("fs");
330
+ expect(existsSync(wrapper)).toBe(true);
331
+ expect(existsSync(pointer)).toBe(true);
332
+ });
333
+ });
334
+
335
+ describe("vault url", () => {
336
+ let dir: string;
337
+ beforeEach(() => {
338
+ dir = mkdtempSync(join(tmpdir(), "vault-url-"));
339
+ });
340
+ afterEach(() => {
341
+ rmSync(dir, { recursive: true, force: true });
342
+ });
343
+
344
+ test("prints the default URL when no port is configured", () => {
345
+ const res = runCli(["url"], dir);
346
+ expect(res.exitCode).toBe(0);
347
+ expect(res.stdout.trim()).toBe("http://127.0.0.1:1940");
348
+ });
349
+
350
+ test("reflects a custom port from config.yaml", () => {
351
+ writeFileSync(join(dir, "config.yaml"), "port: 9999\n");
352
+ const res = runCli(["url"], dir);
353
+ expect(res.exitCode).toBe(0);
354
+ expect(res.stdout.trim()).toBe("http://127.0.0.1:9999");
355
+ });
356
+ });
@@ -0,0 +1,201 @@
1
+ import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
2
+ import { writeFileSync, mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { checkHealth, waitForHealthy, tailFile } from "./health.ts";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // checkHealth — uses a real Bun.serve on a free port so we exercise the
9
+ // fetch + abort plumbing rather than mocking it. The three reachable status
10
+ // codes (200, non-200, closed) are what the CLI needs to distinguish.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ describe("checkHealth", () => {
14
+ test("returns healthy when /health returns 200", async () => {
15
+ const server = Bun.serve({
16
+ port: 0,
17
+ fetch: () => new Response(JSON.stringify({ status: "ok" })),
18
+ });
19
+ try {
20
+ const res = await checkHealth(server.port);
21
+ expect(res.status).toBe("healthy");
22
+ expect(res.statusCode).toBe(200);
23
+ expect(res.latencyMs).toBeGreaterThanOrEqual(0);
24
+ } finally {
25
+ server.stop(true);
26
+ }
27
+ });
28
+
29
+ test("returns unhealthy when /health returns non-2xx", async () => {
30
+ const server = Bun.serve({
31
+ port: 0,
32
+ fetch: () => new Response("bad", { status: 503 }),
33
+ });
34
+ try {
35
+ const res = await checkHealth(server.port);
36
+ expect(res.status).toBe("unhealthy");
37
+ expect(res.statusCode).toBe(503);
38
+ } finally {
39
+ server.stop(true);
40
+ }
41
+ });
42
+
43
+ test("returns not-listening when no server is bound", async () => {
44
+ // Bind, capture port, stop — then probe. The OS won't hand the port to
45
+ // another process instantly, so this reliably produces ECONNREFUSED.
46
+ const server = Bun.serve({ port: 0, fetch: () => new Response("ok") });
47
+ const port = server.port;
48
+ server.stop(true);
49
+ const res = await checkHealth(port, 500);
50
+ expect(res.status).toBe("not-listening");
51
+ });
52
+
53
+ test("returns error with timeout message when the server hangs longer than timeoutMs", async () => {
54
+ const server = Bun.serve({
55
+ port: 0,
56
+ // Never respond — force the probe to abort.
57
+ fetch: () => new Promise<Response>(() => {}),
58
+ });
59
+ try {
60
+ const res = await checkHealth(server.port, 150);
61
+ expect(res.status).toBe("error");
62
+ expect(res.error).toMatch(/timeout/i);
63
+ } finally {
64
+ server.stop(true);
65
+ }
66
+ });
67
+ });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // waitForHealthy — the thing the CLI's `restart` polling depends on.
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe("waitForHealthy", () => {
74
+ test("resolves on first-try healthy response", async () => {
75
+ const server = Bun.serve({ port: 0, fetch: () => new Response("ok") });
76
+ try {
77
+ const res = await waitForHealthy(server.port, { totalMs: 500, intervalMs: 50 });
78
+ expect(res.status).toBe("healthy");
79
+ } finally {
80
+ server.stop(true);
81
+ }
82
+ });
83
+
84
+ test("gives up after totalMs budget when nothing listens", async () => {
85
+ // Grab a port, close it, then wait — should return not-listening promptly
86
+ // after the budget expires.
87
+ const s = Bun.serve({ port: 0, fetch: () => new Response("ok") });
88
+ const port = s.port;
89
+ s.stop(true);
90
+
91
+ const start = Date.now();
92
+ const res = await waitForHealthy(port, { totalMs: 400, intervalMs: 100, perProbeTimeoutMs: 100 });
93
+ const elapsed = Date.now() - start;
94
+ expect(res.status).not.toBe("healthy");
95
+ // Polling should respect the total budget — allow slack for scheduling.
96
+ expect(elapsed).toBeLessThan(2000);
97
+ });
98
+
99
+ test("eventually succeeds when server comes up mid-poll", async () => {
100
+ // Pick an OS-assigned port, free it so probes start as ECONNREFUSED,
101
+ // then bring a server up on the SAME port partway through the budget.
102
+ // That's the scenario cmdRestart depends on: launchctl has bounced the
103
+ // daemon, early probes hit a closed port, a later probe succeeds.
104
+ const first = Bun.serve({ port: 0, fetch: () => new Response("ok") });
105
+ const port = first.port;
106
+ first.stop(true);
107
+
108
+ let second: ReturnType<typeof Bun.serve> | null = null;
109
+ const serverPromise = new Promise<void>((resolve) => {
110
+ setTimeout(() => {
111
+ // Small retry loop — OSes sometimes need a beat to release the port.
112
+ for (let i = 0; i < 10; i++) {
113
+ try {
114
+ second = Bun.serve({ port, fetch: () => new Response("ok") });
115
+ break;
116
+ } catch {
117
+ Bun.sleepSync(20);
118
+ }
119
+ }
120
+ resolve();
121
+ }, 250);
122
+ });
123
+
124
+ try {
125
+ const pollPromise = waitForHealthy(port, {
126
+ totalMs: 2000,
127
+ intervalMs: 100,
128
+ perProbeTimeoutMs: 200,
129
+ });
130
+ await serverPromise;
131
+ const res = await pollPromise;
132
+ expect(res.status).toBe("healthy");
133
+ } finally {
134
+ (second as any)?.stop?.(true);
135
+ }
136
+ });
137
+
138
+ test("caps per-probe timeout at remaining budget (total wait stays under totalMs + slack)", async () => {
139
+ // Probe against a closed port with an intentionally oversized per-probe
140
+ // timeout. Without the cap, the loop could blow past totalMs waiting
141
+ // for one probe's timeout to fire. With the cap, the total wall time
142
+ // stays close to totalMs.
143
+ const s = Bun.serve({ port: 0, fetch: () => new Response("ok") });
144
+ const port = s.port;
145
+ s.stop(true);
146
+
147
+ const start = Date.now();
148
+ const res = await waitForHealthy(port, {
149
+ totalMs: 300,
150
+ intervalMs: 1000,
151
+ perProbeTimeoutMs: 5000,
152
+ });
153
+ const elapsed = Date.now() - start;
154
+ expect(res.status).not.toBe("healthy");
155
+ expect(elapsed).toBeLessThan(1500);
156
+ });
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // tailFile
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe("tailFile", () => {
164
+ let dir: string;
165
+ beforeEach(() => {
166
+ dir = mkdtempSync(join(tmpdir(), "vault-tail-"));
167
+ });
168
+ afterEach(() => {
169
+ rmSync(dir, { recursive: true, force: true });
170
+ });
171
+
172
+ test("returns last n lines of a multi-line file", () => {
173
+ const p = join(dir, "err.log");
174
+ const lines = Array.from({ length: 50 }, (_, i) => `line ${i + 1}`);
175
+ writeFileSync(p, lines.join("\n") + "\n");
176
+ const tail = tailFile(p, 5);
177
+ expect(tail).toBe("line 46\nline 47\nline 48\nline 49\nline 50");
178
+ });
179
+
180
+ test("returns all lines when n exceeds file length", () => {
181
+ const p = join(dir, "err.log");
182
+ writeFileSync(p, "one\ntwo\nthree\n");
183
+ expect(tailFile(p, 100)).toBe("one\ntwo\nthree");
184
+ });
185
+
186
+ test("returns null when file doesn't exist", () => {
187
+ expect(tailFile(join(dir, "nope.log"), 10)).toBeNull();
188
+ });
189
+
190
+ test("returns empty string for empty file", () => {
191
+ const p = join(dir, "empty.log");
192
+ writeFileSync(p, "");
193
+ expect(tailFile(p, 10)).toBe("");
194
+ });
195
+
196
+ test("handles file without trailing newline", () => {
197
+ const p = join(dir, "no-trailing.log");
198
+ writeFileSync(p, "a\nb\nc");
199
+ expect(tailFile(p, 2)).toBe("b\nc");
200
+ });
201
+ });