@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,283 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { SCRIBE_AUTH_ENV_KEY, SCRIBE_URL_ENV_KEY, autoWireScribeAuth } from "../auto-wire.ts";
6
+ import { writePid } from "../process-state.ts";
7
+
8
+ function makeHarness(): { dir: string; cleanup: () => void } {
9
+ const dir = mkdtempSync(join(tmpdir(), "pcli-autowire-"));
10
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
11
+ }
12
+
13
+ const DEFAULT_SCRIBE_URL = "http://127.0.0.1:1943";
14
+
15
+ describe("autoWireScribeAuth", () => {
16
+ test("first call: writes new token + SCRIBE_URL to vault .env and token to scribe config.json", async () => {
17
+ const h = makeHarness();
18
+ try {
19
+ const logs: string[] = [];
20
+ const result = await autoWireScribeAuth({
21
+ configDir: h.dir,
22
+ randomToken: () => "deadbeef00".repeat(6),
23
+ log: (l) => logs.push(l),
24
+ });
25
+ expect(result.generated).toBe(true);
26
+ expect(result.token).toBe("deadbeef00".repeat(6));
27
+ expect(result.scribeUrl).toBe(DEFAULT_SCRIBE_URL);
28
+
29
+ const envText = readFileSync(join(h.dir, "vault", ".env"), "utf8");
30
+ expect(envText).toContain(`${SCRIBE_AUTH_ENV_KEY}=${result.token}`);
31
+ expect(envText).toContain(`${SCRIBE_URL_ENV_KEY}=${DEFAULT_SCRIBE_URL}`);
32
+
33
+ const scribeCfg = JSON.parse(readFileSync(join(h.dir, "scribe", "config.json"), "utf8"));
34
+ expect(scribeCfg).toEqual({ auth: { required_token: result.token } });
35
+
36
+ expect(logs.join("\n")).toMatch(/Auto-wired shared secret \+ SCRIBE_URL/);
37
+ } finally {
38
+ h.cleanup();
39
+ }
40
+ });
41
+
42
+ test("idempotent: pre-existing SCRIBE_AUTH_TOKEN in vault .env is preserved; SCRIBE_URL still wired", async () => {
43
+ const h = makeHarness();
44
+ try {
45
+ // Seed a prior wire (or operator-set token). The helper must not
46
+ // regenerate on repeat install — churning the token would break a
47
+ // running vault worker that already has the old one in its process env.
48
+ // SCRIBE_URL was missing from the prior write (this is a 0.2.4 → 0.2.5
49
+ // upgrade scenario), so it should still be added.
50
+ const envPath = join(h.dir, "vault", ".env");
51
+ const seed = "seeded-token-abc123";
52
+ mkdirSync(join(h.dir, "vault"), { recursive: true });
53
+ writeFileSync(envPath, `FOO=bar\n${SCRIBE_AUTH_ENV_KEY}=${seed}\nOTHER=baz\n`);
54
+
55
+ const result = await autoWireScribeAuth({
56
+ configDir: h.dir,
57
+ randomToken: () => "should-not-be-used",
58
+ log: () => {},
59
+ });
60
+ expect(result.generated).toBe(false);
61
+ expect(result.token).toBe(seed);
62
+ expect(result.scribeUrl).toBe(DEFAULT_SCRIBE_URL);
63
+
64
+ const envText = readFileSync(envPath, "utf8");
65
+ expect(envText).toContain("FOO=bar");
66
+ expect(envText).toContain(`${SCRIBE_AUTH_ENV_KEY}=${seed}`);
67
+ expect(envText).toContain("OTHER=baz");
68
+ expect(envText).toContain(`${SCRIBE_URL_ENV_KEY}=${DEFAULT_SCRIBE_URL}`);
69
+ // And scribe config.json gets the seeded value (so drift between the
70
+ // two sides repairs on repeat install).
71
+ const scribeCfg = JSON.parse(readFileSync(join(h.dir, "scribe", "config.json"), "utf8"));
72
+ expect(scribeCfg.auth.required_token).toBe(seed);
73
+ } finally {
74
+ h.cleanup();
75
+ }
76
+ });
77
+
78
+ test("preserves operator-set SCRIBE_URL (e.g., a non-loopback override)", async () => {
79
+ const h = makeHarness();
80
+ try {
81
+ const envPath = join(h.dir, "vault", ".env");
82
+ const customUrl = "http://scribe.lan:1943";
83
+ mkdirSync(join(h.dir, "vault"), { recursive: true });
84
+ writeFileSync(envPath, `${SCRIBE_URL_ENV_KEY}=${customUrl}\n`);
85
+
86
+ const result = await autoWireScribeAuth({
87
+ configDir: h.dir,
88
+ randomToken: () => "fresh-token",
89
+ log: () => {},
90
+ });
91
+ expect(result.scribeUrl).toBe(customUrl);
92
+
93
+ const envText = readFileSync(envPath, "utf8");
94
+ expect(envText).toContain(`${SCRIBE_URL_ENV_KEY}=${customUrl}`);
95
+ expect(envText).not.toContain(`${SCRIBE_URL_ENV_KEY}=${DEFAULT_SCRIBE_URL}`);
96
+ } finally {
97
+ h.cleanup();
98
+ }
99
+ });
100
+
101
+ test("appends SCRIBE_AUTH_TOKEN without clobbering other vault .env keys", async () => {
102
+ const h = makeHarness();
103
+ try {
104
+ const envPath = join(h.dir, "vault", ".env");
105
+ mkdirSync(join(h.dir, "vault"), { recursive: true });
106
+ writeFileSync(envPath, "VAULT_SECRET=xyz\nLOG_LEVEL=debug\n");
107
+
108
+ await autoWireScribeAuth({
109
+ configDir: h.dir,
110
+ randomToken: () => "fresh-token-123",
111
+ log: () => {},
112
+ });
113
+
114
+ const envText = readFileSync(envPath, "utf8");
115
+ expect(envText).toContain("VAULT_SECRET=xyz");
116
+ expect(envText).toContain("LOG_LEVEL=debug");
117
+ expect(envText).toContain(`${SCRIBE_AUTH_ENV_KEY}=fresh-token-123`);
118
+ expect(envText).toContain(`${SCRIBE_URL_ENV_KEY}=${DEFAULT_SCRIBE_URL}`);
119
+ } finally {
120
+ h.cleanup();
121
+ }
122
+ });
123
+
124
+ test("merges into existing scribe config.json, preserving other keys", async () => {
125
+ const h = makeHarness();
126
+ try {
127
+ const scribeCfgPath = join(h.dir, "scribe", "config.json");
128
+ mkdirSync(join(h.dir, "scribe"), { recursive: true });
129
+ writeFileSync(
130
+ scribeCfgPath,
131
+ JSON.stringify({ whisper: { model: "medium.en" }, auth: { other: "kept" } }, null, 2),
132
+ );
133
+
134
+ await autoWireScribeAuth({
135
+ configDir: h.dir,
136
+ randomToken: () => "tok",
137
+ log: () => {},
138
+ });
139
+
140
+ const cfg = JSON.parse(readFileSync(scribeCfgPath, "utf8"));
141
+ expect(cfg.whisper.model).toBe("medium.en");
142
+ expect(cfg.auth.other).toBe("kept");
143
+ expect(cfg.auth.required_token).toBe("tok");
144
+ } finally {
145
+ h.cleanup();
146
+ }
147
+ });
148
+
149
+ test("handles quoted token values in vault .env (preserves the raw value)", async () => {
150
+ const h = makeHarness();
151
+ try {
152
+ const envPath = join(h.dir, "vault", ".env");
153
+ mkdirSync(join(h.dir, "vault"), { recursive: true });
154
+ writeFileSync(envPath, `${SCRIBE_AUTH_ENV_KEY}="quoted-value"\n`);
155
+
156
+ const result = await autoWireScribeAuth({
157
+ configDir: h.dir,
158
+ randomToken: () => "should-not-be-used",
159
+ log: () => {},
160
+ });
161
+ expect(result.generated).toBe(false);
162
+ expect(result.token).toBe("quoted-value");
163
+
164
+ const cfg = JSON.parse(readFileSync(join(h.dir, "scribe", "config.json"), "utf8"));
165
+ expect(cfg.auth.required_token).toBe("quoted-value");
166
+ } finally {
167
+ h.cleanup();
168
+ }
169
+ });
170
+
171
+ test("creates vault/ and scribe/ dirs if missing", async () => {
172
+ const h = makeHarness();
173
+ try {
174
+ expect(existsSync(join(h.dir, "vault"))).toBe(false);
175
+ expect(existsSync(join(h.dir, "scribe"))).toBe(false);
176
+ await autoWireScribeAuth({
177
+ configDir: h.dir,
178
+ randomToken: () => "tok",
179
+ log: () => {},
180
+ });
181
+ expect(existsSync(join(h.dir, "vault", ".env"))).toBe(true);
182
+ expect(existsSync(join(h.dir, "scribe", "config.json"))).toBe(true);
183
+ } finally {
184
+ h.cleanup();
185
+ }
186
+ });
187
+
188
+ test("restarts vault when the worker is running so the new env takes effect", async () => {
189
+ // The whole point of writing SCRIBE_URL is that vault's transcription
190
+ // worker can find scribe. If vault is already running when we wire,
191
+ // the worker keeps its stale env until we restart it — exactly the
192
+ // launch-day footgun where voice memos sat on `_Transcript pending._`
193
+ // forever. Mirrors the auto-restart-on-expose pattern from PR #39.
194
+ const h = makeHarness();
195
+ try {
196
+ writePid("vault", 4242, h.dir);
197
+ const restartCalls: string[] = [];
198
+ const result = await autoWireScribeAuth({
199
+ configDir: h.dir,
200
+ randomToken: () => "tok",
201
+ log: () => {},
202
+ alive: () => true,
203
+ restartService: async (short) => {
204
+ restartCalls.push(short);
205
+ return 0;
206
+ },
207
+ });
208
+ expect(result.restartedVault).toBe(true);
209
+ expect(restartCalls).toEqual(["vault"]);
210
+ } finally {
211
+ h.cleanup();
212
+ }
213
+ });
214
+
215
+ test("does not restart vault when nothing changed (idempotent repeat call)", async () => {
216
+ // Both keys already present, vault running — there's nothing to pick up
217
+ // on restart, so we shouldn't churn a healthy daemon.
218
+ const h = makeHarness();
219
+ try {
220
+ mkdirSync(join(h.dir, "vault"), { recursive: true });
221
+ writeFileSync(
222
+ join(h.dir, "vault", ".env"),
223
+ `${SCRIBE_AUTH_ENV_KEY}=already\n${SCRIBE_URL_ENV_KEY}=${DEFAULT_SCRIBE_URL}\n`,
224
+ );
225
+ writePid("vault", 4242, h.dir);
226
+ const restartCalls: string[] = [];
227
+ const result = await autoWireScribeAuth({
228
+ configDir: h.dir,
229
+ log: () => {},
230
+ alive: () => true,
231
+ restartService: async (short) => {
232
+ restartCalls.push(short);
233
+ return 0;
234
+ },
235
+ });
236
+ expect(result.restartedVault).toBe(false);
237
+ expect(restartCalls).toEqual([]);
238
+ } finally {
239
+ h.cleanup();
240
+ }
241
+ });
242
+
243
+ test("does not restart vault when it isn't running", async () => {
244
+ // No PID file → processState reports "unknown" → no restart. Avoids
245
+ // launching a daemon as a side effect of install.
246
+ const h = makeHarness();
247
+ try {
248
+ const restartCalls: string[] = [];
249
+ const result = await autoWireScribeAuth({
250
+ configDir: h.dir,
251
+ randomToken: () => "tok",
252
+ log: () => {},
253
+ restartService: async (short) => {
254
+ restartCalls.push(short);
255
+ return 0;
256
+ },
257
+ });
258
+ expect(result.restartedVault).toBe(false);
259
+ expect(restartCalls).toEqual([]);
260
+ } finally {
261
+ h.cleanup();
262
+ }
263
+ });
264
+
265
+ test("logs a clear hint when the auto-restart fails", async () => {
266
+ const h = makeHarness();
267
+ try {
268
+ writePid("vault", 4242, h.dir);
269
+ const logs: string[] = [];
270
+ const result = await autoWireScribeAuth({
271
+ configDir: h.dir,
272
+ randomToken: () => "tok",
273
+ log: (l) => logs.push(l),
274
+ alive: () => true,
275
+ restartService: async () => 1,
276
+ });
277
+ expect(result.restartedVault).toBe(false);
278
+ expect(logs.join("\n")).toMatch(/vault restart failed.*parachute restart vault/);
279
+ } finally {
280
+ h.cleanup();
281
+ }
282
+ });
283
+ });
@@ -0,0 +1,192 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const CLI = join(import.meta.dir, "..", "cli.ts");
7
+
8
+ async function runCli(
9
+ args: string[],
10
+ env: Record<string, string> = {},
11
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
12
+ const proc = Bun.spawn(["bun", CLI, ...args], {
13
+ stdout: "pipe",
14
+ stderr: "pipe",
15
+ env: {
16
+ ...process.env,
17
+ HOME: "/tmp/parachute-hub-nonexistent-home",
18
+ PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
19
+ ...env,
20
+ },
21
+ });
22
+ const [stdout, stderr, code] = await Promise.all([
23
+ new Response(proc.stdout).text(),
24
+ new Response(proc.stderr).text(),
25
+ proc.exited,
26
+ ]);
27
+ return { code, stdout, stderr };
28
+ }
29
+
30
+ describe("cli", () => {
31
+ test("--version prints version from package.json", async () => {
32
+ const { code, stdout } = await runCli(["--version"]);
33
+ expect(code).toBe(0);
34
+ expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
35
+ });
36
+
37
+ test("--help lists commands", async () => {
38
+ const { code, stdout } = await runCli(["--help"]);
39
+ expect(code).toBe(0);
40
+ expect(stdout).toMatch(/parachute install/);
41
+ expect(stdout).toMatch(/parachute status/);
42
+ expect(stdout).toMatch(/parachute auth/);
43
+ expect(stdout).toMatch(/parachute vault/);
44
+ expect(stdout).toMatch(/expose tailnet/);
45
+ expect(stdout).toMatch(/expose public/);
46
+ });
47
+
48
+ test("expose with unknown layer exits 1", async () => {
49
+ const { code, stderr } = await runCli(["expose", "wat"]);
50
+ expect(code).toBe(1);
51
+ expect(stderr).toMatch(/unknown layer/);
52
+ expect(stderr).toMatch(/expose public/);
53
+ });
54
+
55
+ test("no args prints help", async () => {
56
+ const { code, stdout } = await runCli([]);
57
+ expect(code).toBe(0);
58
+ expect(stdout).toMatch(/Usage:/);
59
+ });
60
+
61
+ test("install with no service name exits 1", async () => {
62
+ const { code, stderr } = await runCli(["install"]);
63
+ expect(code).toBe(1);
64
+ expect(stderr).toMatch(/usage: parachute install/);
65
+ });
66
+
67
+ test("unknown command exits 1", async () => {
68
+ const { code, stderr } = await runCli(["wat"]);
69
+ expect(code).toBe(1);
70
+ expect(stderr).toMatch(/unknown command/);
71
+ });
72
+ });
73
+
74
+ describe("cli per-subcommand help", () => {
75
+ test("install --help shows install usage", async () => {
76
+ const { code, stdout } = await runCli(["install", "--help"]);
77
+ expect(code).toBe(0);
78
+ expect(stdout).toMatch(/parachute install/);
79
+ expect(stdout).toMatch(/bun add -g/);
80
+ });
81
+
82
+ test("install -h also works", async () => {
83
+ const { code, stdout } = await runCli(["install", "-h"]);
84
+ expect(code).toBe(0);
85
+ expect(stdout).toMatch(/parachute install/);
86
+ });
87
+
88
+ test("status --help shows status usage", async () => {
89
+ const { code, stdout } = await runCli(["status", "--help"]);
90
+ expect(code).toBe(0);
91
+ expect(stdout).toMatch(/parachute status/);
92
+ expect(stdout).toMatch(/Exit codes/);
93
+ });
94
+
95
+ test("expose --help shows both layers and Funnel notes", async () => {
96
+ const { code, stdout } = await runCli(["expose", "--help"]);
97
+ expect(code).toBe(0);
98
+ expect(stdout).toMatch(/expose tailnet/);
99
+ expect(stdout).toMatch(/expose public/);
100
+ expect(stdout).toMatch(/Funnel/);
101
+ expect(stdout).toMatch(/443/);
102
+ expect(stdout).toMatch(/--cloudflare/);
103
+ expect(stdout).toMatch(/--domain/);
104
+ });
105
+
106
+ test("expose public --cloudflare without --domain exits 1 with usage hint", async () => {
107
+ const { code, stderr } = await runCli(["expose", "public", "--cloudflare"]);
108
+ expect(code).toBe(1);
109
+ expect(stderr).toMatch(/--domain <hostname> is required/);
110
+ expect(stderr).toMatch(/dash\.cloudflare\.com/);
111
+ });
112
+
113
+ test("expose tailnet --cloudflare is rejected (cloudflare is public-only)", async () => {
114
+ const { code, stderr } = await runCli([
115
+ "expose",
116
+ "tailnet",
117
+ "--cloudflare",
118
+ "--domain",
119
+ "vault.example.com",
120
+ ]);
121
+ expect(code).toBe(1);
122
+ expect(stderr).toMatch(/--cloudflare only applies to `public`/);
123
+ });
124
+
125
+ test("expose with missing --domain value exits 1", async () => {
126
+ const { code, stderr } = await runCli(["expose", "public", "--cloudflare", "--domain"]);
127
+ expect(code).toBe(1);
128
+ expect(stderr).toMatch(/--domain requires a hostname argument/);
129
+ });
130
+
131
+ test("expose tailnet --help shows full expose help", async () => {
132
+ const { code, stdout } = await runCli(["expose", "tailnet", "--help"]);
133
+ expect(code).toBe(0);
134
+ expect(stdout).toMatch(/expose tailnet/);
135
+ });
136
+
137
+ test("vault with no args forwards --help to parachute-vault", async () => {
138
+ // Clear PATH so the dispatcher reliably hits the ENOENT branch — that
139
+ // proves the CLI is forwarding rather than printing local help. Spawn
140
+ // bun by absolute path so the outer shell-out isn't affected by PATH=''.
141
+ const proc = Bun.spawn([process.execPath, CLI, "vault"], {
142
+ stdout: "pipe",
143
+ stderr: "pipe",
144
+ env: {
145
+ ...process.env,
146
+ PATH: "",
147
+ HOME: "/tmp/parachute-hub-nonexistent-home",
148
+ PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
149
+ },
150
+ });
151
+ const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
152
+ expect(code).toBe(127);
153
+ expect(stderr).toMatch(/parachute-vault not found on PATH/);
154
+ expect(stderr).toMatch(/parachute install vault/);
155
+ });
156
+
157
+ test("vault tokens create in non-TTY passes through without prompting", async () => {
158
+ // Spawned-subprocess stdio is piped, so isTtyInteractive() returns false
159
+ // and the command falls through to the passthrough. Clearing PATH forces
160
+ // ENOENT — same probe as the `vault no-args` test. If we regressed into
161
+ // prompting, this subprocess would hang on stdin instead of exiting 127.
162
+ const proc = Bun.spawn([process.execPath, CLI, "vault", "tokens", "create"], {
163
+ stdout: "pipe",
164
+ stderr: "pipe",
165
+ env: {
166
+ ...process.env,
167
+ PATH: "",
168
+ HOME: "/tmp/parachute-hub-nonexistent-home",
169
+ PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
170
+ },
171
+ });
172
+ const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
173
+ expect(code).toBe(127);
174
+ expect(stderr).toMatch(/parachute-vault not found on PATH/);
175
+ });
176
+ });
177
+
178
+ describe("cli friendly errors", () => {
179
+ test("malformed services.json prints friendly error not stack trace", async () => {
180
+ const dir = mkdtempSync(join(tmpdir(), "pcli-bad-"));
181
+ try {
182
+ writeFileSync(join(dir, "services.json"), "this is not json{");
183
+ const { code, stderr } = await runCli(["status"], { PARACHUTE_HOME: dir });
184
+ expect(code).toBe(1);
185
+ expect(stderr).toMatch(/services\.json is malformed/);
186
+ expect(stderr).not.toMatch(/at process\./);
187
+ expect(stderr).not.toMatch(/Error:.*at \//);
188
+ } finally {
189
+ rmSync(dir, { recursive: true, force: true });
190
+ }
191
+ });
192
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { renderConfig, writeConfig } from "../cloudflare/config.ts";
6
+
7
+ describe("cloudflare config", () => {
8
+ test("renderConfig produces a valid cloudflared YAML with one-hostname ingress + catch-all 404", () => {
9
+ const yaml = renderConfig({
10
+ tunnelUuid: "2c1a7c7e-1234-5678-9abc-def012345678",
11
+ credentialsFile: "/Users/x/.cloudflared/2c1a7c7e-1234-5678-9abc-def012345678.json",
12
+ hostname: "vault.example.com",
13
+ servicePort: 1940,
14
+ });
15
+ expect(yaml).toContain("tunnel: 2c1a7c7e-1234-5678-9abc-def012345678");
16
+ expect(yaml).toContain(
17
+ 'credentials-file: "/Users/x/.cloudflared/2c1a7c7e-1234-5678-9abc-def012345678.json"',
18
+ );
19
+ expect(yaml).toContain("- hostname: vault.example.com");
20
+ expect(yaml).toContain("service: http://localhost:1940");
21
+ expect(yaml).toContain("- service: http_status:404");
22
+ });
23
+
24
+ test("renderConfig double-quotes credentials-file so paths with spaces survive YAML parse", () => {
25
+ const yaml = renderConfig({
26
+ tunnelUuid: "uuid",
27
+ credentialsFile: "/Users/John Doe/.cloudflared/uuid.json",
28
+ hostname: "vault.example.com",
29
+ servicePort: 1940,
30
+ });
31
+ expect(yaml).toContain('credentials-file: "/Users/John Doe/.cloudflared/uuid.json"');
32
+ });
33
+
34
+ test("writeConfig creates the parent directory and writes to the given path", () => {
35
+ const dir = mkdtempSync(join(tmpdir(), "pcli-cfg-"));
36
+ const path = join(dir, "nested", "subdir", "config.yml");
37
+ try {
38
+ writeConfig(
39
+ {
40
+ tunnelUuid: "uuid",
41
+ credentialsFile: "/tmp/creds.json",
42
+ hostname: "vault.example.com",
43
+ servicePort: 1940,
44
+ },
45
+ path,
46
+ );
47
+ const contents = readFileSync(path, "utf8");
48
+ expect(contents).toContain("tunnel: uuid");
49
+ expect(contents).toContain("hostname: vault.example.com");
50
+ } finally {
51
+ rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ });
54
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ cloudflaredInstallHint,
7
+ isCloudflaredInstalled,
8
+ isCloudflaredLoggedIn,
9
+ } from "../cloudflare/detect.ts";
10
+ import type { CommandResult, Runner } from "../tailscale/run.ts";
11
+
12
+ function stubRunner(result: CommandResult | Error): Runner {
13
+ return async (_cmd) => {
14
+ if (result instanceof Error) throw result;
15
+ return result;
16
+ };
17
+ }
18
+
19
+ describe("cloudflare detect", () => {
20
+ test("isCloudflaredInstalled returns true on exit 0", async () => {
21
+ const runner = stubRunner({ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" });
22
+ expect(await isCloudflaredInstalled(runner)).toBe(true);
23
+ });
24
+
25
+ test("isCloudflaredInstalled returns false on non-zero exit", async () => {
26
+ const runner = stubRunner({ code: 127, stdout: "", stderr: "not found" });
27
+ expect(await isCloudflaredInstalled(runner)).toBe(false);
28
+ });
29
+
30
+ test("isCloudflaredInstalled swallows ENOENT (binary missing → not installed)", async () => {
31
+ // Bun.spawn throws synchronously when the binary is missing; the detector
32
+ // has to read that as "not installed" rather than propagating the error.
33
+ const runner = stubRunner(new Error("ENOENT: cloudflared not on PATH"));
34
+ expect(await isCloudflaredInstalled(runner)).toBe(false);
35
+ });
36
+
37
+ test("isCloudflaredInstalled matches on .code === 'ENOENT' too", async () => {
38
+ const err = Object.assign(new Error("spawn failed"), { code: "ENOENT" });
39
+ expect(await isCloudflaredInstalled(stubRunner(err))).toBe(false);
40
+ });
41
+
42
+ test("isCloudflaredInstalled propagates non-ENOENT errors (don't lie about why)", async () => {
43
+ // An EACCES (binary found but not executable) is real misconfiguration,
44
+ // not a missing install. Swallowing it here would mask the actual fix.
45
+ const err = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
46
+ await expect(isCloudflaredInstalled(stubRunner(err))).rejects.toMatchObject({
47
+ code: "EACCES",
48
+ });
49
+ });
50
+
51
+ test("isCloudflaredLoggedIn reads cert.pem presence in the passed home dir", () => {
52
+ const home = mkdtempSync(join(tmpdir(), "cf-home-"));
53
+ try {
54
+ expect(isCloudflaredLoggedIn(home)).toBe(false);
55
+ writeFileSync(join(home, "cert.pem"), "-----BEGIN CERTIFICATE-----\n...\n");
56
+ expect(isCloudflaredLoggedIn(home)).toBe(true);
57
+ } finally {
58
+ rmSync(home, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test("install hint names brew on darwin and a URL elsewhere", () => {
63
+ expect(cloudflaredInstallHint("darwin")).toContain("brew install cloudflared");
64
+ expect(cloudflaredInstallHint("linux")).toContain(
65
+ "developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads",
66
+ );
67
+ });
68
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ type CloudflaredState,
7
+ CloudflaredStateError,
8
+ clearCloudflaredState,
9
+ readCloudflaredState,
10
+ writeCloudflaredState,
11
+ } from "../cloudflare/state.ts";
12
+
13
+ function makeTempPath(): { path: string; cleanup: () => void } {
14
+ const dir = mkdtempSync(join(tmpdir(), "pcli-cfstate-"));
15
+ return {
16
+ path: join(dir, "cloudflared-state.json"),
17
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
18
+ };
19
+ }
20
+
21
+ const sample: CloudflaredState = {
22
+ version: 1,
23
+ pid: 12345,
24
+ tunnelUuid: "2c1a7c7e-1234-5678-9abc-def012345678",
25
+ tunnelName: "parachute",
26
+ hostname: "vault.example.com",
27
+ startedAt: "2026-04-22T12:00:00.000Z",
28
+ configPath: "/home/x/.parachute/cloudflared/config.yml",
29
+ };
30
+
31
+ describe("cloudflared state", () => {
32
+ test("read returns undefined when the file doesn't exist", () => {
33
+ const { path, cleanup } = makeTempPath();
34
+ try {
35
+ expect(readCloudflaredState(path)).toBeUndefined();
36
+ } finally {
37
+ cleanup();
38
+ }
39
+ });
40
+
41
+ test("write + read round-trip", () => {
42
+ const { path, cleanup } = makeTempPath();
43
+ try {
44
+ writeCloudflaredState(sample, path);
45
+ expect(readCloudflaredState(path)).toEqual(sample);
46
+ } finally {
47
+ cleanup();
48
+ }
49
+ });
50
+
51
+ test("clear removes the file", () => {
52
+ const { path, cleanup } = makeTempPath();
53
+ try {
54
+ writeCloudflaredState(sample, path);
55
+ expect(existsSync(path)).toBe(true);
56
+ clearCloudflaredState(path);
57
+ expect(existsSync(path)).toBe(false);
58
+ } finally {
59
+ cleanup();
60
+ }
61
+ });
62
+
63
+ test("throws on unsupported version", () => {
64
+ const { path, cleanup } = makeTempPath();
65
+ try {
66
+ writeFileSync(path, JSON.stringify({ ...sample, version: 99 }));
67
+ expect(() => readCloudflaredState(path)).toThrow(/unsupported version/);
68
+ } finally {
69
+ cleanup();
70
+ }
71
+ });
72
+
73
+ test("throws on non-positive pid", () => {
74
+ const { path, cleanup } = makeTempPath();
75
+ try {
76
+ writeFileSync(path, JSON.stringify({ ...sample, pid: -1 }));
77
+ expect(() => readCloudflaredState(path)).toThrow(CloudflaredStateError);
78
+ } finally {
79
+ cleanup();
80
+ }
81
+ });
82
+
83
+ test("throws on malformed JSON", () => {
84
+ const { path, cleanup } = makeTempPath();
85
+ try {
86
+ writeFileSync(path, "{not json");
87
+ expect(() => readCloudflaredState(path)).toThrow(/failed to parse/);
88
+ } finally {
89
+ cleanup();
90
+ }
91
+ });
92
+ });