@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,484 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { readCloudflaredState, writeCloudflaredState } from "../cloudflare/state.ts";
6
+ import {
7
+ type CloudflaredSpawner,
8
+ exposeCloudflareOff,
9
+ exposeCloudflareUp,
10
+ } from "../commands/expose-cloudflare.ts";
11
+ import type { CommandResult, Runner } from "../tailscale/run.ts";
12
+
13
+ interface TestEnv {
14
+ configDir: string;
15
+ manifestPath: string;
16
+ statePath: string;
17
+ configPath: string;
18
+ logPath: string;
19
+ cloudflaredHome: string;
20
+ cleanup: () => void;
21
+ }
22
+
23
+ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): TestEnv {
24
+ const includeVault = opts.includeVault ?? true;
25
+ const loggedIn = opts.loggedIn ?? true;
26
+
27
+ const dir = mkdtempSync(join(tmpdir(), "pcli-cf-cmd-"));
28
+ const configDir = join(dir, "parachute");
29
+ const cloudflaredHome = join(dir, "cloudflared");
30
+ const manifestPath = join(configDir, "services.json");
31
+ const statePath = join(configDir, "cloudflared-state.json");
32
+ const configPath = join(configDir, "cloudflared", "config.yml");
33
+ const logPath = join(configDir, "cloudflared", "cloudflared.log");
34
+
35
+ require("node:fs").mkdirSync(configDir, { recursive: true });
36
+ require("node:fs").mkdirSync(cloudflaredHome, { recursive: true });
37
+
38
+ if (loggedIn) {
39
+ writeFileSync(join(cloudflaredHome, "cert.pem"), "---");
40
+ }
41
+
42
+ const services = includeVault
43
+ ? [
44
+ {
45
+ name: "parachute-vault",
46
+ port: 1940,
47
+ paths: ["/vault/default"],
48
+ health: "/vault/default/health",
49
+ version: "0.3.0",
50
+ },
51
+ ]
52
+ : [];
53
+ writeFileSync(manifestPath, JSON.stringify({ services }, null, 2));
54
+
55
+ return {
56
+ configDir,
57
+ manifestPath,
58
+ statePath,
59
+ configPath,
60
+ logPath,
61
+ cloudflaredHome,
62
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
63
+ };
64
+ }
65
+
66
+ interface RunnerCall {
67
+ cmd: string[];
68
+ }
69
+
70
+ function queueRunner(results: CommandResult[]): { runner: Runner; calls: RunnerCall[] } {
71
+ const calls: RunnerCall[] = [];
72
+ let i = 0;
73
+ const runner: Runner = async (cmd) => {
74
+ calls.push({ cmd: [...cmd] });
75
+ const out = results[i++];
76
+ if (!out) throw new Error(`runner called more than the ${results.length} queued results`);
77
+ return out;
78
+ };
79
+ return { runner, calls };
80
+ }
81
+
82
+ function fakeSpawner(pid: number): { spawner: CloudflaredSpawner; seen: string[][] } {
83
+ const seen: string[][] = [];
84
+ const spawner: CloudflaredSpawner = {
85
+ spawn(cmd, logFile) {
86
+ seen.push([...cmd]);
87
+ // Touch the log file so tests that probe existsSync(logPath) can assert it.
88
+ require("node:fs").mkdirSync(require("node:path").dirname(logFile), { recursive: true });
89
+ writeFileSync(logFile, "");
90
+ return pid;
91
+ },
92
+ };
93
+ return { spawner, seen };
94
+ }
95
+
96
+ describe("exposeCloudflareUp", () => {
97
+ test("happy path: creates tunnel, routes DNS, writes config + state, spawns cloudflared", async () => {
98
+ const env = makeEnv();
99
+ try {
100
+ const uuid = "2c1a7c7e-1234-5678-9abc-def012345678";
101
+ const { runner, calls } = queueRunner([
102
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version preflight
103
+ { code: 0, stdout: "[]", stderr: "" }, // tunnel list (none yet)
104
+ {
105
+ code: 0,
106
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
107
+ stderr: "",
108
+ }, // tunnel create
109
+ { code: 0, stdout: "", stderr: "" }, // route dns
110
+ ]);
111
+ const { spawner, seen } = fakeSpawner(42000);
112
+ const logs: string[] = [];
113
+
114
+ const code = await exposeCloudflareUp("vault.example.com", {
115
+ runner,
116
+ spawner,
117
+ alive: () => false,
118
+ kill: () => {},
119
+ log: (l) => logs.push(l),
120
+ manifestPath: env.manifestPath,
121
+ statePath: env.statePath,
122
+ configPath: env.configPath,
123
+ logPath: env.logPath,
124
+ cloudflaredHome: env.cloudflaredHome,
125
+ now: () => new Date("2026-04-22T12:00:00Z"),
126
+ });
127
+
128
+ expect(code).toBe(0);
129
+ expect(calls.map((c) => c.cmd[0])).toEqual([
130
+ "cloudflared",
131
+ "cloudflared",
132
+ "cloudflared",
133
+ "cloudflared",
134
+ ]);
135
+ expect(calls[0]!.cmd).toEqual(["cloudflared", "--version"]);
136
+ expect(calls[1]!.cmd).toEqual(["cloudflared", "tunnel", "list", "--output", "json"]);
137
+ expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create", "parachute"]);
138
+ expect(calls[3]!.cmd).toEqual([
139
+ "cloudflared",
140
+ "tunnel",
141
+ "route",
142
+ "dns",
143
+ "--overwrite-dns",
144
+ "parachute",
145
+ "vault.example.com",
146
+ ]);
147
+ expect(seen[0]).toEqual(["cloudflared", "tunnel", "--config", env.configPath, "run"]);
148
+
149
+ const state = readCloudflaredState(env.statePath);
150
+ expect(state).toEqual({
151
+ version: 1,
152
+ pid: 42000,
153
+ tunnelUuid: uuid,
154
+ tunnelName: "parachute",
155
+ hostname: "vault.example.com",
156
+ startedAt: "2026-04-22T12:00:00.000Z",
157
+ configPath: env.configPath,
158
+ });
159
+
160
+ const yaml = readFileSync(env.configPath, "utf8");
161
+ expect(yaml).toContain(`tunnel: ${uuid}`);
162
+ expect(yaml).toContain("- hostname: vault.example.com");
163
+ expect(yaml).toContain("service: http://localhost:1940");
164
+
165
+ // Security copy surfaces both paths plus a pointer to the auth doc.
166
+ const joined = logs.join("\n");
167
+ expect(joined).toContain("parachute auth set-password");
168
+ expect(joined).toContain("parachute vault tokens create");
169
+ expect(joined).toContain("auth-model.md");
170
+ } finally {
171
+ env.cleanup();
172
+ }
173
+ });
174
+
175
+ test("reuses existing tunnel when name already present", async () => {
176
+ const env = makeEnv();
177
+ try {
178
+ const uuid = "bbbbbbbb-0000-0000-0000-000000000002";
179
+ const { runner, calls } = queueRunner([
180
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
181
+ {
182
+ code: 0,
183
+ stdout: JSON.stringify([{ id: uuid, name: "parachute" }]),
184
+ stderr: "",
185
+ },
186
+ { code: 0, stdout: "", stderr: "" }, // route dns
187
+ ]);
188
+ const { spawner } = fakeSpawner(42001);
189
+ const logs: string[] = [];
190
+
191
+ const code = await exposeCloudflareUp("vault.example.com", {
192
+ runner,
193
+ spawner,
194
+ alive: () => false,
195
+ kill: () => {},
196
+ log: (l) => logs.push(l),
197
+ manifestPath: env.manifestPath,
198
+ statePath: env.statePath,
199
+ configPath: env.configPath,
200
+ logPath: env.logPath,
201
+ cloudflaredHome: env.cloudflaredHome,
202
+ });
203
+ expect(code).toBe(0);
204
+ // No `tunnel create` — only list + route.
205
+ const cmds = calls.map((c) => c.cmd.join(" "));
206
+ expect(cmds.some((c) => c.startsWith("cloudflared tunnel create"))).toBe(false);
207
+ expect(logs.some((l) => l.includes("Reusing existing tunnel"))).toBe(true);
208
+ } finally {
209
+ env.cleanup();
210
+ }
211
+ });
212
+
213
+ test("rejects invalid hostnames up front (no cloudflared calls)", async () => {
214
+ const env = makeEnv();
215
+ try {
216
+ const { runner, calls } = queueRunner([]);
217
+ const { spawner } = fakeSpawner(0);
218
+ const logs: string[] = [];
219
+
220
+ const code = await exposeCloudflareUp("not-a-hostname", {
221
+ runner,
222
+ spawner,
223
+ log: (l) => logs.push(l),
224
+ manifestPath: env.manifestPath,
225
+ statePath: env.statePath,
226
+ configPath: env.configPath,
227
+ logPath: env.logPath,
228
+ cloudflaredHome: env.cloudflaredHome,
229
+ });
230
+
231
+ expect(code).toBe(1);
232
+ expect(calls).toHaveLength(0);
233
+ expect(logs.join("\n")).toContain("--domain must be a valid hostname");
234
+ } finally {
235
+ env.cleanup();
236
+ }
237
+ });
238
+
239
+ test("prints install hint when cloudflared is missing", async () => {
240
+ const env = makeEnv();
241
+ try {
242
+ const { runner, calls } = queueRunner([
243
+ { code: 127, stdout: "", stderr: "command not found" },
244
+ ]);
245
+ const { spawner } = fakeSpawner(0);
246
+ const logs: string[] = [];
247
+
248
+ const code = await exposeCloudflareUp("vault.example.com", {
249
+ runner,
250
+ spawner,
251
+ log: (l) => logs.push(l),
252
+ manifestPath: env.manifestPath,
253
+ statePath: env.statePath,
254
+ configPath: env.configPath,
255
+ logPath: env.logPath,
256
+ cloudflaredHome: env.cloudflaredHome,
257
+ });
258
+
259
+ expect(code).toBe(1);
260
+ expect(calls).toHaveLength(1);
261
+ expect(logs.join("\n")).toContain("cloudflared is not installed");
262
+ } finally {
263
+ env.cleanup();
264
+ }
265
+ });
266
+
267
+ test("prints login hint when cert.pem is absent", async () => {
268
+ const env = makeEnv({ loggedIn: false });
269
+ try {
270
+ const { runner } = queueRunner([{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }]);
271
+ const { spawner } = fakeSpawner(0);
272
+ const logs: string[] = [];
273
+
274
+ const code = await exposeCloudflareUp("vault.example.com", {
275
+ runner,
276
+ spawner,
277
+ log: (l) => logs.push(l),
278
+ manifestPath: env.manifestPath,
279
+ statePath: env.statePath,
280
+ configPath: env.configPath,
281
+ logPath: env.logPath,
282
+ cloudflaredHome: env.cloudflaredHome,
283
+ });
284
+
285
+ expect(code).toBe(1);
286
+ expect(logs.join("\n")).toContain("cloudflared tunnel login");
287
+ } finally {
288
+ env.cleanup();
289
+ }
290
+ });
291
+
292
+ test("errors out when vault isn't installed", async () => {
293
+ const env = makeEnv({ includeVault: false });
294
+ try {
295
+ const { runner } = queueRunner([{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }]);
296
+ const { spawner } = fakeSpawner(0);
297
+ const logs: string[] = [];
298
+
299
+ const code = await exposeCloudflareUp("vault.example.com", {
300
+ runner,
301
+ spawner,
302
+ log: (l) => logs.push(l),
303
+ manifestPath: env.manifestPath,
304
+ statePath: env.statePath,
305
+ configPath: env.configPath,
306
+ logPath: env.logPath,
307
+ cloudflaredHome: env.cloudflaredHome,
308
+ });
309
+
310
+ expect(code).toBe(1);
311
+ expect(logs.join("\n")).toContain("parachute install vault");
312
+ } finally {
313
+ env.cleanup();
314
+ }
315
+ });
316
+
317
+ test("route-dns failure surfaces a dashboard-pointing hint", async () => {
318
+ const env = makeEnv();
319
+ try {
320
+ const uuid = "2c1a7c7e-1234-5678-9abc-def012345678";
321
+ const { runner } = queueRunner([
322
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
323
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
324
+ {
325
+ code: 1,
326
+ stdout: "",
327
+ stderr: "Failed to add route: code: 1000, reason: Invalid DNS zone",
328
+ },
329
+ ]);
330
+ const { spawner } = fakeSpawner(0);
331
+ const logs: string[] = [];
332
+
333
+ const code = await exposeCloudflareUp("vault.example.com", {
334
+ runner,
335
+ spawner,
336
+ log: (l) => logs.push(l),
337
+ manifestPath: env.manifestPath,
338
+ statePath: env.statePath,
339
+ configPath: env.configPath,
340
+ logPath: env.logPath,
341
+ cloudflaredHome: env.cloudflaredHome,
342
+ });
343
+
344
+ expect(code).toBe(1);
345
+ const joined = logs.join("\n");
346
+ expect(joined).toContain("dash.cloudflare.com");
347
+ expect(joined).toContain("Invalid DNS zone");
348
+ } finally {
349
+ env.cleanup();
350
+ }
351
+ });
352
+
353
+ test("stops a prior cloudflared process before spawning a new one", async () => {
354
+ const env = makeEnv();
355
+ try {
356
+ writeCloudflaredState(
357
+ {
358
+ version: 1,
359
+ pid: 99999,
360
+ tunnelUuid: "old-tunnel-uuid",
361
+ tunnelName: "parachute",
362
+ hostname: "vault.example.com",
363
+ startedAt: "2026-04-21T00:00:00.000Z",
364
+ configPath: env.configPath,
365
+ },
366
+ env.statePath,
367
+ );
368
+
369
+ const { runner } = queueRunner([
370
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
371
+ {
372
+ code: 0,
373
+ stdout: JSON.stringify([
374
+ { id: "cccccccc-0000-0000-0000-000000000003", name: "parachute" },
375
+ ]),
376
+ stderr: "",
377
+ },
378
+ { code: 0, stdout: "", stderr: "" }, // route dns
379
+ ]);
380
+ const { spawner } = fakeSpawner(42010);
381
+ const killed: Array<{ pid: number; sig: NodeJS.Signals | number }> = [];
382
+
383
+ const code = await exposeCloudflareUp("vault.example.com", {
384
+ runner,
385
+ spawner,
386
+ alive: (pid) => pid === 99999,
387
+ kill: (pid, sig) => killed.push({ pid, sig }),
388
+ log: () => {},
389
+ manifestPath: env.manifestPath,
390
+ statePath: env.statePath,
391
+ configPath: env.configPath,
392
+ logPath: env.logPath,
393
+ cloudflaredHome: env.cloudflaredHome,
394
+ });
395
+
396
+ expect(code).toBe(0);
397
+ expect(killed).toEqual([{ pid: 99999, sig: "SIGTERM" }]);
398
+ const state = readCloudflaredState(env.statePath);
399
+ expect(state?.pid).toBe(42010);
400
+ } finally {
401
+ env.cleanup();
402
+ }
403
+ });
404
+ });
405
+
406
+ describe("exposeCloudflareOff", () => {
407
+ test("no-op when no state exists", async () => {
408
+ const env = makeEnv();
409
+ try {
410
+ const logs: string[] = [];
411
+ const code = await exposeCloudflareOff({
412
+ statePath: env.statePath,
413
+ log: (l) => logs.push(l),
414
+ });
415
+ expect(code).toBe(0);
416
+ expect(logs.join("\n")).toContain("Nothing to tear down");
417
+ } finally {
418
+ env.cleanup();
419
+ }
420
+ });
421
+
422
+ test("SIGTERMs the process and clears state", async () => {
423
+ const env = makeEnv();
424
+ try {
425
+ writeCloudflaredState(
426
+ {
427
+ version: 1,
428
+ pid: 55555,
429
+ tunnelUuid: "dddddddd-0000-0000-0000-000000000004",
430
+ tunnelName: "parachute",
431
+ hostname: "vault.example.com",
432
+ startedAt: "2026-04-22T12:00:00.000Z",
433
+ configPath: env.configPath,
434
+ },
435
+ env.statePath,
436
+ );
437
+ const killed: number[] = [];
438
+ const logs: string[] = [];
439
+ const code = await exposeCloudflareOff({
440
+ statePath: env.statePath,
441
+ alive: () => true,
442
+ kill: (pid) => killed.push(pid),
443
+ log: (l) => logs.push(l),
444
+ });
445
+ expect(code).toBe(0);
446
+ expect(killed).toEqual([55555]);
447
+ expect(existsSync(env.statePath)).toBe(false);
448
+ // Reassures the user that the tunnel definition isn't lost.
449
+ expect(logs.join("\n")).toContain("remains defined in Cloudflare");
450
+ } finally {
451
+ env.cleanup();
452
+ }
453
+ });
454
+
455
+ test("clears stale state when the process is already gone", async () => {
456
+ const env = makeEnv();
457
+ try {
458
+ writeCloudflaredState(
459
+ {
460
+ version: 1,
461
+ pid: 55556,
462
+ tunnelUuid: "eeeeeeee-0000-0000-0000-000000000005",
463
+ tunnelName: "parachute",
464
+ hostname: "vault.example.com",
465
+ startedAt: "2026-04-22T12:00:00.000Z",
466
+ configPath: env.configPath,
467
+ },
468
+ env.statePath,
469
+ );
470
+ const killed: number[] = [];
471
+ const code = await exposeCloudflareOff({
472
+ statePath: env.statePath,
473
+ alive: () => false,
474
+ kill: (pid) => killed.push(pid),
475
+ log: () => {},
476
+ });
477
+ expect(code).toBe(0);
478
+ expect(killed).toEqual([]);
479
+ expect(existsSync(env.statePath)).toBe(false);
480
+ } finally {
481
+ env.cleanup();
482
+ }
483
+ });
484
+ });