@openparachute/hub 0.3.0-rc.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 (76) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +284 -0
  3. package/package.json +31 -0
  4. package/src/__tests__/auth.test.ts +101 -0
  5. package/src/__tests__/auto-wire.test.ts +283 -0
  6. package/src/__tests__/cli.test.ts +192 -0
  7. package/src/__tests__/cloudflare-config.test.ts +54 -0
  8. package/src/__tests__/cloudflare-detect.test.ts +68 -0
  9. package/src/__tests__/cloudflare-state.test.ts +92 -0
  10. package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
  11. package/src/__tests__/config.test.ts +18 -0
  12. package/src/__tests__/env-file.test.ts +125 -0
  13. package/src/__tests__/expose-auth-preflight.test.ts +201 -0
  14. package/src/__tests__/expose-cloudflare.test.ts +484 -0
  15. package/src/__tests__/expose-interactive.test.ts +703 -0
  16. package/src/__tests__/expose-last-provider.test.ts +113 -0
  17. package/src/__tests__/expose-off-auto.test.ts +269 -0
  18. package/src/__tests__/expose-state.test.ts +101 -0
  19. package/src/__tests__/expose.test.ts +1581 -0
  20. package/src/__tests__/hub-control.test.ts +346 -0
  21. package/src/__tests__/hub-server.test.ts +157 -0
  22. package/src/__tests__/hub.test.ts +116 -0
  23. package/src/__tests__/install.test.ts +1145 -0
  24. package/src/__tests__/lifecycle.test.ts +608 -0
  25. package/src/__tests__/migrate.test.ts +422 -0
  26. package/src/__tests__/notes-serve.test.ts +135 -0
  27. package/src/__tests__/port-assign.test.ts +178 -0
  28. package/src/__tests__/process-state.test.ts +140 -0
  29. package/src/__tests__/scribe-config.test.ts +193 -0
  30. package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
  31. package/src/__tests__/services-manifest.test.ts +177 -0
  32. package/src/__tests__/status.test.ts +347 -0
  33. package/src/__tests__/tailscale-commands.test.ts +111 -0
  34. package/src/__tests__/tailscale-detect.test.ts +64 -0
  35. package/src/__tests__/vault-auth-status.test.ts +164 -0
  36. package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
  37. package/src/__tests__/well-known.test.ts +214 -0
  38. package/src/auto-wire.ts +184 -0
  39. package/src/cli.ts +482 -0
  40. package/src/cloudflare/config.ts +58 -0
  41. package/src/cloudflare/detect.ts +58 -0
  42. package/src/cloudflare/state.ts +96 -0
  43. package/src/cloudflare/tunnel.ts +135 -0
  44. package/src/commands/auth.ts +69 -0
  45. package/src/commands/expose-auth-preflight.ts +217 -0
  46. package/src/commands/expose-cloudflare.ts +329 -0
  47. package/src/commands/expose-interactive.ts +428 -0
  48. package/src/commands/expose-off-auto.ts +199 -0
  49. package/src/commands/expose.ts +522 -0
  50. package/src/commands/install.ts +422 -0
  51. package/src/commands/lifecycle.ts +324 -0
  52. package/src/commands/migrate.ts +253 -0
  53. package/src/commands/scribe-provider-interactive.ts +269 -0
  54. package/src/commands/status.ts +238 -0
  55. package/src/commands/vault-tokens-create-interactive.ts +137 -0
  56. package/src/commands/vault.ts +17 -0
  57. package/src/config.ts +16 -0
  58. package/src/env-file.ts +76 -0
  59. package/src/expose-last-provider.ts +71 -0
  60. package/src/expose-state.ts +125 -0
  61. package/src/help.ts +279 -0
  62. package/src/hub-control.ts +254 -0
  63. package/src/hub-origin.ts +44 -0
  64. package/src/hub-server.ts +113 -0
  65. package/src/hub.ts +674 -0
  66. package/src/notes-serve.ts +135 -0
  67. package/src/port-assign.ts +125 -0
  68. package/src/process-state.ts +111 -0
  69. package/src/scribe-config.ts +149 -0
  70. package/src/service-spec.ts +296 -0
  71. package/src/services-manifest.ts +171 -0
  72. package/src/tailscale/commands.ts +41 -0
  73. package/src/tailscale/detect.ts +107 -0
  74. package/src/tailscale/run.ts +28 -0
  75. package/src/vault/auth-status.ts +179 -0
  76. package/src/well-known.ts +127 -0
@@ -0,0 +1,347 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { status } from "../commands/status.ts";
6
+ import { writePid } from "../process-state.ts";
7
+ import { upsertService } from "../services-manifest.ts";
8
+
9
+ function makeTempPath(): { path: string; cleanup: () => void; configDir: string } {
10
+ const dir = mkdtempSync(join(tmpdir(), "pcli-status-"));
11
+ return {
12
+ path: join(dir, "services.json"),
13
+ configDir: dir,
14
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
15
+ };
16
+ }
17
+
18
+ describe("status", () => {
19
+ test("empty manifest prints hint and exits 0", async () => {
20
+ const { path, cleanup } = makeTempPath();
21
+ try {
22
+ const lines: string[] = [];
23
+ const code = await status({
24
+ manifestPath: path,
25
+ fetchImpl: async () => new Response(null, { status: 200 }),
26
+ print: (l) => lines.push(l),
27
+ });
28
+ expect(code).toBe(0);
29
+ expect(lines.join("\n")).toMatch(/No services installed/);
30
+ } finally {
31
+ cleanup();
32
+ }
33
+ });
34
+
35
+ test("all-healthy returns 0 and prints table", async () => {
36
+ const { path, cleanup } = makeTempPath();
37
+ try {
38
+ upsertService(
39
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
40
+ path,
41
+ );
42
+ upsertService(
43
+ {
44
+ name: "parachute-scribe",
45
+ port: 3200,
46
+ paths: ["/scribe"],
47
+ health: "/scribe/health",
48
+ version: "0.1.0",
49
+ },
50
+ path,
51
+ );
52
+ const seen: string[] = [];
53
+ const lines: string[] = [];
54
+ const code = await status({
55
+ manifestPath: path,
56
+ fetchImpl: async (url) => {
57
+ seen.push(String(url));
58
+ return new Response(null, { status: 200 });
59
+ },
60
+ print: (l) => lines.push(l),
61
+ });
62
+ expect(code).toBe(0);
63
+ expect(seen).toContain("http://localhost:1940/health");
64
+ expect(seen).toContain("http://localhost:3200/scribe/health");
65
+ expect(lines[0]).toMatch(/SERVICE/);
66
+ expect(lines.some((l) => l.includes("parachute-vault"))).toBe(true);
67
+ expect(lines.some((l) => l.includes("ok"))).toBe(true);
68
+ } finally {
69
+ cleanup();
70
+ }
71
+ });
72
+
73
+ test("any-failing returns 1", async () => {
74
+ const { path, cleanup } = makeTempPath();
75
+ try {
76
+ upsertService(
77
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
78
+ path,
79
+ );
80
+ const lines: string[] = [];
81
+ const code = await status({
82
+ manifestPath: path,
83
+ fetchImpl: async () => {
84
+ throw new Error("ECONNREFUSED");
85
+ },
86
+ print: (l) => lines.push(l),
87
+ });
88
+ expect(code).toBe(1);
89
+ expect(lines.some((l) => l.includes("ECONNREFUSED"))).toBe(true);
90
+ } finally {
91
+ cleanup();
92
+ }
93
+ });
94
+
95
+ test("http non-2xx counts as unhealthy with status code", async () => {
96
+ const { path, cleanup } = makeTempPath();
97
+ try {
98
+ upsertService(
99
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
100
+ path,
101
+ );
102
+ const lines: string[] = [];
103
+ const code = await status({
104
+ manifestPath: path,
105
+ fetchImpl: async () => new Response(null, { status: 503 }),
106
+ print: (l) => lines.push(l),
107
+ });
108
+ expect(code).toBe(1);
109
+ expect(lines.some((l) => l.includes("http 503"))).toBe(true);
110
+ } finally {
111
+ cleanup();
112
+ }
113
+ });
114
+
115
+ test("running process shows pid + uptime and still probes", async () => {
116
+ const { path, configDir, cleanup } = makeTempPath();
117
+ try {
118
+ upsertService(
119
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
120
+ path,
121
+ );
122
+ writePid("vault", 4242, configDir);
123
+ const lines: string[] = [];
124
+ const code = await status({
125
+ manifestPath: path,
126
+ configDir,
127
+ alive: () => true,
128
+ fetchImpl: async () => new Response(null, { status: 200 }),
129
+ print: (l) => lines.push(l),
130
+ });
131
+ expect(code).toBe(0);
132
+ expect(lines.some((l) => l.includes("running"))).toBe(true);
133
+ expect(lines.some((l) => l.includes("4242"))).toBe(true);
134
+ expect(lines.some((l) => l.includes("ok"))).toBe(true);
135
+ } finally {
136
+ cleanup();
137
+ }
138
+ });
139
+
140
+ test("known-stopped process skips probe and doesn't fail exit", async () => {
141
+ const { path, configDir, cleanup } = makeTempPath();
142
+ try {
143
+ upsertService(
144
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
145
+ path,
146
+ );
147
+ writePid("vault", 4242, configDir);
148
+ let probed = false;
149
+ const lines: string[] = [];
150
+ const code = await status({
151
+ manifestPath: path,
152
+ configDir,
153
+ alive: () => false,
154
+ fetchImpl: async () => {
155
+ probed = true;
156
+ return new Response(null, { status: 200 });
157
+ },
158
+ print: (l) => lines.push(l),
159
+ });
160
+ expect(code).toBe(0);
161
+ expect(probed).toBe(false);
162
+ expect(lines.some((l) => l.includes("stopped"))).toBe(true);
163
+ } finally {
164
+ cleanup();
165
+ }
166
+ });
167
+
168
+ test("unknown process state (no pid file) still probes — externally managed OK", async () => {
169
+ const { path, configDir, cleanup } = makeTempPath();
170
+ try {
171
+ upsertService(
172
+ { name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
173
+ path,
174
+ );
175
+ let probed = false;
176
+ const code = await status({
177
+ manifestPath: path,
178
+ configDir,
179
+ fetchImpl: async () => {
180
+ probed = true;
181
+ return new Response(null, { status: 200 });
182
+ },
183
+ print: () => {},
184
+ });
185
+ expect(code).toBe(0);
186
+ expect(probed).toBe(true);
187
+ } finally {
188
+ cleanup();
189
+ }
190
+ });
191
+
192
+ // URL column: the launch-day pain was a user staring at the table not
193
+ // knowing where to point Claude.ai or curl. Each row gets a " → URL"
194
+ // continuation line so the next step is obvious.
195
+ test("vault row prints MCP URL beneath it (path + /mcp suffix)", async () => {
196
+ const { path, cleanup } = makeTempPath();
197
+ try {
198
+ upsertService(
199
+ {
200
+ name: "parachute-vault",
201
+ port: 1940,
202
+ paths: ["/vault/default"],
203
+ health: "/vault/default/health",
204
+ version: "0.2.4",
205
+ },
206
+ path,
207
+ );
208
+ const lines: string[] = [];
209
+ await status({
210
+ manifestPath: path,
211
+ fetchImpl: async () => new Response(null, { status: 200 }),
212
+ print: (l) => lines.push(l),
213
+ });
214
+ expect(lines.some((l) => l.includes("→ http://127.0.0.1:1940/vault/default/mcp"))).toBe(true);
215
+ } finally {
216
+ cleanup();
217
+ }
218
+ });
219
+
220
+ test("scribe row prints root URL (API is at /, ignore path prefix)", async () => {
221
+ const { path, cleanup } = makeTempPath();
222
+ try {
223
+ upsertService(
224
+ {
225
+ name: "parachute-scribe",
226
+ port: 1943,
227
+ paths: ["/scribe"],
228
+ health: "/scribe/health",
229
+ version: "0.1.0",
230
+ },
231
+ path,
232
+ );
233
+ const lines: string[] = [];
234
+ await status({
235
+ manifestPath: path,
236
+ fetchImpl: async () => new Response(null, { status: 200 }),
237
+ print: (l) => lines.push(l),
238
+ });
239
+ expect(lines.some((l) => l === " → http://127.0.0.1:1943")).toBe(true);
240
+ } finally {
241
+ cleanup();
242
+ }
243
+ });
244
+
245
+ test("notes row prints UI URL (port + /notes mount)", async () => {
246
+ const { path, cleanup } = makeTempPath();
247
+ try {
248
+ upsertService(
249
+ {
250
+ name: "parachute-notes",
251
+ port: 1942,
252
+ paths: ["/notes"],
253
+ health: "/notes/health",
254
+ version: "0.0.1",
255
+ },
256
+ path,
257
+ );
258
+ const lines: string[] = [];
259
+ await status({
260
+ manifestPath: path,
261
+ fetchImpl: async () => new Response(null, { status: 200 }),
262
+ print: (l) => lines.push(l),
263
+ });
264
+ expect(lines.some((l) => l === " → http://127.0.0.1:1942/notes")).toBe(true);
265
+ } finally {
266
+ cleanup();
267
+ }
268
+ });
269
+
270
+ test("channel row prints port + /channel mount", async () => {
271
+ const { path, cleanup } = makeTempPath();
272
+ try {
273
+ upsertService(
274
+ {
275
+ name: "parachute-channel",
276
+ port: 1941,
277
+ paths: ["/channel"],
278
+ health: "/channel/health",
279
+ version: "0.1.0",
280
+ },
281
+ path,
282
+ );
283
+ const lines: string[] = [];
284
+ await status({
285
+ manifestPath: path,
286
+ fetchImpl: async () => new Response(null, { status: 200 }),
287
+ print: (l) => lines.push(l),
288
+ });
289
+ expect(lines.some((l) => l === " → http://127.0.0.1:1941/channel")).toBe(true);
290
+ } finally {
291
+ cleanup();
292
+ }
293
+ });
294
+
295
+ test("unknown service falls back to bare host:port + paths[0]", async () => {
296
+ const { path, cleanup } = makeTempPath();
297
+ try {
298
+ upsertService(
299
+ {
300
+ name: "third-party-thing",
301
+ port: 9000,
302
+ paths: ["/widget"],
303
+ health: "/health",
304
+ version: "1.0.0",
305
+ },
306
+ path,
307
+ );
308
+ const lines: string[] = [];
309
+ await status({
310
+ manifestPath: path,
311
+ fetchImpl: async () => new Response(null, { status: 200 }),
312
+ print: (l) => lines.push(l),
313
+ });
314
+ expect(lines.some((l) => l === " → http://127.0.0.1:9000/widget")).toBe(true);
315
+ } finally {
316
+ cleanup();
317
+ }
318
+ });
319
+
320
+ test("stopped services still render a URL line so the user knows where to point clients post-start", async () => {
321
+ const { path, configDir, cleanup } = makeTempPath();
322
+ try {
323
+ upsertService(
324
+ {
325
+ name: "parachute-vault",
326
+ port: 1940,
327
+ paths: ["/vault/default"],
328
+ health: "/vault/default/health",
329
+ version: "0.2.4",
330
+ },
331
+ path,
332
+ );
333
+ writePid("vault", 4242, configDir);
334
+ const lines: string[] = [];
335
+ await status({
336
+ manifestPath: path,
337
+ configDir,
338
+ alive: () => false,
339
+ fetchImpl: async () => new Response(null, { status: 200 }),
340
+ print: (l) => lines.push(l),
341
+ });
342
+ expect(lines.some((l) => l.includes("→ http://127.0.0.1:1940/vault/default/mcp"))).toBe(true);
343
+ } finally {
344
+ cleanup();
345
+ }
346
+ });
347
+ });
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type ServeEntry, bringupCommand, teardownCommand } from "../tailscale/commands.ts";
3
+
4
+ const proxyEntry: ServeEntry = {
5
+ kind: "proxy",
6
+ mount: "/",
7
+ target: "http://127.0.0.1:1940",
8
+ service: "parachute-vault",
9
+ };
10
+
11
+ const fileEntry: ServeEntry = {
12
+ kind: "file",
13
+ mount: "/.well-known/parachute.json",
14
+ target: "/Users/x/.parachute/well-known/parachute.json",
15
+ service: "well-known",
16
+ };
17
+
18
+ const subpathEntry: ServeEntry = {
19
+ kind: "proxy",
20
+ mount: "/notes",
21
+ target: "http://127.0.0.1:5173",
22
+ service: "parachute-notes",
23
+ };
24
+
25
+ describe("tailscale commands", () => {
26
+ test("bringup proxy uses https=443 and --set-path", () => {
27
+ expect(bringupCommand(proxyEntry)).toEqual([
28
+ "tailscale",
29
+ "serve",
30
+ "--bg",
31
+ "--https=443",
32
+ "--set-path=/",
33
+ "http://127.0.0.1:1940",
34
+ ]);
35
+ });
36
+
37
+ test("bringup preserves subpath mounts", () => {
38
+ expect(bringupCommand(subpathEntry)).toEqual([
39
+ "tailscale",
40
+ "serve",
41
+ "--bg",
42
+ "--https=443",
43
+ "--set-path=/notes",
44
+ "http://127.0.0.1:5173",
45
+ ]);
46
+ });
47
+
48
+ test("bringup file entry passes filesystem path as target", () => {
49
+ expect(bringupCommand(fileEntry)).toEqual([
50
+ "tailscale",
51
+ "serve",
52
+ "--bg",
53
+ "--https=443",
54
+ "--set-path=/.well-known/parachute.json",
55
+ "/Users/x/.parachute/well-known/parachute.json",
56
+ ]);
57
+ });
58
+
59
+ test("bringup uses `tailscale funnel` subcommand when funnel=true", () => {
60
+ // Modern tailscale (1.82+) split funnel out of serve. Public mode must
61
+ // route through `tailscale funnel`, not `tailscale serve --funnel`.
62
+ expect(bringupCommand(proxyEntry, { funnel: true })).toEqual([
63
+ "tailscale",
64
+ "funnel",
65
+ "--bg",
66
+ "--https=443",
67
+ "--set-path=/",
68
+ "http://127.0.0.1:1940",
69
+ ]);
70
+ });
71
+
72
+ test("bringup honors custom port", () => {
73
+ expect(bringupCommand(proxyEntry, { port: 8443 })).toEqual([
74
+ "tailscale",
75
+ "serve",
76
+ "--bg",
77
+ "--https=8443",
78
+ "--set-path=/",
79
+ "http://127.0.0.1:1940",
80
+ ]);
81
+ });
82
+
83
+ test("teardown issues off per mount", () => {
84
+ expect(teardownCommand(proxyEntry)).toEqual([
85
+ "tailscale",
86
+ "serve",
87
+ "--https=443",
88
+ "--set-path=/",
89
+ "off",
90
+ ]);
91
+ expect(teardownCommand(fileEntry)).toEqual([
92
+ "tailscale",
93
+ "serve",
94
+ "--https=443",
95
+ "--set-path=/.well-known/parachute.json",
96
+ "off",
97
+ ]);
98
+ });
99
+
100
+ test("teardown routes through `tailscale funnel` when funnel=true", () => {
101
+ // Same subcommand split applies to teardown — `serve … off` doesn't
102
+ // remove a funnel-mounted entry on 1.82+.
103
+ expect(teardownCommand(proxyEntry, { funnel: true })).toEqual([
104
+ "tailscale",
105
+ "funnel",
106
+ "--https=443",
107
+ "--set-path=/",
108
+ "off",
109
+ ]);
110
+ });
111
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getTailscaleStatus } from "../tailscale/detect.ts";
3
+ import type { CommandResult, Runner } from "../tailscale/run.ts";
4
+
5
+ function statusRunner(selfJson: Record<string, unknown>, code = 0): Runner {
6
+ return async (cmd) => {
7
+ if (cmd.slice(0, 2).join(" ") === "tailscale status") {
8
+ return {
9
+ code,
10
+ stdout: JSON.stringify({ Self: selfJson }),
11
+ stderr: "",
12
+ } as CommandResult;
13
+ }
14
+ throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
15
+ };
16
+ }
17
+
18
+ describe("getTailscaleStatus funnelCapable", () => {
19
+ test("recognizes bare 'funnel' cap key (tailscaled ≥ 1.96)", async () => {
20
+ // Aaron's tailscaled 1.96.5 emits { funnel: null } — no URL-form key.
21
+ const runner = statusRunner({
22
+ DNSName: "host.example.ts.net.",
23
+ CapMap: { funnel: null },
24
+ });
25
+ const result = await getTailscaleStatus(runner);
26
+ expect(result).toEqual({ loggedIn: true, funnelCapable: true });
27
+ });
28
+
29
+ test("recognizes legacy URL-form cap key", async () => {
30
+ const runner = statusRunner({
31
+ DNSName: "host.example.ts.net.",
32
+ CapMap: { "https://tailscale.com/cap/funnel": ["*"] },
33
+ });
34
+ const result = await getTailscaleStatus(runner);
35
+ expect(result).toEqual({ loggedIn: true, funnelCapable: true });
36
+ });
37
+
38
+ test("funnel-ports cap alone does not imply funnel capability", async () => {
39
+ // funnel-ports declares *which* ports are allowed; it is not the grant.
40
+ const runner = statusRunner({
41
+ DNSName: "host.example.ts.net.",
42
+ CapMap: {
43
+ "https://tailscale.com/cap/funnel-ports?ports=443,8443,10000": null,
44
+ },
45
+ });
46
+ const result = await getTailscaleStatus(runner);
47
+ expect(result).toEqual({ loggedIn: true, funnelCapable: false });
48
+ });
49
+
50
+ test("no funnel cap key → funnelCapable false", async () => {
51
+ const runner = statusRunner({
52
+ DNSName: "host.example.ts.net.",
53
+ CapMap: { "default-auto-update": [true] },
54
+ });
55
+ const result = await getTailscaleStatus(runner);
56
+ expect(result).toEqual({ loggedIn: true, funnelCapable: false });
57
+ });
58
+
59
+ test("logged out → both false even with funnel cap", async () => {
60
+ const runner = statusRunner({ CapMap: { funnel: null } });
61
+ const result = await getTailscaleStatus(runner);
62
+ expect(result).toEqual({ loggedIn: false, funnelCapable: false });
63
+ });
64
+ });
@@ -0,0 +1,164 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { readVaultAuthStatus } from "../vault/auth-status.ts";
6
+
7
+ function makeVaultHome(): { path: string; cleanup: () => void } {
8
+ const path = mkdtempSync(join(tmpdir(), "pcli-vault-auth-"));
9
+ return { path, cleanup: () => rmSync(path, { recursive: true, force: true }) };
10
+ }
11
+
12
+ function writeConfig(vaultHome: string, body: string): void {
13
+ writeFileSync(join(vaultHome, "config.yaml"), body);
14
+ }
15
+
16
+ function seedVault(vaultHome: string, name: string, opts: { withDb?: boolean } = {}): string {
17
+ const dir = join(vaultHome, "data", name);
18
+ mkdirSync(dir, { recursive: true });
19
+ writeFileSync(join(dir, "vault.yaml"), "# placeholder\n");
20
+ const dbPath = join(dir, "vault.db");
21
+ if (opts.withDb) writeFileSync(dbPath, ""); // exists but opaque to the fake counter
22
+ return dbPath;
23
+ }
24
+
25
+ describe("readVaultAuthStatus — config.yaml parse", () => {
26
+ test("missing config.yaml → hasOwnerPassword + hasTotp both false", () => {
27
+ const env = makeVaultHome();
28
+ try {
29
+ const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
30
+ expect(status.hasOwnerPassword).toBe(false);
31
+ expect(status.hasTotp).toBe(false);
32
+ } finally {
33
+ env.cleanup();
34
+ }
35
+ });
36
+
37
+ test("both keys present and non-empty → both true", () => {
38
+ const env = makeVaultHome();
39
+ try {
40
+ writeConfig(
41
+ env.path,
42
+ [
43
+ "port: 1940",
44
+ 'owner_password_hash: "$2b$12$somehashhere"',
45
+ 'totp_secret: "JBSWY3DPEHPK3PXP"',
46
+ "",
47
+ ].join("\n"),
48
+ );
49
+ const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
50
+ expect(status.hasOwnerPassword).toBe(true);
51
+ expect(status.hasTotp).toBe(true);
52
+ } finally {
53
+ env.cleanup();
54
+ }
55
+ });
56
+
57
+ test("empty quoted values are treated as absent (matches vault's readGlobalConfig)", () => {
58
+ const env = makeVaultHome();
59
+ try {
60
+ writeConfig(env.path, ['owner_password_hash: ""', 'totp_secret: ""', ""].join("\n"));
61
+ const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
62
+ expect(status.hasOwnerPassword).toBe(false);
63
+ expect(status.hasTotp).toBe(false);
64
+ } finally {
65
+ env.cleanup();
66
+ }
67
+ });
68
+
69
+ test("only owner_password_hash present", () => {
70
+ const env = makeVaultHome();
71
+ try {
72
+ writeConfig(env.path, 'owner_password_hash: "$2b$12$abc"\n');
73
+ const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
74
+ expect(status.hasOwnerPassword).toBe(true);
75
+ expect(status.hasTotp).toBe(false);
76
+ } finally {
77
+ env.cleanup();
78
+ }
79
+ });
80
+ });
81
+
82
+ describe("readVaultAuthStatus — vault discovery", () => {
83
+ test("no data/ dir → vaultNames empty, tokenCount 0", () => {
84
+ const env = makeVaultHome();
85
+ try {
86
+ const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 999 });
87
+ expect(status.vaultNames).toEqual([]);
88
+ expect(status.tokenCount).toBe(0);
89
+ } finally {
90
+ env.cleanup();
91
+ }
92
+ });
93
+
94
+ test("directories without vault.yaml are skipped", () => {
95
+ const env = makeVaultHome();
96
+ try {
97
+ // "real" vault
98
+ seedVault(env.path, "default", { withDb: true });
99
+ // garbage dir that happens to sit under data/
100
+ mkdirSync(join(env.path, "data", "stray"), { recursive: true });
101
+ const status = readVaultAuthStatus({ vaultHome: env.path, countTokens: () => 0 });
102
+ expect(status.vaultNames).toEqual(["default"]);
103
+ } finally {
104
+ env.cleanup();
105
+ }
106
+ });
107
+ });
108
+
109
+ describe("readVaultAuthStatus — token count resilience", () => {
110
+ test("sums across multiple vaults", () => {
111
+ const env = makeVaultHome();
112
+ try {
113
+ seedVault(env.path, "default", { withDb: true });
114
+ seedVault(env.path, "work", { withDb: true });
115
+ const status = readVaultAuthStatus({
116
+ vaultHome: env.path,
117
+ countTokens: (dbPath) => (dbPath.includes("/default/") ? 2 : 3),
118
+ });
119
+ expect(status.tokenCount).toBe(5);
120
+ expect(new Set(status.vaultNames)).toEqual(new Set(["default", "work"]));
121
+ } finally {
122
+ env.cleanup();
123
+ }
124
+ });
125
+
126
+ test("vault.yaml present but vault.db missing → count that vault as 0, keep going", () => {
127
+ const env = makeVaultHome();
128
+ try {
129
+ seedVault(env.path, "default", { withDb: false });
130
+ seedVault(env.path, "work", { withDb: true });
131
+ const status = readVaultAuthStatus({
132
+ vaultHome: env.path,
133
+ countTokens: (dbPath) => {
134
+ // Should only be called for the vault whose DB exists.
135
+ if (dbPath.includes("/default/")) throw new Error("should not open missing DB");
136
+ return 4;
137
+ },
138
+ });
139
+ expect(status.tokenCount).toBe(4);
140
+ } finally {
141
+ env.cleanup();
142
+ }
143
+ });
144
+
145
+ test("countTokens throws → tokenCount degrades to null (not partial)", () => {
146
+ const env = makeVaultHome();
147
+ try {
148
+ seedVault(env.path, "default", { withDb: true });
149
+ seedVault(env.path, "work", { withDb: true });
150
+ const status = readVaultAuthStatus({
151
+ vaultHome: env.path,
152
+ countTokens: (dbPath) => {
153
+ if (dbPath.includes("/work/")) throw new Error("locked");
154
+ return 2;
155
+ },
156
+ });
157
+ // Even though "default" succeeded with 2, we return null — callers
158
+ // shouldn't see a misleading partial count.
159
+ expect(status.tokenCount).toBeNull();
160
+ } finally {
161
+ env.cleanup();
162
+ }
163
+ });
164
+ });