@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.
- package/LICENSE +661 -0
- package/README.md +284 -0
- package/package.json +31 -0
- package/src/__tests__/auth.test.ts +101 -0
- package/src/__tests__/auto-wire.test.ts +283 -0
- package/src/__tests__/cli.test.ts +192 -0
- package/src/__tests__/cloudflare-config.test.ts +54 -0
- package/src/__tests__/cloudflare-detect.test.ts +68 -0
- package/src/__tests__/cloudflare-state.test.ts +92 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
- package/src/__tests__/config.test.ts +18 -0
- package/src/__tests__/env-file.test.ts +125 -0
- package/src/__tests__/expose-auth-preflight.test.ts +201 -0
- package/src/__tests__/expose-cloudflare.test.ts +484 -0
- package/src/__tests__/expose-interactive.test.ts +703 -0
- package/src/__tests__/expose-last-provider.test.ts +113 -0
- package/src/__tests__/expose-off-auto.test.ts +269 -0
- package/src/__tests__/expose-state.test.ts +101 -0
- package/src/__tests__/expose.test.ts +1581 -0
- package/src/__tests__/hub-control.test.ts +346 -0
- package/src/__tests__/hub-server.test.ts +157 -0
- package/src/__tests__/hub.test.ts +116 -0
- package/src/__tests__/install.test.ts +1145 -0
- package/src/__tests__/lifecycle.test.ts +608 -0
- package/src/__tests__/migrate.test.ts +422 -0
- package/src/__tests__/notes-serve.test.ts +135 -0
- package/src/__tests__/port-assign.test.ts +178 -0
- package/src/__tests__/process-state.test.ts +140 -0
- package/src/__tests__/scribe-config.test.ts +193 -0
- package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
- package/src/__tests__/services-manifest.test.ts +177 -0
- package/src/__tests__/status.test.ts +347 -0
- package/src/__tests__/tailscale-commands.test.ts +111 -0
- package/src/__tests__/tailscale-detect.test.ts +64 -0
- package/src/__tests__/vault-auth-status.test.ts +164 -0
- package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
- package/src/__tests__/well-known.test.ts +214 -0
- package/src/auto-wire.ts +184 -0
- package/src/cli.ts +482 -0
- package/src/cloudflare/config.ts +58 -0
- package/src/cloudflare/detect.ts +58 -0
- package/src/cloudflare/state.ts +96 -0
- package/src/cloudflare/tunnel.ts +135 -0
- package/src/commands/auth.ts +69 -0
- package/src/commands/expose-auth-preflight.ts +217 -0
- package/src/commands/expose-cloudflare.ts +329 -0
- package/src/commands/expose-interactive.ts +428 -0
- package/src/commands/expose-off-auto.ts +199 -0
- package/src/commands/expose.ts +522 -0
- package/src/commands/install.ts +422 -0
- package/src/commands/lifecycle.ts +324 -0
- package/src/commands/migrate.ts +253 -0
- package/src/commands/scribe-provider-interactive.ts +269 -0
- package/src/commands/status.ts +238 -0
- package/src/commands/vault-tokens-create-interactive.ts +137 -0
- package/src/commands/vault.ts +17 -0
- package/src/config.ts +16 -0
- package/src/env-file.ts +76 -0
- package/src/expose-last-provider.ts +71 -0
- package/src/expose-state.ts +125 -0
- package/src/help.ts +279 -0
- package/src/hub-control.ts +254 -0
- package/src/hub-origin.ts +44 -0
- package/src/hub-server.ts +113 -0
- package/src/hub.ts +674 -0
- package/src/notes-serve.ts +135 -0
- package/src/port-assign.ts +125 -0
- package/src/process-state.ts +111 -0
- package/src/scribe-config.ts +149 -0
- package/src/service-spec.ts +296 -0
- package/src/services-manifest.ts +171 -0
- package/src/tailscale/commands.ts +41 -0
- package/src/tailscale/detect.ts +107 -0
- package/src/tailscale/run.ts +28 -0
- package/src/vault/auth-status.ts +179 -0
- package/src/well-known.ts +127 -0
|
@@ -0,0 +1,703 @@
|
|
|
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 { exposePublicInteractive } from "../commands/expose-interactive.ts";
|
|
6
|
+
import { readLastProvider, writeLastProvider } from "../expose-last-provider.ts";
|
|
7
|
+
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
8
|
+
|
|
9
|
+
// Every test in this file uses injected seams for tailscale/cloudflared; the
|
|
10
|
+
// auth preflight is a separate module with its own test file, so stub it out
|
|
11
|
+
// here to keep these tests focused on the picker logic. Tests that want to
|
|
12
|
+
// assert preflight behavior override this.
|
|
13
|
+
const noopPreflight = async () => {};
|
|
14
|
+
|
|
15
|
+
interface TestEnv {
|
|
16
|
+
cloudflaredHome: string;
|
|
17
|
+
lastProviderPath: string;
|
|
18
|
+
cleanup: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeEnv(opts: { cloudflaredLoggedIn?: boolean } = {}): TestEnv {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-expose-interactive-"));
|
|
23
|
+
const cloudflaredHome = join(dir, "cloudflared");
|
|
24
|
+
require("node:fs").mkdirSync(cloudflaredHome, { recursive: true });
|
|
25
|
+
if (opts.cloudflaredLoggedIn) {
|
|
26
|
+
writeFileSync(join(cloudflaredHome, "cert.pem"), "---");
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
cloudflaredHome,
|
|
30
|
+
lastProviderPath: join(dir, "expose-last-provider.json"),
|
|
31
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface FixedRunnerOpts {
|
|
36
|
+
tailscaleInstalled?: boolean;
|
|
37
|
+
tailscaleLoggedIn?: boolean;
|
|
38
|
+
tailscaleFunnelCap?: boolean;
|
|
39
|
+
cloudflaredInstalled?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns a runner that answers the detection calls deterministically:
|
|
44
|
+
* - `tailscale version` → exit 0 iff tailscaleInstalled
|
|
45
|
+
* - `tailscale status --json` → JSON with Self.DNSName (if logged in) and
|
|
46
|
+
* Self.CapMap[funnel] (if funnel cap granted)
|
|
47
|
+
* - `cloudflared --version` → exit 0 iff cloudflaredInstalled
|
|
48
|
+
*
|
|
49
|
+
* Every call is appended to the returned `calls` array.
|
|
50
|
+
*/
|
|
51
|
+
function fixedRunner(opts: FixedRunnerOpts): { runner: Runner; calls: string[][] } {
|
|
52
|
+
const calls: string[][] = [];
|
|
53
|
+
const runner: Runner = async (cmd) => {
|
|
54
|
+
calls.push([...cmd]);
|
|
55
|
+
const head = cmd.slice(0, 2).join(" ");
|
|
56
|
+
if (head === "tailscale version") {
|
|
57
|
+
return opts.tailscaleInstalled
|
|
58
|
+
? ({ code: 0, stdout: "1.82.0\n", stderr: "" } as CommandResult)
|
|
59
|
+
: ({ code: 127, stdout: "", stderr: "not found" } as CommandResult);
|
|
60
|
+
}
|
|
61
|
+
if (head === "tailscale status") {
|
|
62
|
+
const self: Record<string, unknown> = {};
|
|
63
|
+
if (opts.tailscaleLoggedIn) self.DNSName = "parachute.example.ts.net.";
|
|
64
|
+
if (opts.tailscaleFunnelCap) self.CapMap = { "https://tailscale.com/cap/funnel": ["*"] };
|
|
65
|
+
return { code: 0, stdout: JSON.stringify({ Self: self }), stderr: "" } as CommandResult;
|
|
66
|
+
}
|
|
67
|
+
if (head === "cloudflared --version") {
|
|
68
|
+
return opts.cloudflaredInstalled
|
|
69
|
+
? ({ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" } as CommandResult)
|
|
70
|
+
: ({ code: 127, stdout: "", stderr: "not found" } as CommandResult);
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
73
|
+
};
|
|
74
|
+
return { runner, calls };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function queuePrompt(answers: string[]): {
|
|
78
|
+
prompt: (q: string) => Promise<string>;
|
|
79
|
+
asked: string[];
|
|
80
|
+
} {
|
|
81
|
+
const asked: string[] = [];
|
|
82
|
+
let i = 0;
|
|
83
|
+
return {
|
|
84
|
+
prompt: async (q) => {
|
|
85
|
+
asked.push(q);
|
|
86
|
+
const a = answers[i++];
|
|
87
|
+
if (a === undefined) throw new Error(`prompt exhausted at question: ${q}`);
|
|
88
|
+
return a;
|
|
89
|
+
},
|
|
90
|
+
asked,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("exposePublicInteractive — both ready", () => {
|
|
95
|
+
test("picks Tailscale by default when nothing remembered", async () => {
|
|
96
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
97
|
+
try {
|
|
98
|
+
const { runner } = fixedRunner({
|
|
99
|
+
tailscaleInstalled: true,
|
|
100
|
+
tailscaleLoggedIn: true,
|
|
101
|
+
tailscaleFunnelCap: true,
|
|
102
|
+
cloudflaredInstalled: true,
|
|
103
|
+
});
|
|
104
|
+
const { prompt } = queuePrompt([""]); // accept default
|
|
105
|
+
let tailscaleCalled = false;
|
|
106
|
+
let cloudflareCalled = false;
|
|
107
|
+
const code = await exposePublicInteractive({
|
|
108
|
+
runner,
|
|
109
|
+
prompt,
|
|
110
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
111
|
+
lastProviderPath: env.lastProviderPath,
|
|
112
|
+
log: () => {},
|
|
113
|
+
exposePublicImpl: async () => {
|
|
114
|
+
tailscaleCalled = true;
|
|
115
|
+
return 0;
|
|
116
|
+
},
|
|
117
|
+
exposeCloudflareUpImpl: async () => {
|
|
118
|
+
cloudflareCalled = true;
|
|
119
|
+
return 0;
|
|
120
|
+
},
|
|
121
|
+
runAuthPreflightImpl: noopPreflight,
|
|
122
|
+
});
|
|
123
|
+
expect(code).toBe(0);
|
|
124
|
+
expect(tailscaleCalled).toBe(true);
|
|
125
|
+
expect(cloudflareCalled).toBe(false);
|
|
126
|
+
expect(readLastProvider(env.lastProviderPath)?.provider).toBe("tailscale");
|
|
127
|
+
} finally {
|
|
128
|
+
env.cleanup();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("remembers and defaults to the last-used provider", async () => {
|
|
133
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
134
|
+
writeLastProvider("cloudflare", { path: env.lastProviderPath });
|
|
135
|
+
try {
|
|
136
|
+
const { runner } = fixedRunner({
|
|
137
|
+
tailscaleInstalled: true,
|
|
138
|
+
tailscaleLoggedIn: true,
|
|
139
|
+
tailscaleFunnelCap: true,
|
|
140
|
+
cloudflaredInstalled: true,
|
|
141
|
+
});
|
|
142
|
+
// Accept default (blank) at provider prompt, then supply hostname.
|
|
143
|
+
const { prompt } = queuePrompt(["", "vault.example.com"]);
|
|
144
|
+
let cloudflareHostname: string | undefined;
|
|
145
|
+
const code = await exposePublicInteractive({
|
|
146
|
+
runner,
|
|
147
|
+
prompt,
|
|
148
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
149
|
+
lastProviderPath: env.lastProviderPath,
|
|
150
|
+
log: () => {},
|
|
151
|
+
exposePublicImpl: async () => 0,
|
|
152
|
+
exposeCloudflareUpImpl: async (h) => {
|
|
153
|
+
cloudflareHostname = h;
|
|
154
|
+
return 0;
|
|
155
|
+
},
|
|
156
|
+
runAuthPreflightImpl: noopPreflight,
|
|
157
|
+
});
|
|
158
|
+
expect(code).toBe(0);
|
|
159
|
+
expect(cloudflareHostname).toBe("vault.example.com");
|
|
160
|
+
} finally {
|
|
161
|
+
env.cleanup();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("explicit '2' selects Cloudflare; hostname prompted and validated", async () => {
|
|
166
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
167
|
+
try {
|
|
168
|
+
const { runner } = fixedRunner({
|
|
169
|
+
tailscaleInstalled: true,
|
|
170
|
+
tailscaleLoggedIn: true,
|
|
171
|
+
tailscaleFunnelCap: true,
|
|
172
|
+
cloudflaredInstalled: true,
|
|
173
|
+
});
|
|
174
|
+
const { prompt } = queuePrompt(["2", "not a host", "vault.example.com"]);
|
|
175
|
+
let cloudflareHostname: string | undefined;
|
|
176
|
+
const code = await exposePublicInteractive({
|
|
177
|
+
runner,
|
|
178
|
+
prompt,
|
|
179
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
180
|
+
lastProviderPath: env.lastProviderPath,
|
|
181
|
+
log: () => {},
|
|
182
|
+
exposePublicImpl: async () => 0,
|
|
183
|
+
exposeCloudflareUpImpl: async (h) => {
|
|
184
|
+
cloudflareHostname = h;
|
|
185
|
+
return 0;
|
|
186
|
+
},
|
|
187
|
+
runAuthPreflightImpl: noopPreflight,
|
|
188
|
+
});
|
|
189
|
+
expect(code).toBe(0);
|
|
190
|
+
expect(cloudflareHostname).toBe("vault.example.com");
|
|
191
|
+
expect(readLastProvider(env.lastProviderPath)?.provider).toBe("cloudflare");
|
|
192
|
+
} finally {
|
|
193
|
+
env.cleanup();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("'q' aborts cleanly with exit 0 and no downstream calls", async () => {
|
|
198
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
199
|
+
try {
|
|
200
|
+
const { runner } = fixedRunner({
|
|
201
|
+
tailscaleInstalled: true,
|
|
202
|
+
tailscaleLoggedIn: true,
|
|
203
|
+
tailscaleFunnelCap: true,
|
|
204
|
+
cloudflaredInstalled: true,
|
|
205
|
+
});
|
|
206
|
+
const { prompt } = queuePrompt(["q"]);
|
|
207
|
+
let anyCalled = false;
|
|
208
|
+
const code = await exposePublicInteractive({
|
|
209
|
+
runner,
|
|
210
|
+
prompt,
|
|
211
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
212
|
+
lastProviderPath: env.lastProviderPath,
|
|
213
|
+
log: () => {},
|
|
214
|
+
exposePublicImpl: async () => {
|
|
215
|
+
anyCalled = true;
|
|
216
|
+
return 0;
|
|
217
|
+
},
|
|
218
|
+
exposeCloudflareUpImpl: async () => {
|
|
219
|
+
anyCalled = true;
|
|
220
|
+
return 0;
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
expect(code).toBe(0);
|
|
224
|
+
expect(anyCalled).toBe(false);
|
|
225
|
+
expect(readLastProvider(env.lastProviderPath)).toBeUndefined();
|
|
226
|
+
} finally {
|
|
227
|
+
env.cleanup();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("blank hostname at the Cloudflare prompt exits 0 without handoff", async () => {
|
|
232
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
233
|
+
try {
|
|
234
|
+
const { runner } = fixedRunner({
|
|
235
|
+
tailscaleInstalled: true,
|
|
236
|
+
tailscaleLoggedIn: true,
|
|
237
|
+
tailscaleFunnelCap: true,
|
|
238
|
+
cloudflaredInstalled: true,
|
|
239
|
+
});
|
|
240
|
+
const { prompt } = queuePrompt(["2", ""]);
|
|
241
|
+
let cloudflareCalled = false;
|
|
242
|
+
const code = await exposePublicInteractive({
|
|
243
|
+
runner,
|
|
244
|
+
prompt,
|
|
245
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
246
|
+
lastProviderPath: env.lastProviderPath,
|
|
247
|
+
log: () => {},
|
|
248
|
+
exposePublicImpl: async () => 0,
|
|
249
|
+
exposeCloudflareUpImpl: async () => {
|
|
250
|
+
cloudflareCalled = true;
|
|
251
|
+
return 0;
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
expect(code).toBe(0);
|
|
255
|
+
expect(cloudflareCalled).toBe(false);
|
|
256
|
+
} finally {
|
|
257
|
+
env.cleanup();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("exposePublicInteractive — only one ready", () => {
|
|
263
|
+
test("tailscale-ready, cloudflare-missing: announces and runs tailscale without prompting", async () => {
|
|
264
|
+
const env = makeEnv();
|
|
265
|
+
try {
|
|
266
|
+
const { runner } = fixedRunner({
|
|
267
|
+
tailscaleInstalled: true,
|
|
268
|
+
tailscaleLoggedIn: true,
|
|
269
|
+
tailscaleFunnelCap: true,
|
|
270
|
+
cloudflaredInstalled: false,
|
|
271
|
+
});
|
|
272
|
+
let prompts = 0;
|
|
273
|
+
const logs: string[] = [];
|
|
274
|
+
const code = await exposePublicInteractive({
|
|
275
|
+
runner,
|
|
276
|
+
prompt: async () => {
|
|
277
|
+
prompts++;
|
|
278
|
+
return "";
|
|
279
|
+
},
|
|
280
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
281
|
+
lastProviderPath: env.lastProviderPath,
|
|
282
|
+
log: (l) => logs.push(l),
|
|
283
|
+
exposePublicImpl: async () => 0,
|
|
284
|
+
exposeCloudflareUpImpl: async () => 0,
|
|
285
|
+
runAuthPreflightImpl: noopPreflight,
|
|
286
|
+
});
|
|
287
|
+
expect(code).toBe(0);
|
|
288
|
+
expect(prompts).toBe(0);
|
|
289
|
+
expect(logs.join("\n")).toContain("Using Tailscale Funnel");
|
|
290
|
+
expect(readLastProvider(env.lastProviderPath)?.provider).toBe("tailscale");
|
|
291
|
+
} finally {
|
|
292
|
+
env.cleanup();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("cloudflare-ready, tailscale-missing: announces and prompts hostname", async () => {
|
|
297
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
298
|
+
try {
|
|
299
|
+
const { runner } = fixedRunner({
|
|
300
|
+
tailscaleInstalled: false,
|
|
301
|
+
cloudflaredInstalled: true,
|
|
302
|
+
});
|
|
303
|
+
const { prompt } = queuePrompt(["vault.example.com"]);
|
|
304
|
+
const logs: string[] = [];
|
|
305
|
+
let cloudflareHostname: string | undefined;
|
|
306
|
+
const code = await exposePublicInteractive({
|
|
307
|
+
runner,
|
|
308
|
+
prompt,
|
|
309
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
310
|
+
lastProviderPath: env.lastProviderPath,
|
|
311
|
+
log: (l) => logs.push(l),
|
|
312
|
+
exposePublicImpl: async () => 0,
|
|
313
|
+
exposeCloudflareUpImpl: async (h) => {
|
|
314
|
+
cloudflareHostname = h;
|
|
315
|
+
return 0;
|
|
316
|
+
},
|
|
317
|
+
runAuthPreflightImpl: noopPreflight,
|
|
318
|
+
});
|
|
319
|
+
expect(code).toBe(0);
|
|
320
|
+
expect(cloudflareHostname).toBe("vault.example.com");
|
|
321
|
+
expect(logs.join("\n")).toContain("Using Cloudflare Tunnel");
|
|
322
|
+
} finally {
|
|
323
|
+
env.cleanup();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("exposePublicInteractive — neither ready", () => {
|
|
329
|
+
test("user picks tailscale: prints setup guidance and exits 1", async () => {
|
|
330
|
+
const env = makeEnv();
|
|
331
|
+
try {
|
|
332
|
+
const { runner } = fixedRunner({});
|
|
333
|
+
const { prompt } = queuePrompt(["1"]);
|
|
334
|
+
const logs: string[] = [];
|
|
335
|
+
let tailscaleCalled = false;
|
|
336
|
+
const code = await exposePublicInteractive({
|
|
337
|
+
runner,
|
|
338
|
+
prompt,
|
|
339
|
+
platform: "darwin",
|
|
340
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
341
|
+
lastProviderPath: env.lastProviderPath,
|
|
342
|
+
log: (l) => logs.push(l),
|
|
343
|
+
exposePublicImpl: async () => {
|
|
344
|
+
tailscaleCalled = true;
|
|
345
|
+
return 0;
|
|
346
|
+
},
|
|
347
|
+
exposeCloudflareUpImpl: async () => 0,
|
|
348
|
+
});
|
|
349
|
+
expect(code).toBe(1);
|
|
350
|
+
expect(tailscaleCalled).toBe(false);
|
|
351
|
+
const joined = logs.join("\n");
|
|
352
|
+
expect(joined).toContain("brew install tailscale");
|
|
353
|
+
expect(joined).toContain("tailscale up");
|
|
354
|
+
expect(joined).toContain("login.tailscale.com/admin/acls");
|
|
355
|
+
} finally {
|
|
356
|
+
env.cleanup();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("user picks tailscale on linux: install hint links to tailscale.com", async () => {
|
|
361
|
+
const env = makeEnv();
|
|
362
|
+
try {
|
|
363
|
+
const { runner } = fixedRunner({});
|
|
364
|
+
const { prompt } = queuePrompt(["1"]);
|
|
365
|
+
const logs: string[] = [];
|
|
366
|
+
const code = await exposePublicInteractive({
|
|
367
|
+
runner,
|
|
368
|
+
prompt,
|
|
369
|
+
platform: "linux",
|
|
370
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
371
|
+
lastProviderPath: env.lastProviderPath,
|
|
372
|
+
log: (l) => logs.push(l),
|
|
373
|
+
exposePublicImpl: async () => 0,
|
|
374
|
+
exposeCloudflareUpImpl: async () => 0,
|
|
375
|
+
});
|
|
376
|
+
expect(code).toBe(1);
|
|
377
|
+
const joined = logs.join("\n");
|
|
378
|
+
expect(joined).toContain("tailscale.com/download");
|
|
379
|
+
} finally {
|
|
380
|
+
env.cleanup();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("user picks cloudflare on macos: brew install confirmed, login runs, hostname then handoff", async () => {
|
|
385
|
+
const env = makeEnv();
|
|
386
|
+
try {
|
|
387
|
+
let cloudflaredInstalled = false;
|
|
388
|
+
const calls: string[][] = [];
|
|
389
|
+
const runner: Runner = async (cmd) => {
|
|
390
|
+
calls.push([...cmd]);
|
|
391
|
+
const head = cmd.slice(0, 2).join(" ");
|
|
392
|
+
if (head === "tailscale version") return { code: 127, stdout: "", stderr: "not found" };
|
|
393
|
+
if (head === "tailscale status") return { code: 0, stdout: "{}", stderr: "" };
|
|
394
|
+
if (head === "cloudflared --version") {
|
|
395
|
+
return cloudflaredInstalled
|
|
396
|
+
? { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }
|
|
397
|
+
: { code: 127, stdout: "", stderr: "not found" };
|
|
398
|
+
}
|
|
399
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
400
|
+
};
|
|
401
|
+
const interactiveCmds: string[][] = [];
|
|
402
|
+
const interactiveRunner = async (cmd: readonly string[]) => {
|
|
403
|
+
interactiveCmds.push([...cmd]);
|
|
404
|
+
if (cmd[0] === "brew") {
|
|
405
|
+
cloudflaredInstalled = true;
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
if (cmd[0] === "cloudflared") {
|
|
409
|
+
// Simulate successful login by dropping cert.pem.
|
|
410
|
+
writeFileSync(join(env.cloudflaredHome, "cert.pem"), "---");
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
throw new Error(`unexpected interactive cmd: ${cmd.join(" ")}`);
|
|
414
|
+
};
|
|
415
|
+
const { prompt } = queuePrompt(["2", "y", "y", "vault.example.com"]);
|
|
416
|
+
const logs: string[] = [];
|
|
417
|
+
let cloudflareHostname: string | undefined;
|
|
418
|
+
const code = await exposePublicInteractive({
|
|
419
|
+
runner,
|
|
420
|
+
interactiveRunner,
|
|
421
|
+
prompt,
|
|
422
|
+
platform: "darwin",
|
|
423
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
424
|
+
lastProviderPath: env.lastProviderPath,
|
|
425
|
+
log: (l) => logs.push(l),
|
|
426
|
+
exposePublicImpl: async () => 0,
|
|
427
|
+
exposeCloudflareUpImpl: async (h) => {
|
|
428
|
+
cloudflareHostname = h;
|
|
429
|
+
return 0;
|
|
430
|
+
},
|
|
431
|
+
runAuthPreflightImpl: noopPreflight,
|
|
432
|
+
});
|
|
433
|
+
expect(code).toBe(0);
|
|
434
|
+
expect(interactiveCmds[0]).toEqual(["brew", "install", "cloudflared"]);
|
|
435
|
+
expect(interactiveCmds[1]).toEqual(["cloudflared", "tunnel", "login"]);
|
|
436
|
+
expect(cloudflareHostname).toBe("vault.example.com");
|
|
437
|
+
expect(readLastProvider(env.lastProviderPath)?.provider).toBe("cloudflare");
|
|
438
|
+
} finally {
|
|
439
|
+
env.cleanup();
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("user picks cloudflare on linux: prints manual install pointers and exits 1", async () => {
|
|
444
|
+
const env = makeEnv();
|
|
445
|
+
try {
|
|
446
|
+
const { runner } = fixedRunner({});
|
|
447
|
+
const { prompt } = queuePrompt(["2"]);
|
|
448
|
+
const logs: string[] = [];
|
|
449
|
+
let interactiveCalled = false;
|
|
450
|
+
let cloudflareCalled = false;
|
|
451
|
+
const code = await exposePublicInteractive({
|
|
452
|
+
runner,
|
|
453
|
+
interactiveRunner: async () => {
|
|
454
|
+
interactiveCalled = true;
|
|
455
|
+
return 0;
|
|
456
|
+
},
|
|
457
|
+
prompt,
|
|
458
|
+
platform: "linux",
|
|
459
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
460
|
+
lastProviderPath: env.lastProviderPath,
|
|
461
|
+
log: (l) => logs.push(l),
|
|
462
|
+
exposePublicImpl: async () => 0,
|
|
463
|
+
exposeCloudflareUpImpl: async () => {
|
|
464
|
+
cloudflareCalled = true;
|
|
465
|
+
return 0;
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
expect(code).toBe(1);
|
|
469
|
+
expect(interactiveCalled).toBe(false);
|
|
470
|
+
expect(cloudflareCalled).toBe(false);
|
|
471
|
+
const joined = logs.join("\n");
|
|
472
|
+
expect(joined).toMatch(/apt-get|dnf/);
|
|
473
|
+
expect(joined).toContain(
|
|
474
|
+
"developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads",
|
|
475
|
+
);
|
|
476
|
+
} finally {
|
|
477
|
+
env.cleanup();
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("user picks cloudflare on macos but declines brew: exits 1, no install attempted", async () => {
|
|
482
|
+
const env = makeEnv();
|
|
483
|
+
try {
|
|
484
|
+
const { runner } = fixedRunner({});
|
|
485
|
+
const interactiveCmds: string[][] = [];
|
|
486
|
+
const { prompt } = queuePrompt(["2", "n"]);
|
|
487
|
+
const code = await exposePublicInteractive({
|
|
488
|
+
runner,
|
|
489
|
+
interactiveRunner: async (cmd) => {
|
|
490
|
+
interactiveCmds.push([...cmd]);
|
|
491
|
+
return 0;
|
|
492
|
+
},
|
|
493
|
+
prompt,
|
|
494
|
+
platform: "darwin",
|
|
495
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
496
|
+
lastProviderPath: env.lastProviderPath,
|
|
497
|
+
log: () => {},
|
|
498
|
+
exposePublicImpl: async () => 0,
|
|
499
|
+
exposeCloudflareUpImpl: async () => 0,
|
|
500
|
+
});
|
|
501
|
+
expect(code).toBe(1);
|
|
502
|
+
expect(interactiveCmds).toHaveLength(0);
|
|
503
|
+
} finally {
|
|
504
|
+
env.cleanup();
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("user picks cloudflare on macos, installed but login declined: exits 1", async () => {
|
|
509
|
+
const env = makeEnv(); // no cert.pem
|
|
510
|
+
try {
|
|
511
|
+
const { runner } = fixedRunner({ cloudflaredInstalled: true });
|
|
512
|
+
const interactiveCmds: string[][] = [];
|
|
513
|
+
const { prompt } = queuePrompt(["2", "n"]);
|
|
514
|
+
const code = await exposePublicInteractive({
|
|
515
|
+
runner,
|
|
516
|
+
interactiveRunner: async (cmd) => {
|
|
517
|
+
interactiveCmds.push([...cmd]);
|
|
518
|
+
return 0;
|
|
519
|
+
},
|
|
520
|
+
prompt,
|
|
521
|
+
platform: "darwin",
|
|
522
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
523
|
+
lastProviderPath: env.lastProviderPath,
|
|
524
|
+
log: () => {},
|
|
525
|
+
exposePublicImpl: async () => 0,
|
|
526
|
+
exposeCloudflareUpImpl: async () => 0,
|
|
527
|
+
});
|
|
528
|
+
expect(code).toBe(1);
|
|
529
|
+
// No brew install was needed; no login was performed.
|
|
530
|
+
expect(interactiveCmds).toHaveLength(0);
|
|
531
|
+
} finally {
|
|
532
|
+
env.cleanup();
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("quit at neither-ready picker exits 0 with no handoff", async () => {
|
|
537
|
+
const env = makeEnv();
|
|
538
|
+
try {
|
|
539
|
+
const { runner } = fixedRunner({});
|
|
540
|
+
const { prompt } = queuePrompt(["q"]);
|
|
541
|
+
let anyCalled = false;
|
|
542
|
+
const code = await exposePublicInteractive({
|
|
543
|
+
runner,
|
|
544
|
+
prompt,
|
|
545
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
546
|
+
lastProviderPath: env.lastProviderPath,
|
|
547
|
+
log: () => {},
|
|
548
|
+
exposePublicImpl: async () => {
|
|
549
|
+
anyCalled = true;
|
|
550
|
+
return 0;
|
|
551
|
+
},
|
|
552
|
+
exposeCloudflareUpImpl: async () => {
|
|
553
|
+
anyCalled = true;
|
|
554
|
+
return 0;
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
expect(code).toBe(0);
|
|
558
|
+
expect(anyCalled).toBe(false);
|
|
559
|
+
} finally {
|
|
560
|
+
env.cleanup();
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe("exposePublicInteractive — edge cases", () => {
|
|
566
|
+
test("tailscale installed+logged-in but Funnel cap missing counts as not ready", async () => {
|
|
567
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
568
|
+
try {
|
|
569
|
+
const { runner } = fixedRunner({
|
|
570
|
+
tailscaleInstalled: true,
|
|
571
|
+
tailscaleLoggedIn: true,
|
|
572
|
+
tailscaleFunnelCap: false,
|
|
573
|
+
cloudflaredInstalled: true,
|
|
574
|
+
});
|
|
575
|
+
// Since tailscale isn't "ready", only cloudflare counts as ready → one-ready path.
|
|
576
|
+
const { prompt } = queuePrompt(["vault.example.com"]);
|
|
577
|
+
let cloudflareCalled = false;
|
|
578
|
+
const code = await exposePublicInteractive({
|
|
579
|
+
runner,
|
|
580
|
+
prompt,
|
|
581
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
582
|
+
lastProviderPath: env.lastProviderPath,
|
|
583
|
+
log: () => {},
|
|
584
|
+
exposePublicImpl: async () => 0,
|
|
585
|
+
exposeCloudflareUpImpl: async () => {
|
|
586
|
+
cloudflareCalled = true;
|
|
587
|
+
return 0;
|
|
588
|
+
},
|
|
589
|
+
runAuthPreflightImpl: noopPreflight,
|
|
590
|
+
});
|
|
591
|
+
expect(code).toBe(0);
|
|
592
|
+
expect(cloudflareCalled).toBe(true);
|
|
593
|
+
} finally {
|
|
594
|
+
env.cleanup();
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("passthrough opts flow to the downstream entry points", async () => {
|
|
599
|
+
const env = makeEnv();
|
|
600
|
+
try {
|
|
601
|
+
const { runner } = fixedRunner({
|
|
602
|
+
tailscaleInstalled: true,
|
|
603
|
+
tailscaleLoggedIn: true,
|
|
604
|
+
tailscaleFunnelCap: true,
|
|
605
|
+
});
|
|
606
|
+
let receivedExposeOpts: unknown;
|
|
607
|
+
const code = await exposePublicInteractive({
|
|
608
|
+
runner,
|
|
609
|
+
prompt: async () => "",
|
|
610
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
611
|
+
lastProviderPath: env.lastProviderPath,
|
|
612
|
+
log: () => {},
|
|
613
|
+
exposeOpts: { hubOrigin: "https://custom.example" },
|
|
614
|
+
exposePublicImpl: async (_action, opts) => {
|
|
615
|
+
receivedExposeOpts = opts;
|
|
616
|
+
return 0;
|
|
617
|
+
},
|
|
618
|
+
exposeCloudflareUpImpl: async () => 0,
|
|
619
|
+
runAuthPreflightImpl: noopPreflight,
|
|
620
|
+
});
|
|
621
|
+
expect(code).toBe(0);
|
|
622
|
+
expect(receivedExposeOpts).toEqual({ hubOrigin: "https://custom.example" });
|
|
623
|
+
} finally {
|
|
624
|
+
env.cleanup();
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("preselect=cloudflare skips picker and prompts only for hostname", async () => {
|
|
629
|
+
// Simulates `parachute expose public --cloudflare` in a TTY without
|
|
630
|
+
// --domain: we know the user wants Cloudflare, so no provider picker,
|
|
631
|
+
// straight to the hostname prompt.
|
|
632
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
633
|
+
try {
|
|
634
|
+
const { runner } = fixedRunner({
|
|
635
|
+
tailscaleInstalled: true,
|
|
636
|
+
tailscaleLoggedIn: true,
|
|
637
|
+
tailscaleFunnelCap: true,
|
|
638
|
+
cloudflaredInstalled: true,
|
|
639
|
+
});
|
|
640
|
+
const { prompt, asked } = queuePrompt(["vault.example.com"]);
|
|
641
|
+
let cloudflareHostname: string | undefined;
|
|
642
|
+
let tailscaleCalled = false;
|
|
643
|
+
const code = await exposePublicInteractive({
|
|
644
|
+
runner,
|
|
645
|
+
prompt,
|
|
646
|
+
preselect: "cloudflare",
|
|
647
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
648
|
+
lastProviderPath: env.lastProviderPath,
|
|
649
|
+
log: () => {},
|
|
650
|
+
exposePublicImpl: async () => {
|
|
651
|
+
tailscaleCalled = true;
|
|
652
|
+
return 0;
|
|
653
|
+
},
|
|
654
|
+
exposeCloudflareUpImpl: async (h) => {
|
|
655
|
+
cloudflareHostname = h;
|
|
656
|
+
return 0;
|
|
657
|
+
},
|
|
658
|
+
runAuthPreflightImpl: noopPreflight,
|
|
659
|
+
});
|
|
660
|
+
expect(code).toBe(0);
|
|
661
|
+
expect(tailscaleCalled).toBe(false);
|
|
662
|
+
expect(cloudflareHostname).toBe("vault.example.com");
|
|
663
|
+
// Only one prompt was asked — the hostname. No provider picker shown.
|
|
664
|
+
expect(asked).toHaveLength(1);
|
|
665
|
+
expect(asked[0]?.toLowerCase()).toContain("hostname");
|
|
666
|
+
expect(readLastProvider(env.lastProviderPath)?.provider).toBe("cloudflare");
|
|
667
|
+
} finally {
|
|
668
|
+
env.cleanup();
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("invalid provider input reprompts rather than crashing", async () => {
|
|
673
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
674
|
+
try {
|
|
675
|
+
const { runner } = fixedRunner({
|
|
676
|
+
tailscaleInstalled: true,
|
|
677
|
+
tailscaleLoggedIn: true,
|
|
678
|
+
tailscaleFunnelCap: true,
|
|
679
|
+
cloudflaredInstalled: true,
|
|
680
|
+
});
|
|
681
|
+
const { prompt, asked } = queuePrompt(["huh", "7", "1"]);
|
|
682
|
+
let tailscaleCalled = false;
|
|
683
|
+
const code = await exposePublicInteractive({
|
|
684
|
+
runner,
|
|
685
|
+
prompt,
|
|
686
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
687
|
+
lastProviderPath: env.lastProviderPath,
|
|
688
|
+
log: () => {},
|
|
689
|
+
exposePublicImpl: async () => {
|
|
690
|
+
tailscaleCalled = true;
|
|
691
|
+
return 0;
|
|
692
|
+
},
|
|
693
|
+
exposeCloudflareUpImpl: async () => 0,
|
|
694
|
+
runAuthPreflightImpl: noopPreflight,
|
|
695
|
+
});
|
|
696
|
+
expect(code).toBe(0);
|
|
697
|
+
expect(tailscaleCalled).toBe(true);
|
|
698
|
+
expect(asked.length).toBeGreaterThanOrEqual(3);
|
|
699
|
+
} finally {
|
|
700
|
+
env.cleanup();
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
});
|