@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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* parachute — the top-level CLI for the Parachute ecosystem.
|
|
5
|
+
*
|
|
6
|
+
* Run `parachute --help` or `parachute <subcommand> --help` for usage.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import pkg from "../package.json" with { type: "json" };
|
|
10
|
+
import { CloudflaredStateError } from "./cloudflare/state.ts";
|
|
11
|
+
import { auth } from "./commands/auth.ts";
|
|
12
|
+
import { exposePublic, exposeTailnet } from "./commands/expose.ts";
|
|
13
|
+
import { install } from "./commands/install.ts";
|
|
14
|
+
import { logs, restart, start, stop } from "./commands/lifecycle.ts";
|
|
15
|
+
import { migrate } from "./commands/migrate.ts";
|
|
16
|
+
import { status } from "./commands/status.ts";
|
|
17
|
+
import { dispatchVault } from "./commands/vault.ts";
|
|
18
|
+
import { ExposeStateError } from "./expose-state.ts";
|
|
19
|
+
import {
|
|
20
|
+
exposeHelp,
|
|
21
|
+
installHelp,
|
|
22
|
+
logsHelp,
|
|
23
|
+
migrateHelp,
|
|
24
|
+
restartHelp,
|
|
25
|
+
startHelp,
|
|
26
|
+
statusHelp,
|
|
27
|
+
stopHelp,
|
|
28
|
+
topLevelHelp,
|
|
29
|
+
} from "./help.ts";
|
|
30
|
+
import { knownServices } from "./service-spec.ts";
|
|
31
|
+
import { ServicesManifestError } from "./services-manifest.ts";
|
|
32
|
+
import { TailscaleError } from "./tailscale/run.ts";
|
|
33
|
+
|
|
34
|
+
function isHelpFlag(arg: string | undefined): boolean {
|
|
35
|
+
return arg === "--help" || arg === "-h" || arg === "help";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Both stdin and stdout must be TTYs before we offer interactive prompts.
|
|
40
|
+
* Stdin-only TTY would let us read keystrokes but leave prompt text going to
|
|
41
|
+
* a log file; stdout-only TTY would let us write prompts but never read an
|
|
42
|
+
* answer. Either asymmetry means the flag-driven path is the safer default.
|
|
43
|
+
*/
|
|
44
|
+
function isTtyInteractive(): boolean {
|
|
45
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract `--hub-origin=<url>` / `--hub-origin <url>` from argv. Returns the
|
|
50
|
+
* URL and the remaining args (so callers can keep validating positionals
|
|
51
|
+
* without the flag in the way). `error` is set on missing value.
|
|
52
|
+
*/
|
|
53
|
+
function extractHubOrigin(args: string[]): {
|
|
54
|
+
hubOrigin?: string;
|
|
55
|
+
rest: string[];
|
|
56
|
+
error?: string;
|
|
57
|
+
} {
|
|
58
|
+
const rest: string[] = [];
|
|
59
|
+
let hubOrigin: string | undefined;
|
|
60
|
+
for (let i = 0; i < args.length; i++) {
|
|
61
|
+
const a = args[i];
|
|
62
|
+
if (a === "--hub-origin") {
|
|
63
|
+
const v = args[i + 1];
|
|
64
|
+
if (!v) return { rest, error: "--hub-origin requires a URL argument" };
|
|
65
|
+
hubOrigin = v;
|
|
66
|
+
i++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (a?.startsWith("--hub-origin=")) {
|
|
70
|
+
hubOrigin = a.slice("--hub-origin=".length);
|
|
71
|
+
if (!hubOrigin) return { rest, error: "--hub-origin requires a URL argument" };
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (a !== undefined) rest.push(a);
|
|
75
|
+
}
|
|
76
|
+
return { hubOrigin, rest };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract `--tag=<value>` / `--tag <value>` from argv. Same shape as
|
|
81
|
+
* `extractHubOrigin` so the install command can layer the two flags
|
|
82
|
+
* uniformly. `error` is set on missing value.
|
|
83
|
+
*/
|
|
84
|
+
function extractTag(args: string[]): {
|
|
85
|
+
tag?: string;
|
|
86
|
+
rest: string[];
|
|
87
|
+
error?: string;
|
|
88
|
+
} {
|
|
89
|
+
const rest: string[] = [];
|
|
90
|
+
let tag: string | undefined;
|
|
91
|
+
for (let i = 0; i < args.length; i++) {
|
|
92
|
+
const a = args[i];
|
|
93
|
+
if (a === "--tag") {
|
|
94
|
+
const v = args[i + 1];
|
|
95
|
+
if (!v) return { rest, error: "--tag requires a value (dist-tag or version)" };
|
|
96
|
+
tag = v;
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (a?.startsWith("--tag=")) {
|
|
101
|
+
tag = a.slice("--tag=".length);
|
|
102
|
+
if (!tag) return { rest, error: "--tag requires a value (dist-tag or version)" };
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (a !== undefined) rest.push(a);
|
|
106
|
+
}
|
|
107
|
+
return { tag, rest };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generic `--name=<value>` / `--name <value>` extractor used for the scribe
|
|
112
|
+
* install flags. Returns the matched value and argv with the flag stripped, or
|
|
113
|
+
* an error when the flag is present without a value.
|
|
114
|
+
*/
|
|
115
|
+
function extractNamedFlag(
|
|
116
|
+
args: string[],
|
|
117
|
+
flag: string,
|
|
118
|
+
): { value?: string; rest: string[]; error?: string } {
|
|
119
|
+
const rest: string[] = [];
|
|
120
|
+
let value: string | undefined;
|
|
121
|
+
const eqPrefix = `${flag}=`;
|
|
122
|
+
for (let i = 0; i < args.length; i++) {
|
|
123
|
+
const a = args[i];
|
|
124
|
+
if (a === flag) {
|
|
125
|
+
const v = args[i + 1];
|
|
126
|
+
if (!v) return { rest, error: `${flag} requires a value` };
|
|
127
|
+
value = v;
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (a?.startsWith(eqPrefix)) {
|
|
132
|
+
value = a.slice(eqPrefix.length);
|
|
133
|
+
if (!value) return { rest, error: `${flag} requires a value` };
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (a !== undefined) rest.push(a);
|
|
137
|
+
}
|
|
138
|
+
return { value, rest };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Extract the Cloudflare-mode flags from `parachute expose public …`:
|
|
143
|
+
* `--cloudflare` (boolean) + `--domain=<host>` / `--domain <host>`. Returns
|
|
144
|
+
* the stripped argv so the layer/action parser sees `[layer, action?]`
|
|
145
|
+
* regardless of flag placement.
|
|
146
|
+
*/
|
|
147
|
+
function extractCloudflareFlags(args: string[]): {
|
|
148
|
+
cloudflare: boolean;
|
|
149
|
+
domain?: string;
|
|
150
|
+
rest: string[];
|
|
151
|
+
error?: string;
|
|
152
|
+
} {
|
|
153
|
+
const rest: string[] = [];
|
|
154
|
+
let cloudflare = false;
|
|
155
|
+
let domain: string | undefined;
|
|
156
|
+
for (let i = 0; i < args.length; i++) {
|
|
157
|
+
const a = args[i];
|
|
158
|
+
if (a === "--cloudflare") {
|
|
159
|
+
cloudflare = true;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (a === "--domain") {
|
|
163
|
+
const v = args[i + 1];
|
|
164
|
+
if (!v) return { cloudflare, rest, error: "--domain requires a hostname argument" };
|
|
165
|
+
domain = v;
|
|
166
|
+
i++;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (a?.startsWith("--domain=")) {
|
|
170
|
+
domain = a.slice("--domain=".length);
|
|
171
|
+
if (!domain) return { cloudflare, rest, error: "--domain requires a hostname argument" };
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (a !== undefined) rest.push(a);
|
|
175
|
+
}
|
|
176
|
+
return { cloudflare, domain, rest };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function main(argv: string[]): Promise<number> {
|
|
180
|
+
const [command, ...rest] = argv;
|
|
181
|
+
|
|
182
|
+
switch (command) {
|
|
183
|
+
case undefined:
|
|
184
|
+
case "help":
|
|
185
|
+
case "--help":
|
|
186
|
+
case "-h":
|
|
187
|
+
console.log(topLevelHelp());
|
|
188
|
+
return 0;
|
|
189
|
+
|
|
190
|
+
case "--version":
|
|
191
|
+
case "-v":
|
|
192
|
+
console.log(pkg.version);
|
|
193
|
+
return 0;
|
|
194
|
+
|
|
195
|
+
case "install": {
|
|
196
|
+
if (isHelpFlag(rest[0])) {
|
|
197
|
+
console.log(installHelp());
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
const tagExtract = extractTag(rest);
|
|
201
|
+
if (tagExtract.error) {
|
|
202
|
+
console.error(`parachute install: ${tagExtract.error}`);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
const providerExtract = extractNamedFlag(tagExtract.rest, "--scribe-provider");
|
|
206
|
+
if (providerExtract.error) {
|
|
207
|
+
console.error(`parachute install: ${providerExtract.error}`);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
const keyExtract = extractNamedFlag(providerExtract.rest, "--scribe-key");
|
|
211
|
+
if (keyExtract.error) {
|
|
212
|
+
console.error(`parachute install: ${keyExtract.error}`);
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
const noStart = keyExtract.rest.includes("--no-start");
|
|
216
|
+
const installArgs = keyExtract.rest.filter((a) => a !== "--no-start");
|
|
217
|
+
const service = installArgs[0];
|
|
218
|
+
if (!service) {
|
|
219
|
+
console.error("usage: parachute install <service|all> [--tag <name>] [--no-start]");
|
|
220
|
+
console.error(
|
|
221
|
+
" parachute install scribe [--scribe-provider <name>] [--scribe-key <key>]",
|
|
222
|
+
);
|
|
223
|
+
console.error(`services: ${knownServices().join(", ")}`);
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
const installOpts: Parameters<typeof install>[1] = {};
|
|
227
|
+
if (tagExtract.tag) installOpts.tag = tagExtract.tag;
|
|
228
|
+
if (noStart) installOpts.noStart = true;
|
|
229
|
+
if (providerExtract.value) installOpts.scribeProvider = providerExtract.value;
|
|
230
|
+
if (keyExtract.value) installOpts.scribeKey = keyExtract.value;
|
|
231
|
+
if (service === "all") {
|
|
232
|
+
// Bootstrap the whole ecosystem to one dist-tag — the RC-testing payload.
|
|
233
|
+
// Bail on first failure so a broken channel doesn't mask a working tag.
|
|
234
|
+
for (const svc of knownServices()) {
|
|
235
|
+
const code = await install(svc, installOpts);
|
|
236
|
+
if (code !== 0) return code;
|
|
237
|
+
}
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
return await install(service, installOpts);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case "status":
|
|
244
|
+
if (isHelpFlag(rest[0])) {
|
|
245
|
+
console.log(statusHelp());
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
return await status();
|
|
249
|
+
|
|
250
|
+
case "expose": {
|
|
251
|
+
const hubExtract = extractHubOrigin(rest);
|
|
252
|
+
if (hubExtract.error) {
|
|
253
|
+
console.error(`parachute expose: ${hubExtract.error}`);
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
const cfExtract = extractCloudflareFlags(hubExtract.rest);
|
|
257
|
+
if (cfExtract.error) {
|
|
258
|
+
console.error(`parachute expose: ${cfExtract.error}`);
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
261
|
+
const exposeArgs = cfExtract.rest;
|
|
262
|
+
const layer = exposeArgs[0];
|
|
263
|
+
const mode = exposeArgs[1];
|
|
264
|
+
if (isHelpFlag(layer)) {
|
|
265
|
+
console.log(exposeHelp());
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
if (layer !== "tailnet" && layer !== "public") {
|
|
269
|
+
console.error(`parachute expose: unknown layer "${layer ?? ""}"`);
|
|
270
|
+
console.error("usage: parachute expose tailnet [off]");
|
|
271
|
+
console.error(" parachute expose public [off]");
|
|
272
|
+
console.error(" parachute expose public --cloudflare --domain <hostname>");
|
|
273
|
+
console.error("run `parachute expose --help` for details");
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
if (isHelpFlag(mode)) {
|
|
277
|
+
console.log(exposeHelp());
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
if (mode !== undefined && mode !== "off") {
|
|
281
|
+
console.error(`parachute expose ${layer}: unknown argument "${mode}"`);
|
|
282
|
+
console.error(`usage: parachute expose ${layer} [off]`);
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
const action = mode === "off" ? "off" : "up";
|
|
286
|
+
|
|
287
|
+
// Cloudflare mode is a separate execution path — different detector,
|
|
288
|
+
// different state file, different process model (it spawns cloudflared
|
|
289
|
+
// rather than driving tailscale serve/funnel). Route to it early.
|
|
290
|
+
if (cfExtract.cloudflare) {
|
|
291
|
+
if (layer !== "public") {
|
|
292
|
+
console.error(
|
|
293
|
+
"parachute expose: --cloudflare only applies to `public` (it's a public-internet path).",
|
|
294
|
+
);
|
|
295
|
+
return 1;
|
|
296
|
+
}
|
|
297
|
+
const { exposeCloudflareUp, exposeCloudflareOff } = await import(
|
|
298
|
+
"./commands/expose-cloudflare.ts"
|
|
299
|
+
);
|
|
300
|
+
if (action === "off") {
|
|
301
|
+
return await exposeCloudflareOff();
|
|
302
|
+
}
|
|
303
|
+
if (!cfExtract.domain) {
|
|
304
|
+
// Partial flag promotion: the user told us they want Cloudflare but
|
|
305
|
+
// didn't supply a hostname. In a TTY, prompt only for what's
|
|
306
|
+
// missing instead of forcing them to retype the whole command. In a
|
|
307
|
+
// non-TTY (scripts, CI), keep today's hard-error so automation
|
|
308
|
+
// doesn't block on an invisible prompt.
|
|
309
|
+
if (isTtyInteractive()) {
|
|
310
|
+
const { exposePublicInteractive } = await import("./commands/expose-interactive.ts");
|
|
311
|
+
return await exposePublicInteractive({ preselect: "cloudflare" });
|
|
312
|
+
}
|
|
313
|
+
console.error("parachute expose public --cloudflare: --domain <hostname> is required.");
|
|
314
|
+
console.error("Example: parachute expose public --cloudflare --domain vault.example.com");
|
|
315
|
+
console.error("");
|
|
316
|
+
console.error("The hostname's apex domain must already be a zone on your Cloudflare");
|
|
317
|
+
console.error(
|
|
318
|
+
"account. If you don't have one yet: https://dash.cloudflare.com → Add site.",
|
|
319
|
+
);
|
|
320
|
+
console.error("");
|
|
321
|
+
console.error("If you'd rather not own a domain, use Tailscale Funnel instead:");
|
|
322
|
+
console.error(" parachute expose public");
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
return await exposeCloudflareUp(cfExtract.domain);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const exposeOpts = hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {};
|
|
329
|
+
|
|
330
|
+
// Interactive picker: `parachute expose public` with no provider/domain
|
|
331
|
+
// flags, running under a TTY on both stdin and stdout, routes through a
|
|
332
|
+
// guided flow that offers Tailscale vs. Cloudflare, walks provider
|
|
333
|
+
// setup, and hands back to the flag-driven entry points. Non-TTY or any
|
|
334
|
+
// scripted use (flags present) keeps today's behavior exactly.
|
|
335
|
+
if (layer === "public" && action === "up" && isTtyInteractive()) {
|
|
336
|
+
const { exposePublicInteractive } = await import("./commands/expose-interactive.ts");
|
|
337
|
+
return await exposePublicInteractive({ exposeOpts });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// `expose public off` (no `--cloudflare`) auto-detects which provider is
|
|
341
|
+
// live. The explicit `--cloudflare` off branch above still wins — this
|
|
342
|
+
// path is only for users who typed plain `off` and don't want to
|
|
343
|
+
// remember which provider they brought up last.
|
|
344
|
+
if (layer === "public" && action === "off") {
|
|
345
|
+
const { runExposePublicOffAutoDetect } = await import("./commands/expose-off-auto.ts");
|
|
346
|
+
return await runExposePublicOffAutoDetect({ tailscaleOffOpts: exposeOpts });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return layer === "public"
|
|
350
|
+
? await exposePublic(action, exposeOpts)
|
|
351
|
+
: await exposeTailnet(action, exposeOpts);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
case "start": {
|
|
355
|
+
if (isHelpFlag(rest[0])) {
|
|
356
|
+
console.log(startHelp());
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
const hubExtract = extractHubOrigin(rest);
|
|
360
|
+
if (hubExtract.error) {
|
|
361
|
+
console.error(`parachute start: ${hubExtract.error}`);
|
|
362
|
+
return 1;
|
|
363
|
+
}
|
|
364
|
+
const startOpts = hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {};
|
|
365
|
+
return await start(hubExtract.rest[0], startOpts);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case "stop": {
|
|
369
|
+
if (isHelpFlag(rest[0])) {
|
|
370
|
+
console.log(stopHelp());
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
return await stop(rest[0]);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
case "restart": {
|
|
377
|
+
if (isHelpFlag(rest[0])) {
|
|
378
|
+
console.log(restartHelp());
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
return await restart(rest[0]);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
case "logs": {
|
|
385
|
+
if (isHelpFlag(rest[0])) {
|
|
386
|
+
console.log(logsHelp());
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
const svc = rest[0];
|
|
390
|
+
if (!svc) {
|
|
391
|
+
console.error("usage: parachute logs <service> [-f]");
|
|
392
|
+
console.error(`services: ${knownServices().join(", ")}`);
|
|
393
|
+
return 1;
|
|
394
|
+
}
|
|
395
|
+
const follow = rest.includes("-f") || rest.includes("--follow");
|
|
396
|
+
return await logs(svc, { follow });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
case "migrate": {
|
|
400
|
+
if (isHelpFlag(rest[0])) {
|
|
401
|
+
console.log(migrateHelp());
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
const dryRun = rest.includes("--dry-run");
|
|
405
|
+
const yes = rest.includes("--yes") || rest.includes("-y");
|
|
406
|
+
const unknown = rest.find((a) => a !== "--dry-run" && a !== "--yes" && a !== "-y");
|
|
407
|
+
if (unknown !== undefined) {
|
|
408
|
+
console.error(`parachute migrate: unknown argument "${unknown}"`);
|
|
409
|
+
console.error("usage: parachute migrate [--dry-run] [--yes]");
|
|
410
|
+
return 1;
|
|
411
|
+
}
|
|
412
|
+
return await migrate({ dryRun, yes });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
case "auth":
|
|
416
|
+
return await auth(rest);
|
|
417
|
+
|
|
418
|
+
case "vault": {
|
|
419
|
+
// `parachute vault` with no args forwards --help to parachute-vault so
|
|
420
|
+
// users see the actual vault surface, not a CLI-side stub. Anything
|
|
421
|
+
// after `vault` (including --help) is passed through verbatim.
|
|
422
|
+
if (rest.length === 0) return await dispatchVault(["--help"]);
|
|
423
|
+
|
|
424
|
+
// `parachute vault tokens create` in a TTY with no scope-narrowing flag
|
|
425
|
+
// → guided flow. Any of --scope / --read / --permission means the user
|
|
426
|
+
// has already decided, so we stay out of the way. Non-TTY always
|
|
427
|
+
// bypasses (no way to answer a prompt). Label is orthogonal — the
|
|
428
|
+
// guided flow prompts for it only if --label wasn't supplied.
|
|
429
|
+
const wantsGuidedTokenCreate =
|
|
430
|
+
rest[0] === "tokens" &&
|
|
431
|
+
rest[1] === "create" &&
|
|
432
|
+
isTtyInteractive() &&
|
|
433
|
+
!rest.includes("--scope") &&
|
|
434
|
+
!rest.includes("--read") &&
|
|
435
|
+
!rest.includes("--permission") &&
|
|
436
|
+
!isHelpFlag(rest[2]);
|
|
437
|
+
if (wantsGuidedTokenCreate) {
|
|
438
|
+
const { runVaultTokensCreateInteractive } = await import(
|
|
439
|
+
"./commands/vault-tokens-create-interactive.ts"
|
|
440
|
+
);
|
|
441
|
+
return await runVaultTokensCreateInteractive({ args: rest.slice(2) });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return await dispatchVault(rest);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
default:
|
|
448
|
+
console.error(`parachute: unknown command "${command}"`);
|
|
449
|
+
console.error("run `parachute --help` for usage");
|
|
450
|
+
return 1;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function run(argv: string[]): Promise<number> {
|
|
455
|
+
try {
|
|
456
|
+
return await main(argv);
|
|
457
|
+
} catch (err) {
|
|
458
|
+
if (err instanceof ServicesManifestError) {
|
|
459
|
+
console.error(`services.json is malformed: ${err.message}`);
|
|
460
|
+
console.error("Fix or remove the file, then re-run.");
|
|
461
|
+
return 1;
|
|
462
|
+
}
|
|
463
|
+
if (err instanceof ExposeStateError) {
|
|
464
|
+
console.error(`expose-state.json is malformed: ${err.message}`);
|
|
465
|
+
console.error("If you're stuck, delete ~/.parachute/expose-state.json and re-run.");
|
|
466
|
+
return 1;
|
|
467
|
+
}
|
|
468
|
+
if (err instanceof CloudflaredStateError) {
|
|
469
|
+
console.error(`cloudflared-state.json is malformed: ${err.message}`);
|
|
470
|
+
console.error("If you're stuck, delete ~/.parachute/cloudflared-state.json and re-run.");
|
|
471
|
+
return 1;
|
|
472
|
+
}
|
|
473
|
+
if (err instanceof TailscaleError) {
|
|
474
|
+
console.error(`tailscale command failed: ${err.message}`);
|
|
475
|
+
return 1;
|
|
476
|
+
}
|
|
477
|
+
throw err;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const code = await run(process.argv.slice(2));
|
|
482
|
+
process.exit(code);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { CONFIG_DIR } from "../config.ts";
|
|
4
|
+
|
|
5
|
+
export const CLOUDFLARED_DIR = join(CONFIG_DIR, "cloudflared");
|
|
6
|
+
export const CLOUDFLARED_CONFIG_PATH = join(CLOUDFLARED_DIR, "config.yml");
|
|
7
|
+
export const CLOUDFLARED_LOG_PATH = join(CLOUDFLARED_DIR, "cloudflared.log");
|
|
8
|
+
|
|
9
|
+
export interface TunnelConfigOpts {
|
|
10
|
+
tunnelUuid: string;
|
|
11
|
+
/** Absolute path to the per-tunnel credentials JSON (`~/.cloudflared/<uuid>.json`). */
|
|
12
|
+
credentialsFile: string;
|
|
13
|
+
hostname: string;
|
|
14
|
+
/** Loopback port the tunnel forwards traffic to (vault = 1940). */
|
|
15
|
+
servicePort: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Emit a cloudflared config.yml. The shape is pinned to the documented
|
|
20
|
+
* Named Tunnel + ingress rules schema; we route every request for the
|
|
21
|
+
* configured hostname to the local service port and 404 everything else.
|
|
22
|
+
*
|
|
23
|
+
* Single-hostname ingress today. Multi-service routing (hub at /, vault
|
|
24
|
+
* under /vault/…, etc.) is deferred — the hub/OAuth seam lives on the
|
|
25
|
+
* Tailscale Funnel path and wasn't worth duplicating into the Cloudflare
|
|
26
|
+
* shape before we have a second CF user pushing on it.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Double-quote `credentials-file` so a `$HOME` with a space (e.g. macOS
|
|
30
|
+
* "John Doe") doesn't break YAML parsing. Tunnel UUIDs and `http://localhost`
|
|
31
|
+
* URLs don't need quoting by the YAML spec, so only this one path gets it.
|
|
32
|
+
* Backslashes and double-quotes inside the path are escaped.
|
|
33
|
+
*/
|
|
34
|
+
function yamlQuote(s: string): string {
|
|
35
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function renderConfig(opts: TunnelConfigOpts): string {
|
|
39
|
+
return `# Generated by parachute expose public --cloudflare — do not edit by hand.
|
|
40
|
+
# Re-running the command regenerates this file.
|
|
41
|
+
tunnel: ${opts.tunnelUuid}
|
|
42
|
+
credentials-file: ${yamlQuote(opts.credentialsFile)}
|
|
43
|
+
|
|
44
|
+
ingress:
|
|
45
|
+
- hostname: ${opts.hostname}
|
|
46
|
+
service: http://localhost:${opts.servicePort}
|
|
47
|
+
- service: http_status:404
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function writeConfig(
|
|
52
|
+
opts: TunnelConfigOpts,
|
|
53
|
+
configPath: string = CLOUDFLARED_CONFIG_PATH,
|
|
54
|
+
): string {
|
|
55
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
56
|
+
writeFileSync(configPath, renderConfig(opts));
|
|
57
|
+
return configPath;
|
|
58
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Runner } from "../tailscale/run.ts";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_CLOUDFLARED_HOME = join(homedir(), ".cloudflared");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `cloudflared --version` is the canonical liveness probe. Swallow only
|
|
10
|
+
* "binary not on PATH" errors — anything else (EACCES from a non-executable
|
|
11
|
+
* file, corrupted binary, etc.) propagates so we don't silently report
|
|
12
|
+
* "not installed" when something more specific is wrong.
|
|
13
|
+
*/
|
|
14
|
+
export async function isCloudflaredInstalled(runner: Runner): Promise<boolean> {
|
|
15
|
+
try {
|
|
16
|
+
const { code } = await runner(["cloudflared", "--version"]);
|
|
17
|
+
return code === 0;
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (isBinaryNotFoundError(err)) return false;
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isBinaryNotFoundError(err: unknown): boolean {
|
|
25
|
+
if (!err || typeof err !== "object") return false;
|
|
26
|
+
const e = err as { code?: unknown; message?: unknown };
|
|
27
|
+
if (e.code === "ENOENT") return true;
|
|
28
|
+
// Bun.spawn's error shape varies across versions; fall back to message
|
|
29
|
+
// string matching so we catch "Executable not found in $PATH" and
|
|
30
|
+
// "ENOENT" variants without pinning to one runtime detail.
|
|
31
|
+
if (typeof e.message === "string") {
|
|
32
|
+
return /ENOENT|not found|No such file/i.test(e.message);
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `cloudflared tunnel login` drops a cert at `~/.cloudflared/cert.pem` — its
|
|
39
|
+
* presence is cloudflared's own login marker. Every `cloudflared tunnel
|
|
40
|
+
* create|list|route` call reads this file; without it those commands fail
|
|
41
|
+
* with "Cannot determine default origin certificate path", which is a worse
|
|
42
|
+
* surface than catching the missing cert up front.
|
|
43
|
+
*/
|
|
44
|
+
export function isCloudflaredLoggedIn(cloudflaredHome: string = DEFAULT_CLOUDFLARED_HOME): boolean {
|
|
45
|
+
return existsSync(join(cloudflaredHome, "cert.pem"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function cloudflaredInstallHint(platform: NodeJS.Platform = process.platform): string {
|
|
49
|
+
const url =
|
|
50
|
+
"https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
|
|
51
|
+
if (platform === "darwin") {
|
|
52
|
+
return `Install cloudflared:\n brew install cloudflared\n(or see ${url})`;
|
|
53
|
+
}
|
|
54
|
+
if (platform === "linux") {
|
|
55
|
+
return `Install cloudflared: ${url}`;
|
|
56
|
+
}
|
|
57
|
+
return `Install cloudflared: ${url}`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { CONFIG_DIR } from "../config.ts";
|
|
11
|
+
|
|
12
|
+
export const CLOUDFLARED_STATE_PATH = join(CONFIG_DIR, "cloudflared-state.json");
|
|
13
|
+
|
|
14
|
+
export interface CloudflaredState {
|
|
15
|
+
version: 1;
|
|
16
|
+
pid: number;
|
|
17
|
+
tunnelUuid: string;
|
|
18
|
+
tunnelName: string;
|
|
19
|
+
hostname: string;
|
|
20
|
+
/** ISO-8601 start timestamp — debugging only. */
|
|
21
|
+
startedAt: string;
|
|
22
|
+
/** Absolute path to the cloudflared config.yml driving this tunnel. */
|
|
23
|
+
configPath: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class CloudflaredStateError extends Error {
|
|
27
|
+
override name = "CloudflaredStateError";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validate(raw: unknown, path: string): CloudflaredState {
|
|
31
|
+
if (!raw || typeof raw !== "object") {
|
|
32
|
+
throw new CloudflaredStateError(`${path}: root must be an object`);
|
|
33
|
+
}
|
|
34
|
+
const r = raw as Record<string, unknown>;
|
|
35
|
+
if (r.version !== 1) {
|
|
36
|
+
throw new CloudflaredStateError(`${path}: unsupported version ${String(r.version)}`);
|
|
37
|
+
}
|
|
38
|
+
if (typeof r.pid !== "number" || !Number.isInteger(r.pid) || r.pid <= 0) {
|
|
39
|
+
throw new CloudflaredStateError(`${path}: pid must be a positive integer`);
|
|
40
|
+
}
|
|
41
|
+
if (typeof r.tunnelUuid !== "string" || r.tunnelUuid.length === 0) {
|
|
42
|
+
throw new CloudflaredStateError(`${path}: tunnelUuid must be a non-empty string`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof r.tunnelName !== "string" || r.tunnelName.length === 0) {
|
|
45
|
+
throw new CloudflaredStateError(`${path}: tunnelName must be a non-empty string`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof r.hostname !== "string" || r.hostname.length === 0) {
|
|
48
|
+
throw new CloudflaredStateError(`${path}: hostname must be a non-empty string`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof r.startedAt !== "string" || r.startedAt.length === 0) {
|
|
51
|
+
throw new CloudflaredStateError(`${path}: startedAt must be a non-empty string`);
|
|
52
|
+
}
|
|
53
|
+
if (typeof r.configPath !== "string" || r.configPath.length === 0) {
|
|
54
|
+
throw new CloudflaredStateError(`${path}: configPath must be a non-empty string`);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
version: 1,
|
|
58
|
+
pid: r.pid,
|
|
59
|
+
tunnelUuid: r.tunnelUuid,
|
|
60
|
+
tunnelName: r.tunnelName,
|
|
61
|
+
hostname: r.hostname,
|
|
62
|
+
startedAt: r.startedAt,
|
|
63
|
+
configPath: r.configPath,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function readCloudflaredState(
|
|
68
|
+
path: string = CLOUDFLARED_STATE_PATH,
|
|
69
|
+
): CloudflaredState | undefined {
|
|
70
|
+
if (!existsSync(path)) return undefined;
|
|
71
|
+
let raw: unknown;
|
|
72
|
+
try {
|
|
73
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
throw new CloudflaredStateError(
|
|
76
|
+
`failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return validate(raw, path);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function writeCloudflaredState(
|
|
83
|
+
state: CloudflaredState,
|
|
84
|
+
path: string = CLOUDFLARED_STATE_PATH,
|
|
85
|
+
): void {
|
|
86
|
+
if (!existsSync(dirname(path))) {
|
|
87
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
90
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
91
|
+
renameSync(tmp, path);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function clearCloudflaredState(path: string = CLOUDFLARED_STATE_PATH): void {
|
|
95
|
+
if (existsSync(path)) unlinkSync(path);
|
|
96
|
+
}
|