@openparachute/hub 0.6.4-rc.5 → 0.6.4-rc.6
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/package.json +1 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-cloudflare.test.ts +27 -5
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/init.test.ts +42 -3
- package/src/__tests__/install.test.ts +24 -1
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-cloudflare.ts +75 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/init.ts +42 -2
- package/src/commands/install.ts +36 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.6.4-rc.
|
|
3
|
+
"version": "0.6.4-rc.6",
|
|
4
4
|
"description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
"format": "biome format --write .",
|
|
33
33
|
"typecheck": "tsc --noEmit",
|
|
34
34
|
"build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
|
|
35
|
-
"postinstall": "if [ -d web/ui ]; then bun run build:spa; fi",
|
|
36
35
|
"prepack": "bun run build:spa"
|
|
37
36
|
},
|
|
38
37
|
"devDependencies": {
|
|
@@ -7,12 +7,15 @@ import {
|
|
|
7
7
|
CloudflaredStateError,
|
|
8
8
|
type CloudflaredTunnelRecord,
|
|
9
9
|
clearCloudflaredState,
|
|
10
|
+
clearPendingHostname,
|
|
10
11
|
findTunnelRecord,
|
|
11
12
|
listTunnelRecords,
|
|
12
13
|
readCloudflaredState,
|
|
14
|
+
readPendingHostname,
|
|
13
15
|
withTunnelRecord,
|
|
14
16
|
withoutTunnelRecord,
|
|
15
17
|
writeCloudflaredState,
|
|
18
|
+
writePendingHostname,
|
|
16
19
|
} from "../cloudflare/state.ts";
|
|
17
20
|
|
|
18
21
|
function makeTempPath(): { path: string; cleanup: () => void } {
|
|
@@ -250,3 +253,104 @@ describe("cloudflared state — record helpers", () => {
|
|
|
250
253
|
expect(listTunnelRecords(undefined)).toEqual([]);
|
|
251
254
|
});
|
|
252
255
|
});
|
|
256
|
+
|
|
257
|
+
describe("hub#567 pending hostname", () => {
|
|
258
|
+
test("read returns undefined when no state file / no pending hostname", () => {
|
|
259
|
+
const { path, cleanup } = makeTempPath();
|
|
260
|
+
try {
|
|
261
|
+
expect(readPendingHostname(path)).toBeUndefined();
|
|
262
|
+
writeCloudflaredState(sample, path);
|
|
263
|
+
expect(readPendingHostname(path)).toBeUndefined();
|
|
264
|
+
} finally {
|
|
265
|
+
cleanup();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("write then read round-trips the pending hostname (seeds empty state)", () => {
|
|
270
|
+
const { path, cleanup } = makeTempPath();
|
|
271
|
+
try {
|
|
272
|
+
writePendingHostname("techne.parachute.computer", path);
|
|
273
|
+
expect(readPendingHostname(path)).toBe("techne.parachute.computer");
|
|
274
|
+
const state = readCloudflaredState(path);
|
|
275
|
+
expect(state?.pendingHostname).toBe("techne.parachute.computer");
|
|
276
|
+
expect(state?.tunnels).toEqual({});
|
|
277
|
+
} finally {
|
|
278
|
+
cleanup();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("write preserves existing tunnel records", () => {
|
|
283
|
+
const { path, cleanup } = makeTempPath();
|
|
284
|
+
try {
|
|
285
|
+
writeCloudflaredState(sample, path);
|
|
286
|
+
writePendingHostname("techne.parachute.computer", path);
|
|
287
|
+
const state = readCloudflaredState(path);
|
|
288
|
+
expect(state?.pendingHostname).toBe("techne.parachute.computer");
|
|
289
|
+
expect(state?.tunnels.parachute).toEqual(sampleRecord);
|
|
290
|
+
} finally {
|
|
291
|
+
cleanup();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("clear drops the pending hostname but keeps tunnel records", () => {
|
|
296
|
+
const { path, cleanup } = makeTempPath();
|
|
297
|
+
try {
|
|
298
|
+
writeCloudflaredState({ ...sample, pendingHostname: "techne.parachute.computer" }, path);
|
|
299
|
+
clearPendingHostname(path);
|
|
300
|
+
const state = readCloudflaredState(path);
|
|
301
|
+
expect(state?.pendingHostname).toBeUndefined();
|
|
302
|
+
expect(state?.tunnels.parachute).toEqual(sampleRecord);
|
|
303
|
+
} finally {
|
|
304
|
+
cleanup();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("clear removes the state file entirely when no tunnels remain", () => {
|
|
309
|
+
const { path, cleanup } = makeTempPath();
|
|
310
|
+
try {
|
|
311
|
+
writePendingHostname("techne.parachute.computer", path);
|
|
312
|
+
expect(existsSync(path)).toBe(true);
|
|
313
|
+
clearPendingHostname(path);
|
|
314
|
+
expect(existsSync(path)).toBe(false);
|
|
315
|
+
} finally {
|
|
316
|
+
cleanup();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("validate preserves a pending hostname round-tripped through the bytes", () => {
|
|
321
|
+
const { path, cleanup } = makeTempPath();
|
|
322
|
+
try {
|
|
323
|
+
const withPending: CloudflaredState = { ...sample, pendingHostname: "a.example.com" };
|
|
324
|
+
writeCloudflaredState(withPending, path);
|
|
325
|
+
expect(readCloudflaredState(path)).toEqual(withPending);
|
|
326
|
+
} finally {
|
|
327
|
+
cleanup();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("withTunnelRecord preserves an existing pending hostname", () => {
|
|
332
|
+
const seed: CloudflaredState = { version: 2, tunnels: {}, pendingHostname: "a.example.com" };
|
|
333
|
+
const next = withTunnelRecord(seed, sampleRecord);
|
|
334
|
+
expect(next.pendingHostname).toBe("a.example.com");
|
|
335
|
+
expect(next.tunnels.parachute).toEqual(sampleRecord);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("withoutTunnelRecord carries the pending hostname when it's the only thing left", () => {
|
|
339
|
+
const seed: CloudflaredState = {
|
|
340
|
+
version: 2,
|
|
341
|
+
tunnels: { parachute: sampleRecord },
|
|
342
|
+
pendingHostname: "a.example.com",
|
|
343
|
+
};
|
|
344
|
+
// Removing the last tunnel must NOT discard a typed-but-not-routed hostname.
|
|
345
|
+
expect(withoutTunnelRecord(seed, "parachute")).toEqual({
|
|
346
|
+
version: 2,
|
|
347
|
+
tunnels: {},
|
|
348
|
+
pendingHostname: "a.example.com",
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("withoutTunnelRecord returns undefined when no tunnels AND no pending hostname remain", () => {
|
|
353
|
+
const seed: CloudflaredState = { version: 2, tunnels: { parachute: sampleRecord } };
|
|
354
|
+
expect(withoutTunnelRecord(seed, "parachute")).toBeUndefined();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -509,16 +509,31 @@ describe("exposeCloudflareUp", () => {
|
|
|
509
509
|
}
|
|
510
510
|
});
|
|
511
511
|
|
|
512
|
-
test("
|
|
512
|
+
test("hub#564: continues (no vault gate) when vault isn't installed — routes the hub anyway", async () => {
|
|
513
513
|
const env = makeEnv({ includeVault: false });
|
|
514
514
|
try {
|
|
515
|
-
const
|
|
516
|
-
const
|
|
515
|
+
const uuid = "3d2b8d8f-2345-6789-abcd-ef0123456789";
|
|
516
|
+
const derived = "parachute-vault-example-com";
|
|
517
|
+
// The full chain must run now that the vault gate is gone: version,
|
|
518
|
+
// tunnel list, tunnel create, route dns.
|
|
519
|
+
const { runner } = queueRunner([
|
|
520
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
|
|
521
|
+
{ code: 0, stdout: "[]", stderr: "" }, // tunnel list
|
|
522
|
+
{
|
|
523
|
+
code: 0,
|
|
524
|
+
stdout: `Created tunnel ${derived} with id ${uuid}\n`,
|
|
525
|
+
stderr: "",
|
|
526
|
+
}, // create
|
|
527
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
528
|
+
]);
|
|
529
|
+
const { spawner } = fakeSpawner(42000);
|
|
517
530
|
const logs: string[] = [];
|
|
518
531
|
|
|
519
532
|
const code = await exposeCloudflareUp("vault.example.com", {
|
|
520
533
|
runner,
|
|
521
534
|
spawner,
|
|
535
|
+
alive: () => false,
|
|
536
|
+
kill: () => {},
|
|
522
537
|
log: (l) => logs.push(l),
|
|
523
538
|
manifestPath: env.manifestPath,
|
|
524
539
|
statePath: env.statePath,
|
|
@@ -528,10 +543,17 @@ describe("exposeCloudflareUp", () => {
|
|
|
528
543
|
cloudflaredHome: env.cloudflaredHome,
|
|
529
544
|
configDir: env.configDir,
|
|
530
545
|
skipHub: true,
|
|
546
|
+
now: () => new Date("2026-04-22T12:00:00Z"),
|
|
531
547
|
});
|
|
532
548
|
|
|
533
|
-
|
|
534
|
-
|
|
549
|
+
// The expose succeeds (the hub is what gets routed), with a courtesy
|
|
550
|
+
// note instead of a dead-end. No "install vault" gate, no Vault-URL
|
|
551
|
+
// footer (vault isn't there to point at).
|
|
552
|
+
expect(code).toBe(0);
|
|
553
|
+
const joined = logs.join("\n");
|
|
554
|
+
expect(joined).toContain("vault not installed yet");
|
|
555
|
+
expect(joined).not.toContain("nothing to route");
|
|
556
|
+
expect(joined).not.toMatch(/^\s*Vault:/m);
|
|
535
557
|
} finally {
|
|
536
558
|
env.cleanup();
|
|
537
559
|
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { readPendingHostname, writePendingHostname } from "../cloudflare/state.ts";
|
|
5
6
|
import { exposePublicInteractive } from "../commands/expose-interactive.ts";
|
|
6
7
|
import { readLastProvider, writeLastProvider } from "../expose-last-provider.ts";
|
|
7
8
|
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
@@ -15,6 +16,7 @@ const noopPreflight = async () => {};
|
|
|
15
16
|
interface TestEnv {
|
|
16
17
|
cloudflaredHome: string;
|
|
17
18
|
lastProviderPath: string;
|
|
19
|
+
statePath: string;
|
|
18
20
|
cleanup: () => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -28,6 +30,7 @@ function makeEnv(opts: { cloudflaredLoggedIn?: boolean } = {}): TestEnv {
|
|
|
28
30
|
return {
|
|
29
31
|
cloudflaredHome,
|
|
30
32
|
lastProviderPath: join(dir, "expose-last-provider.json"),
|
|
33
|
+
statePath: join(dir, "cloudflared-state.json"),
|
|
31
34
|
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
32
35
|
};
|
|
33
36
|
}
|
|
@@ -194,6 +197,75 @@ describe("exposePublicInteractive — both ready", () => {
|
|
|
194
197
|
}
|
|
195
198
|
});
|
|
196
199
|
|
|
200
|
+
test("hub#567: persists the typed hostname as soon as it validates", async () => {
|
|
201
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
202
|
+
try {
|
|
203
|
+
const { runner } = fixedRunner({
|
|
204
|
+
tailscaleInstalled: true,
|
|
205
|
+
tailscaleLoggedIn: true,
|
|
206
|
+
tailscaleFunnelCap: true,
|
|
207
|
+
cloudflaredInstalled: true,
|
|
208
|
+
});
|
|
209
|
+
const { prompt } = queuePrompt(["2", "vault.example.com"]);
|
|
210
|
+
// exposeCloudflareUpImpl FAILS — so the hostname must survive for a retry.
|
|
211
|
+
const code = await exposePublicInteractive({
|
|
212
|
+
runner,
|
|
213
|
+
prompt,
|
|
214
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
215
|
+
lastProviderPath: env.lastProviderPath,
|
|
216
|
+
statePath: env.statePath,
|
|
217
|
+
log: () => {},
|
|
218
|
+
exposePublicImpl: async () => 0,
|
|
219
|
+
exposeCloudflareUpImpl: async () => 1,
|
|
220
|
+
runAuthPreflightImpl: noopPreflight,
|
|
221
|
+
});
|
|
222
|
+
expect(code).toBe(1);
|
|
223
|
+
// Stashed despite the downstream failure.
|
|
224
|
+
expect(readPendingHostname(env.statePath)).toBe("vault.example.com");
|
|
225
|
+
} finally {
|
|
226
|
+
env.cleanup();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("hub#567: pre-fills the hostname prompt from a stashed value; Enter accepts it", async () => {
|
|
231
|
+
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
232
|
+
try {
|
|
233
|
+
writePendingHostname("techne.parachute.computer", env.statePath);
|
|
234
|
+
const { runner } = fixedRunner({
|
|
235
|
+
tailscaleInstalled: true,
|
|
236
|
+
tailscaleLoggedIn: true,
|
|
237
|
+
tailscaleFunnelCap: true,
|
|
238
|
+
cloudflaredInstalled: true,
|
|
239
|
+
});
|
|
240
|
+
// Pick cloudflare, then press Enter (blank) at the hostname prompt.
|
|
241
|
+
const { prompt, asked } = queuePrompt(["2", ""]);
|
|
242
|
+
let cloudflareHostname: string | undefined;
|
|
243
|
+
const code = await exposePublicInteractive({
|
|
244
|
+
runner,
|
|
245
|
+
prompt,
|
|
246
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
247
|
+
lastProviderPath: env.lastProviderPath,
|
|
248
|
+
statePath: env.statePath,
|
|
249
|
+
log: () => {},
|
|
250
|
+
exposePublicImpl: async () => 0,
|
|
251
|
+
exposeCloudflareUpImpl: async (h) => {
|
|
252
|
+
cloudflareHostname = h;
|
|
253
|
+
return 0;
|
|
254
|
+
},
|
|
255
|
+
runAuthPreflightImpl: noopPreflight,
|
|
256
|
+
});
|
|
257
|
+
expect(code).toBe(0);
|
|
258
|
+
// Enter accepted the stashed hostname.
|
|
259
|
+
expect(cloudflareHostname).toBe("techne.parachute.computer");
|
|
260
|
+
// The prompt surfaced the default in brackets.
|
|
261
|
+
expect(asked.some((q) => q.includes("[techne.parachute.computer]"))).toBe(true);
|
|
262
|
+
// Cleared once routing succeeded.
|
|
263
|
+
expect(readPendingHostname(env.statePath)).toBeUndefined();
|
|
264
|
+
} finally {
|
|
265
|
+
env.cleanup();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
197
269
|
test("'q' aborts cleanly with exit 0 and no downstream calls", async () => {
|
|
198
270
|
const env = makeEnv({ cloudflaredLoggedIn: true });
|
|
199
271
|
try {
|
|
@@ -440,11 +512,12 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
440
512
|
}
|
|
441
513
|
});
|
|
442
514
|
|
|
443
|
-
test("
|
|
515
|
+
test("hub#566: cloudflare on linux, user DECLINES auto-install: prints manual + --cloudflare hint, exits 1", async () => {
|
|
444
516
|
const env = makeEnv();
|
|
445
517
|
try {
|
|
446
518
|
const { runner } = fixedRunner({});
|
|
447
|
-
|
|
519
|
+
// "2" → cloudflare; "n" → decline the auto-install offer.
|
|
520
|
+
const { prompt } = queuePrompt(["2", "n"]);
|
|
448
521
|
const logs: string[] = [];
|
|
449
522
|
let interactiveCalled = false;
|
|
450
523
|
let cloudflareCalled = false;
|
|
@@ -456,8 +529,10 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
456
529
|
},
|
|
457
530
|
prompt,
|
|
458
531
|
platform: "linux",
|
|
532
|
+
arch: "x64",
|
|
459
533
|
cloudflaredHome: env.cloudflaredHome,
|
|
460
534
|
lastProviderPath: env.lastProviderPath,
|
|
535
|
+
statePath: env.statePath,
|
|
461
536
|
log: (l) => logs.push(l),
|
|
462
537
|
exposePublicImpl: async () => 0,
|
|
463
538
|
exposeCloudflareUpImpl: async () => {
|
|
@@ -466,16 +541,16 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
466
541
|
},
|
|
467
542
|
});
|
|
468
543
|
expect(code).toBe(1);
|
|
544
|
+
// Declining means no curl/chmod ran and we never reached the expose.
|
|
469
545
|
expect(interactiveCalled).toBe(false);
|
|
470
546
|
expect(cloudflareCalled).toBe(false);
|
|
471
547
|
const joined = logs.join("\n");
|
|
472
|
-
|
|
473
|
-
// off apt-get / dnf / developers.cloudflare.com (all unreliable —
|
|
474
|
-
// Aaron hit `No match for argument: cloudflared` on AL2023 and
|
|
475
|
-
// 404s from the docs URL on the same box) onto the static binary
|
|
476
|
-
// from GitHub releases.
|
|
548
|
+
expect(joined).toContain("Skipped auto-install");
|
|
477
549
|
expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
|
|
478
550
|
expect(joined).toContain("curl -L -o /usr/local/bin/cloudflared");
|
|
551
|
+
// hub#566: re-run hint carries the --cloudflare flag (bare `expose
|
|
552
|
+
// public` defaults to Tailscale).
|
|
553
|
+
expect(joined).toContain("parachute expose public --cloudflare");
|
|
479
554
|
expect(joined).not.toContain("developers.cloudflare.com");
|
|
480
555
|
expect(joined).not.toContain("pkg.cloudflare.com");
|
|
481
556
|
expect(joined).not.toContain("sudo dnf install cloudflared");
|
|
@@ -484,6 +559,158 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
484
559
|
}
|
|
485
560
|
});
|
|
486
561
|
|
|
562
|
+
test("hub#566: cloudflare on linux as ROOT, accepts auto-install: runs bare curl+chmod (no sudo), then exposes", async () => {
|
|
563
|
+
const env = makeEnv();
|
|
564
|
+
try {
|
|
565
|
+
// cloudflared starts absent (so the install offer fires), then present
|
|
566
|
+
// after the install runs (so the verify probe + flow continue).
|
|
567
|
+
let cloudflaredPresent = false;
|
|
568
|
+
const runner: Runner = async (cmd) => {
|
|
569
|
+
if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
|
|
570
|
+
return cloudflaredPresent
|
|
571
|
+
? { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }
|
|
572
|
+
: { code: 127, stdout: "", stderr: "not found" };
|
|
573
|
+
}
|
|
574
|
+
if (cmd[0] === "tailscale") {
|
|
575
|
+
// Detection: tailscale absent (forces the cloudflare-only path).
|
|
576
|
+
return { code: 127, stdout: "", stderr: "not found" };
|
|
577
|
+
}
|
|
578
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
579
|
+
};
|
|
580
|
+
// "2" cloudflare → "Y" install → "Y" login → hostname. The login prompt
|
|
581
|
+
// fires because detection reported cloudflared absent (so loggedIn=false)
|
|
582
|
+
// even though cert.pem appears once login "runs".
|
|
583
|
+
const { prompt } = queuePrompt(["2", "y", "y", "vault.example.com"]);
|
|
584
|
+
const interactiveCmds: string[][] = [];
|
|
585
|
+
const logs: string[] = [];
|
|
586
|
+
let cloudflareHostname = "";
|
|
587
|
+
const code = await exposePublicInteractive({
|
|
588
|
+
runner,
|
|
589
|
+
interactiveRunner: async (cmd) => {
|
|
590
|
+
interactiveCmds.push([...cmd]);
|
|
591
|
+
// Install "succeeds": flip cloudflared to present. Login "succeeds":
|
|
592
|
+
// drop the cert so `isCloudflaredLoggedIn` reads true afterward.
|
|
593
|
+
if (cmd.includes("login")) writeFileSync(join(env.cloudflaredHome, "cert.pem"), "---");
|
|
594
|
+
else cloudflaredPresent = true;
|
|
595
|
+
return 0;
|
|
596
|
+
},
|
|
597
|
+
prompt,
|
|
598
|
+
platform: "linux",
|
|
599
|
+
arch: "x64",
|
|
600
|
+
getuid: () => 0, // root
|
|
601
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
602
|
+
lastProviderPath: env.lastProviderPath,
|
|
603
|
+
statePath: env.statePath,
|
|
604
|
+
log: (l) => logs.push(l),
|
|
605
|
+
exposePublicImpl: async () => 0,
|
|
606
|
+
exposeCloudflareUpImpl: async (hostname) => {
|
|
607
|
+
cloudflareHostname = hostname;
|
|
608
|
+
return 0;
|
|
609
|
+
},
|
|
610
|
+
runAuthPreflightImpl: noopPreflight,
|
|
611
|
+
});
|
|
612
|
+
expect(code).toBe(0);
|
|
613
|
+
expect(cloudflareHostname).toBe("vault.example.com");
|
|
614
|
+
// Root runs curl + chmod WITHOUT a sudo prefix.
|
|
615
|
+
expect(interactiveCmds[0]?.[0]).toBe("curl");
|
|
616
|
+
expect(interactiveCmds[0]).toContain("/usr/local/bin/cloudflared");
|
|
617
|
+
expect(interactiveCmds[1]?.[0]).toBe("chmod");
|
|
618
|
+
expect(interactiveCmds.some((c) => c[0] === "sudo")).toBe(false);
|
|
619
|
+
expect(logs.join("\n")).toContain("✓ cloudflared installed.");
|
|
620
|
+
} finally {
|
|
621
|
+
env.cleanup();
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("hub#566: cloudflare on linux NON-root, accepts auto-install: wraps curl+chmod in sudo", async () => {
|
|
626
|
+
const env = makeEnv();
|
|
627
|
+
try {
|
|
628
|
+
let cloudflaredPresent = false;
|
|
629
|
+
const runner: Runner = async (cmd) => {
|
|
630
|
+
if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
|
|
631
|
+
return cloudflaredPresent
|
|
632
|
+
? { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }
|
|
633
|
+
: { code: 127, stdout: "", stderr: "not found" };
|
|
634
|
+
}
|
|
635
|
+
if (cmd[0] === "tailscale") return { code: 127, stdout: "", stderr: "not found" };
|
|
636
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
637
|
+
};
|
|
638
|
+
const { prompt } = queuePrompt(["2", "y", "y", "vault.example.com"]);
|
|
639
|
+
const interactiveCmds: string[][] = [];
|
|
640
|
+
const code = await exposePublicInteractive({
|
|
641
|
+
runner,
|
|
642
|
+
interactiveRunner: async (cmd) => {
|
|
643
|
+
interactiveCmds.push([...cmd]);
|
|
644
|
+
if (cmd.includes("login")) writeFileSync(join(env.cloudflaredHome, "cert.pem"), "---");
|
|
645
|
+
else cloudflaredPresent = true;
|
|
646
|
+
return 0;
|
|
647
|
+
},
|
|
648
|
+
prompt,
|
|
649
|
+
platform: "linux",
|
|
650
|
+
arch: "arm64",
|
|
651
|
+
getuid: () => 1000, // non-root
|
|
652
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
653
|
+
lastProviderPath: env.lastProviderPath,
|
|
654
|
+
statePath: env.statePath,
|
|
655
|
+
log: () => {},
|
|
656
|
+
exposePublicImpl: async () => 0,
|
|
657
|
+
exposeCloudflareUpImpl: async () => 0,
|
|
658
|
+
runAuthPreflightImpl: noopPreflight,
|
|
659
|
+
});
|
|
660
|
+
expect(code).toBe(0);
|
|
661
|
+
// Non-root prefixes both privileged steps with non-interactive `sudo -n`
|
|
662
|
+
// (fails fast instead of hanging on a password prompt under a detached
|
|
663
|
+
// init).
|
|
664
|
+
expect(interactiveCmds[0]?.slice(0, 2)).toEqual(["sudo", "-n"]);
|
|
665
|
+
expect(interactiveCmds[0]).toContain("curl");
|
|
666
|
+
expect(interactiveCmds[1]?.slice(0, 2)).toEqual(["sudo", "-n"]);
|
|
667
|
+
expect(interactiveCmds[1]).toContain("chmod");
|
|
668
|
+
} finally {
|
|
669
|
+
env.cleanup();
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("hub#566: cloudflare on linux, sudo curl FAILS: prints manual + --cloudflare hint, exits 1", async () => {
|
|
674
|
+
const env = makeEnv();
|
|
675
|
+
try {
|
|
676
|
+
const runner: Runner = async (cmd) => {
|
|
677
|
+
if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
|
|
678
|
+
return { code: 127, stdout: "", stderr: "not found" };
|
|
679
|
+
}
|
|
680
|
+
if (cmd[0] === "tailscale") return { code: 127, stdout: "", stderr: "not found" };
|
|
681
|
+
throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
|
|
682
|
+
};
|
|
683
|
+
const { prompt } = queuePrompt(["2", "y"]);
|
|
684
|
+
const logs: string[] = [];
|
|
685
|
+
let cloudflareCalled = false;
|
|
686
|
+
const code = await exposePublicInteractive({
|
|
687
|
+
runner,
|
|
688
|
+
// Simulate sudo failing (no cached creds, no tty).
|
|
689
|
+
interactiveRunner: async () => 1,
|
|
690
|
+
prompt,
|
|
691
|
+
platform: "linux",
|
|
692
|
+
arch: "x64",
|
|
693
|
+
getuid: () => 1000,
|
|
694
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
695
|
+
lastProviderPath: env.lastProviderPath,
|
|
696
|
+
statePath: env.statePath,
|
|
697
|
+
log: (l) => logs.push(l),
|
|
698
|
+
exposePublicImpl: async () => 0,
|
|
699
|
+
exposeCloudflareUpImpl: async () => {
|
|
700
|
+
cloudflareCalled = true;
|
|
701
|
+
return 0;
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
expect(code).toBe(1);
|
|
705
|
+
expect(cloudflareCalled).toBe(false);
|
|
706
|
+
const joined = logs.join("\n");
|
|
707
|
+
expect(joined).toContain("Download failed");
|
|
708
|
+
expect(joined).toContain("parachute expose public --cloudflare");
|
|
709
|
+
} finally {
|
|
710
|
+
env.cleanup();
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
|
|
487
714
|
test("user picks cloudflare on macos but declines brew: exits 1, no install attempted", async () => {
|
|
488
715
|
const env = makeEnv();
|
|
489
716
|
try {
|
|
@@ -898,14 +898,15 @@ describe("init exposure chain", () => {
|
|
|
898
898
|
}
|
|
899
899
|
});
|
|
900
900
|
|
|
901
|
-
test("exposure chain
|
|
901
|
+
test("hub#565: a failed exposure chain does NOT abort init — warns, continues, exits 0", async () => {
|
|
902
902
|
const h = makeHarness();
|
|
903
903
|
try {
|
|
904
904
|
writeHubPort(1939, h.configDir);
|
|
905
|
+
const logs: string[] = [];
|
|
905
906
|
const code = await init({
|
|
906
907
|
configDir: h.configDir,
|
|
907
908
|
manifestPath: h.manifestPath,
|
|
908
|
-
log: () =>
|
|
909
|
+
log: (l) => logs.push(l),
|
|
909
910
|
alive: () => false,
|
|
910
911
|
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
911
912
|
readExposeStateFn: () => undefined,
|
|
@@ -915,8 +916,46 @@ describe("init exposure chain", () => {
|
|
|
915
916
|
exposeTailnetImpl: async () => 0,
|
|
916
917
|
exposeCloudflareImpl: async () => 2,
|
|
917
918
|
exposeChoice: "cloudflare",
|
|
919
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
918
920
|
});
|
|
919
|
-
|
|
921
|
+
// Init reaches the admin-URL/wizard handoff regardless of the expose
|
|
922
|
+
// failure (exposure is an enhancement, not a prerequisite).
|
|
923
|
+
expect(code).toBe(0);
|
|
924
|
+
const joined = logs.join("\n");
|
|
925
|
+
// Warned about the failure + printed the exact retry command (the
|
|
926
|
+
// `--cloudflare` flag matters — bare `expose public` defaults to
|
|
927
|
+
// Tailscale, hub#566).
|
|
928
|
+
expect(joined).toContain("Couldn't finish setting up public access");
|
|
929
|
+
expect(joined).toContain("parachute expose public --cloudflare");
|
|
930
|
+
// Fell through to the loopback admin URL.
|
|
931
|
+
expect(joined).toContain("http://127.0.0.1:1939/admin/");
|
|
932
|
+
} finally {
|
|
933
|
+
h.cleanup();
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test("hub#565: tailnet expose failure prints the --tailnet retry command", async () => {
|
|
938
|
+
const h = makeHarness();
|
|
939
|
+
try {
|
|
940
|
+
writeHubPort(1939, h.configDir);
|
|
941
|
+
const logs: string[] = [];
|
|
942
|
+
const code = await init({
|
|
943
|
+
configDir: h.configDir,
|
|
944
|
+
manifestPath: h.manifestPath,
|
|
945
|
+
log: (l) => logs.push(l),
|
|
946
|
+
alive: () => false,
|
|
947
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
948
|
+
readExposeStateFn: () => undefined,
|
|
949
|
+
isTty: false,
|
|
950
|
+
platform: "linux",
|
|
951
|
+
env: {},
|
|
952
|
+
exposeTailnetImpl: async () => 3,
|
|
953
|
+
exposeCloudflareImpl: async () => 0,
|
|
954
|
+
exposeChoice: "tailnet",
|
|
955
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
956
|
+
});
|
|
957
|
+
expect(code).toBe(0);
|
|
958
|
+
expect(logs.join("\n")).toContain("parachute expose public --tailnet");
|
|
920
959
|
} finally {
|
|
921
960
|
h.cleanup();
|
|
922
961
|
}
|
|
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { install } from "../commands/install.ts";
|
|
5
|
+
import { defaultStartLifecycleOpts, install } from "../commands/install.ts";
|
|
6
6
|
import { findService, upsertService } from "../services-manifest.ts";
|
|
7
7
|
|
|
8
8
|
function makeTempPath(): { path: string; configDir: string; cleanup: () => void } {
|
|
@@ -1721,3 +1721,26 @@ describe("install", () => {
|
|
|
1721
1721
|
}
|
|
1722
1722
|
});
|
|
1723
1723
|
});
|
|
1724
|
+
|
|
1725
|
+
describe("hub#573 — install auto-start converges on supervised detection", () => {
|
|
1726
|
+
test("the default start opts opt into real supervisor detection + the migrate offer", () => {
|
|
1727
|
+
const log = () => {};
|
|
1728
|
+
const opts = defaultStartLifecycleOpts({
|
|
1729
|
+
manifestPath: "/tmp/services.json",
|
|
1730
|
+
configDir: "/tmp/cfg",
|
|
1731
|
+
log,
|
|
1732
|
+
});
|
|
1733
|
+
// `supervisor: {}` (present, even if empty) → lifecycle resolves
|
|
1734
|
+
// `unitInstalled` via the real `isHubUnitInstalled` probe instead of the
|
|
1735
|
+
// omitted-supervisor default of `false`. Pre-fix this block was absent, so
|
|
1736
|
+
// the auto-start ALWAYS concluded "no unit" and printed the spurious
|
|
1737
|
+
// "No supervised hub unit is installed" + "didn't start cleanly".
|
|
1738
|
+
expect(opts.supervisor).toEqual({});
|
|
1739
|
+
// The cutover offer is armed, matching `parachute start <svc>` (cli.ts).
|
|
1740
|
+
expect(opts.migrateOffer).toEqual({ enabled: true });
|
|
1741
|
+
// Plumbing preserved.
|
|
1742
|
+
expect(opts.manifestPath).toBe("/tmp/services.json");
|
|
1743
|
+
expect(opts.configDir).toBe("/tmp/cfg");
|
|
1744
|
+
expect(opts.log).toBe(log);
|
|
1745
|
+
});
|
|
1746
|
+
});
|
package/src/cloudflare/detect.ts
CHANGED
|
@@ -109,7 +109,7 @@ export function cloudflaredInstallHint(
|
|
|
109
109
|
* artifact (registry recipe is undefined) — the caller then uses the generic
|
|
110
110
|
* pointer. Keeps the arch→suffix mapping in exactly one place (the registry).
|
|
111
111
|
*/
|
|
112
|
-
function cloudflaredLinuxDownloadUrl(arch: NodeJS.Architecture): string | undefined {
|
|
112
|
+
export function cloudflaredLinuxDownloadUrl(arch: NodeJS.Architecture): string | undefined {
|
|
113
113
|
const recipe = lookupDep("cloudflared")?.install.linuxBinaryUrl?.(arch);
|
|
114
114
|
if (!recipe) return undefined;
|
|
115
115
|
const urlLine = recipe
|
package/src/cloudflare/state.ts
CHANGED
|
@@ -45,6 +45,15 @@ export interface CloudflaredTunnelRecord {
|
|
|
45
45
|
export interface CloudflaredState {
|
|
46
46
|
version: 2;
|
|
47
47
|
tunnels: Record<string, CloudflaredTunnelRecord>;
|
|
48
|
+
/**
|
|
49
|
+
* A hostname the operator typed in the interactive Cloudflare flow that
|
|
50
|
+
* hasn't been routed yet (hub#567). Persisted as soon as it validates so a
|
|
51
|
+
* mid-chain failure (cloudflared missing, login, tunnel/DNS error) doesn't
|
|
52
|
+
* discard it — the next interactive run pre-fills the hostname prompt with
|
|
53
|
+
* it. Cleared once routing succeeds (the tunnel record then carries the live
|
|
54
|
+
* hostname). Optional + free-floating from the per-tunnel records.
|
|
55
|
+
*/
|
|
56
|
+
pendingHostname?: string;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
export class CloudflaredStateError extends Error {
|
|
@@ -91,11 +100,21 @@ function validate(raw: unknown, path: string): CloudflaredState {
|
|
|
91
100
|
throw new CloudflaredStateError(`${path}: root must be an object`);
|
|
92
101
|
}
|
|
93
102
|
const r = raw as Record<string, unknown>;
|
|
103
|
+
// hub#567: an optional top-level `pendingHostname` (a typed-but-not-yet-routed
|
|
104
|
+
// hostname). Non-string / empty values read as absent so older state files
|
|
105
|
+
// keep validating.
|
|
106
|
+
const pendingHostname =
|
|
107
|
+
typeof r.pendingHostname === "string" && r.pendingHostname.length > 0
|
|
108
|
+
? r.pendingHostname
|
|
109
|
+
: undefined;
|
|
110
|
+
const withPending = (state: CloudflaredState): CloudflaredState =>
|
|
111
|
+
pendingHostname ? { ...state, pendingHostname } : state;
|
|
112
|
+
|
|
94
113
|
if (r.version === 1) {
|
|
95
114
|
// v1 — single record at top level. Migrate by wrapping it under its
|
|
96
115
|
// tunnelName. Disk isn't rewritten until the next write.
|
|
97
116
|
const record = validateRecord(r, path);
|
|
98
|
-
return { version: 2, tunnels: { [record.tunnelName]: record } };
|
|
117
|
+
return withPending({ version: 2, tunnels: { [record.tunnelName]: record } });
|
|
99
118
|
}
|
|
100
119
|
if (r.version !== 2) {
|
|
101
120
|
throw new CloudflaredStateError(`${path}: unsupported version ${String(r.version)}`);
|
|
@@ -113,7 +132,7 @@ function validate(raw: unknown, path: string): CloudflaredState {
|
|
|
113
132
|
}
|
|
114
133
|
tunnels[key] = record;
|
|
115
134
|
}
|
|
116
|
-
return { version: 2, tunnels };
|
|
135
|
+
return withPending({ version: 2, tunnels });
|
|
117
136
|
}
|
|
118
137
|
|
|
119
138
|
export function readCloudflaredState(
|
|
@@ -161,13 +180,88 @@ export function withTunnelRecord(
|
|
|
161
180
|
record: CloudflaredTunnelRecord,
|
|
162
181
|
): CloudflaredState {
|
|
163
182
|
const tunnels = { ...(state?.tunnels ?? {}), [record.tunnelName]: record };
|
|
164
|
-
|
|
183
|
+
// Preserve any pending hostname (hub#567); the caller clears it explicitly
|
|
184
|
+
// via `clearPendingHostname` once routing fully succeeds.
|
|
185
|
+
return state?.pendingHostname
|
|
186
|
+
? { version: 2, tunnels, pendingHostname: state.pendingHostname }
|
|
187
|
+
: { version: 2, tunnels };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Pure: set the pending (typed-but-not-routed) hostname on the state (hub#567).
|
|
192
|
+
* Seeds an empty v2 state when none exists yet.
|
|
193
|
+
*/
|
|
194
|
+
export function withPendingHostname(
|
|
195
|
+
state: CloudflaredState | undefined,
|
|
196
|
+
hostname: string,
|
|
197
|
+
): CloudflaredState {
|
|
198
|
+
return { version: 2, tunnels: state?.tunnels ?? {}, pendingHostname: hostname };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Pure: drop the pending hostname (hub#567). Returns undefined when the result
|
|
203
|
+
* would carry no tunnels either, so the caller can `clearCloudflaredState`
|
|
204
|
+
* rather than write an empty file.
|
|
205
|
+
*/
|
|
206
|
+
export function withoutPendingHostname(
|
|
207
|
+
state: CloudflaredState | undefined,
|
|
208
|
+
): CloudflaredState | undefined {
|
|
209
|
+
if (!state) return undefined;
|
|
210
|
+
if (Object.keys(state.tunnels).length === 0) return undefined;
|
|
211
|
+
return { version: 2, tunnels: state.tunnels };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Read the pending hostname from the on-disk state (hub#567). Returns undefined
|
|
216
|
+
* when there's no state file or no pending hostname. Swallows read/parse errors
|
|
217
|
+
* (a corrupt state file must not abort the prompt — we just don't pre-fill).
|
|
218
|
+
*/
|
|
219
|
+
export function readPendingHostname(path: string = CLOUDFLARED_STATE_PATH): string | undefined {
|
|
220
|
+
try {
|
|
221
|
+
return readCloudflaredState(path)?.pendingHostname;
|
|
222
|
+
} catch {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Persist a typed-but-not-yet-routed hostname (hub#567), preserving existing
|
|
229
|
+
* tunnel records. Best-effort: a write failure must not abort the expose flow.
|
|
230
|
+
*/
|
|
231
|
+
export function writePendingHostname(
|
|
232
|
+
hostname: string,
|
|
233
|
+
path: string = CLOUDFLARED_STATE_PATH,
|
|
234
|
+
): void {
|
|
235
|
+
try {
|
|
236
|
+
const state = readCloudflaredState(path);
|
|
237
|
+
writeCloudflaredState(withPendingHostname(state, hostname), path);
|
|
238
|
+
} catch {
|
|
239
|
+
// Non-fatal — persistence is a convenience, not a correctness requirement.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Clear the pending hostname once routing succeeds (hub#567). If no tunnel
|
|
245
|
+
* records remain, removes the state file entirely. Best-effort.
|
|
246
|
+
*/
|
|
247
|
+
export function clearPendingHostname(path: string = CLOUDFLARED_STATE_PATH): void {
|
|
248
|
+
try {
|
|
249
|
+
const state = readCloudflaredState(path);
|
|
250
|
+
if (!state?.pendingHostname) return;
|
|
251
|
+
const next = withoutPendingHostname(state);
|
|
252
|
+
if (next) writeCloudflaredState(next, path);
|
|
253
|
+
else clearCloudflaredState(path);
|
|
254
|
+
} catch {
|
|
255
|
+
// Non-fatal.
|
|
256
|
+
}
|
|
165
257
|
}
|
|
166
258
|
|
|
167
259
|
/**
|
|
168
|
-
* Pure: drop the named tunnel from state. Returns undefined when
|
|
169
|
-
*
|
|
170
|
-
* an empty file.
|
|
260
|
+
* Pure: drop the named tunnel from state. Returns undefined when NO tunnels AND
|
|
261
|
+
* no pending hostname remain, so callers can `clearCloudflaredState` instead of
|
|
262
|
+
* writing an empty file. A pending hostname (hub#567) is carried forward — both
|
|
263
|
+
* when other tunnels survive and when it's the only thing left — so removing a
|
|
264
|
+
* tunnel never discards a typed-but-not-routed hostname awaiting retry.
|
|
171
265
|
*/
|
|
172
266
|
export function withoutTunnelRecord(
|
|
173
267
|
state: CloudflaredState | undefined,
|
|
@@ -175,8 +269,10 @@ export function withoutTunnelRecord(
|
|
|
175
269
|
): CloudflaredState | undefined {
|
|
176
270
|
if (!state) return undefined;
|
|
177
271
|
const { [tunnelName]: _dropped, ...rest } = state.tunnels;
|
|
178
|
-
if (Object.keys(rest).length === 0) return undefined;
|
|
179
|
-
return
|
|
272
|
+
if (Object.keys(rest).length === 0 && !state.pendingHostname) return undefined;
|
|
273
|
+
return state.pendingHostname
|
|
274
|
+
? { version: 2, tunnels: rest, pendingHostname: state.pendingHostname }
|
|
275
|
+
: { version: 2, tunnels: rest };
|
|
180
276
|
}
|
|
181
277
|
|
|
182
278
|
/** All tunnel records, in name-sorted order so output is deterministic. */
|
|
@@ -51,7 +51,7 @@ import { HUB_DEFAULT_PORT, readHubPort } from "../hub-control.ts";
|
|
|
51
51
|
import { deriveHubOrigin } from "../hub-origin.ts";
|
|
52
52
|
import { HUB_UNIT_DEFAULT_PORT } from "../hub-unit.ts";
|
|
53
53
|
import { type AliveFn, defaultAlive } from "../process-state.ts";
|
|
54
|
-
import {
|
|
54
|
+
import { readManifestLenient } from "../services-manifest.ts";
|
|
55
55
|
import { type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
56
56
|
import type { VaultAuthStatus } from "../vault/auth-status.ts";
|
|
57
57
|
import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
|
|
@@ -91,6 +91,22 @@ export function isValidHostname(h: string): boolean {
|
|
|
91
91
|
return h.split(".").every((label) => labelRe.test(label));
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Best-effort "is this module registered in services.json?" check, used only
|
|
96
|
+
* for the courtesy note in the Cloudflare expose path (hub#564). Uses the
|
|
97
|
+
* LENIENT manifest reader on purpose: the strict `readManifest` can reject an
|
|
98
|
+
* older manifest shape (the #564 diagnostic — a manifest written by 0.6.3-era
|
|
99
|
+
* registration that the strict reader bounced), and a courtesy note must never
|
|
100
|
+
* throw. Any read error is swallowed and treated as "not installed".
|
|
101
|
+
*/
|
|
102
|
+
function serviceInstalled(manifestPath: string, name: string): boolean {
|
|
103
|
+
try {
|
|
104
|
+
return readManifestLenient(manifestPath).services.some((s) => s.name === name);
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
export interface CloudflaredSpawner {
|
|
95
111
|
spawn(cmd: readonly string[], logFile: string): number;
|
|
96
112
|
}
|
|
@@ -606,12 +622,18 @@ export async function exposeCloudflareUp(
|
|
|
606
622
|
return 1;
|
|
607
623
|
}
|
|
608
624
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
625
|
+
// No vault gate here (hub#564). cloudflared's ingress targets the HUB port
|
|
626
|
+
// (see `servicePort: hubPort` in `writeConfig` below + the long comment
|
|
627
|
+
// there) — the hub does ALL routing (discovery, admin, OAuth, well-known,
|
|
628
|
+
// per-vault proxy, generic /<svc>/* dispatch). Vault doesn't have to be
|
|
629
|
+
// installed for the tunnel to route anything. The old gate
|
|
630
|
+
// (`parachute-vault is not installed; nothing to route` → return 1) was a
|
|
631
|
+
// vestige of the pre-2026-05-27 vault-centric ingress, and it dead-ended
|
|
632
|
+
// fresh-server init: post-#168 init exposes (Step 2) BEFORE it installs the
|
|
633
|
+
// vault module (Step 2.5), so the gate always tripped on a fresh box and
|
|
634
|
+
// aborted the whole init. Courtesy note only — never block.
|
|
635
|
+
if (!serviceInstalled(r.manifestPath, "parachute-vault")) {
|
|
636
|
+
r.log("vault not installed yet — the admin wizard will set it up. Routing the hub anyway.");
|
|
615
637
|
}
|
|
616
638
|
|
|
617
639
|
// Resolve the public hub origin before spawning the hub server — it gets
|
|
@@ -897,45 +919,58 @@ export async function exposeCloudflareUp(
|
|
|
897
919
|
// and makes the running vault re-read it immediately rather than waiting for
|
|
898
920
|
// the next reboot.
|
|
899
921
|
//
|
|
922
|
+
// hub#564: vault may not be installed yet (init exposes BEFORE installing the
|
|
923
|
+
// vault module). Resolve the entry once, here, and gate the vault-restart +
|
|
924
|
+
// the Vault-URL footer on it — restarting a not-installed vault would just
|
|
925
|
+
// fail and print a spurious warning. When present, a well-formed manifest
|
|
926
|
+
// always lists at least one mount path.
|
|
927
|
+
const vaultEntry = (() => {
|
|
928
|
+
try {
|
|
929
|
+
return readManifestLenient(r.manifestPath).services.find((s) => s.name === "parachute-vault");
|
|
930
|
+
} catch {
|
|
931
|
+
return undefined;
|
|
932
|
+
}
|
|
933
|
+
})();
|
|
934
|
+
|
|
900
935
|
// §4.3c: drive the restart through the running Supervisor
|
|
901
936
|
// (`driveModuleOp("vault", "restart")`), which re-injects the hub's current
|
|
902
937
|
// origin; `restartHubDependentViaSupervisor` also persists the durable `.env`
|
|
903
938
|
// + self-heals the operator-token issuer. Phase 5b retired the detached
|
|
904
|
-
// `lifecycle.restart` arm.
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
939
|
+
// `lifecycle.restart` arm. Skipped entirely when vault isn't installed.
|
|
940
|
+
if (vaultEntry) {
|
|
941
|
+
r.log("");
|
|
942
|
+
r.log("Restarting vault to pick up new hub origin…");
|
|
943
|
+
const rcode = await restartHubDependentViaSupervisor({
|
|
944
|
+
short: "vault",
|
|
945
|
+
hubOrigin,
|
|
946
|
+
configDir: r.configDir,
|
|
947
|
+
sup: r.sup,
|
|
948
|
+
log: r.log,
|
|
949
|
+
});
|
|
950
|
+
if (rcode !== 0) {
|
|
951
|
+
r.log(
|
|
952
|
+
"⚠ vault restart failed. Run manually once the issue is resolved: parachute restart vault",
|
|
953
|
+
);
|
|
954
|
+
}
|
|
918
955
|
}
|
|
919
956
|
|
|
920
957
|
const baseUrl = `https://${hostname}`;
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
958
|
+
let vaultUrl: string | undefined;
|
|
959
|
+
if (vaultEntry) {
|
|
960
|
+
if (!vaultEntry.paths[0]) {
|
|
961
|
+
r.log(
|
|
962
|
+
`⚠ vault entry in services.json has no paths[]; defaulting to "/vault/default". Check the manifest.`,
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
vaultUrl = `${baseUrl}${vaultEntry.paths[0] ?? "/vault/default"}`;
|
|
929
966
|
}
|
|
930
|
-
const vaultMount = vaultEntry.paths[0] ?? "/vault/default";
|
|
931
|
-
const vaultUrl = `${baseUrl}${vaultMount}`;
|
|
932
967
|
|
|
933
968
|
r.log("");
|
|
934
969
|
r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
|
|
935
970
|
r.log(` Tunnel: ${r.tunnelName} (dedicated to this machine)`);
|
|
936
971
|
r.log(` Open: ${baseUrl}/`);
|
|
937
972
|
r.log(` Admin: ${baseUrl}/admin/`);
|
|
938
|
-
r.log(` Vault: ${vaultUrl}`);
|
|
973
|
+
if (vaultUrl) r.log(` Vault: ${vaultUrl}`);
|
|
939
974
|
r.log(` OAuth: ${hubOrigin}`);
|
|
940
975
|
r.log(` Logs: ${r.logPath}`);
|
|
941
976
|
r.log("");
|
|
@@ -954,10 +989,14 @@ export async function exposeCloudflareUp(
|
|
|
954
989
|
r.log("background but does NOT survive a reboot. After a reboot, re-run:");
|
|
955
990
|
r.log(` parachute expose public --cloudflare --domain ${hostname}`);
|
|
956
991
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
992
|
+
// The connector guidance points at a vault MCP URL — only meaningful once a
|
|
993
|
+
// vault is installed (hub#564: it may not be yet, when init exposes first).
|
|
994
|
+
if (vaultUrl) {
|
|
995
|
+
r.log("");
|
|
996
|
+
r.log("Point a claude.ai / ChatGPT connector at:");
|
|
997
|
+
r.log(` ${vaultUrl}`);
|
|
998
|
+
printAuthGuidance(r.log, vaultUrl);
|
|
999
|
+
}
|
|
961
1000
|
// 2FA-enrollment warning when /admin/login is now reachable on the public
|
|
962
1001
|
// internet but the operator hasn't enrolled TOTP. Cloudflare exposure is
|
|
963
1002
|
// always public; tailnet/funnel mirrors this in `expose.ts`. See #186.
|
|
@@ -14,9 +14,16 @@ import { createInterface } from "node:readline/promises";
|
|
|
14
14
|
import {
|
|
15
15
|
DEFAULT_CLOUDFLARED_HOME,
|
|
16
16
|
cloudflaredInstallHint,
|
|
17
|
+
cloudflaredLinuxDownloadUrl,
|
|
17
18
|
isCloudflaredInstalled,
|
|
18
19
|
isCloudflaredLoggedIn,
|
|
19
20
|
} from "../cloudflare/detect.ts";
|
|
21
|
+
import {
|
|
22
|
+
CLOUDFLARED_STATE_PATH,
|
|
23
|
+
clearPendingHostname,
|
|
24
|
+
readPendingHostname,
|
|
25
|
+
writePendingHostname,
|
|
26
|
+
} from "../cloudflare/state.ts";
|
|
20
27
|
import {
|
|
21
28
|
EXPOSE_LAST_PROVIDER_PATH,
|
|
22
29
|
type ExposeProvider,
|
|
@@ -73,6 +80,19 @@ export interface ExposeInteractiveOpts {
|
|
|
73
80
|
prompt?: (question: string) => Promise<string>;
|
|
74
81
|
cloudflaredHome?: string;
|
|
75
82
|
platform?: NodeJS.Platform;
|
|
83
|
+
/** Test seam: `process.arch` — drives the Linux cloudflared download URL. */
|
|
84
|
+
arch?: NodeJS.Architecture;
|
|
85
|
+
/**
|
|
86
|
+
* Test seam: `process.getuid` — root (uid 0) can write
|
|
87
|
+
* /usr/local/bin/cloudflared directly; non-root needs `sudo`. Defaults to
|
|
88
|
+
* `process.getuid` (undefined on platforms without it → treated non-root).
|
|
89
|
+
*/
|
|
90
|
+
getuid?: () => number;
|
|
91
|
+
/**
|
|
92
|
+
* Path to cloudflared-state.json (hub#567 pending-hostname persistence).
|
|
93
|
+
* Defaults to the canonical `CLOUDFLARED_STATE_PATH`.
|
|
94
|
+
*/
|
|
95
|
+
statePath?: string;
|
|
76
96
|
lastProviderPath?: string;
|
|
77
97
|
now?: () => Date;
|
|
78
98
|
log?: (line: string) => void;
|
|
@@ -110,6 +130,9 @@ interface Resolved {
|
|
|
110
130
|
prompt: (question: string) => Promise<string>;
|
|
111
131
|
cloudflaredHome: string;
|
|
112
132
|
platform: NodeJS.Platform;
|
|
133
|
+
arch: NodeJS.Architecture;
|
|
134
|
+
getuid: () => number;
|
|
135
|
+
statePath: string;
|
|
113
136
|
lastProviderPath: string;
|
|
114
137
|
now: () => Date;
|
|
115
138
|
log: (line: string) => void;
|
|
@@ -129,6 +152,10 @@ function resolve(opts: ExposeInteractiveOpts): Resolved {
|
|
|
129
152
|
prompt: opts.prompt ?? defaultPrompt,
|
|
130
153
|
cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
|
|
131
154
|
platform: opts.platform ?? process.platform,
|
|
155
|
+
arch: opts.arch ?? process.arch,
|
|
156
|
+
// `process.getuid` is absent on Windows; treat missing as non-root (uid 1).
|
|
157
|
+
getuid: opts.getuid ?? (() => process.getuid?.() ?? 1),
|
|
158
|
+
statePath: opts.statePath ?? CLOUDFLARED_STATE_PATH,
|
|
132
159
|
lastProviderPath: opts.lastProviderPath ?? EXPOSE_LAST_PROVIDER_PATH,
|
|
133
160
|
now: opts.now ?? (() => new Date()),
|
|
134
161
|
log: opts.log ?? ((line) => console.log(line)),
|
|
@@ -185,10 +212,25 @@ async function promptHostname(r: Resolved): Promise<string | undefined> {
|
|
|
185
212
|
r.log("");
|
|
186
213
|
r.log("Cloudflare needs a hostname under a domain you've added to your Cloudflare account.");
|
|
187
214
|
r.log('Example: vault.example.com (apex "example.com" must be a Cloudflare zone)');
|
|
215
|
+
// hub#567: pre-fill with a hostname the operator typed on a prior (failed)
|
|
216
|
+
// run so a retry is "press Enter", not "redo the whole interview".
|
|
217
|
+
const pending = readPendingHostname(r.statePath);
|
|
218
|
+
const promptText = pending
|
|
219
|
+
? `Hostname [${pending}] (or blank to quit): `
|
|
220
|
+
: "Hostname (or blank to quit): ";
|
|
188
221
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
189
|
-
const raw = (await r.prompt(
|
|
222
|
+
const raw = (await r.prompt(promptText)).trim();
|
|
223
|
+
// Enter on a pre-filled prompt accepts the stashed hostname.
|
|
224
|
+
if (raw === "" && pending) {
|
|
225
|
+
return pending;
|
|
226
|
+
}
|
|
190
227
|
if (raw === "") return undefined;
|
|
191
|
-
if (isValidHostname(raw))
|
|
228
|
+
if (isValidHostname(raw)) {
|
|
229
|
+
// Stash it the moment it validates so a downstream failure (cloudflared
|
|
230
|
+
// login, tunnel/DNS error) doesn't discard it. Cleared on success.
|
|
231
|
+
writePendingHostname(raw, r.statePath);
|
|
232
|
+
return raw;
|
|
233
|
+
}
|
|
192
234
|
r.log(`"${raw}" doesn't look like a hostname. Expected something like vault.example.com.`);
|
|
193
235
|
}
|
|
194
236
|
r.log("Too many invalid entries; aborting.");
|
|
@@ -238,11 +280,99 @@ function printTailscaleSetupGuidance(r: Resolved, readiness: ProviderAvailabilit
|
|
|
238
280
|
r.log("Once those are done, re-run: parachute expose public");
|
|
239
281
|
}
|
|
240
282
|
|
|
283
|
+
/**
|
|
284
|
+
* hub#566: offer to install cloudflared on Linux in place (instead of printing
|
|
285
|
+
* the command and bailing). The install is a single static-binary download
|
|
286
|
+
* (curl + chmod) we already know how to do.
|
|
287
|
+
*
|
|
288
|
+
* - Confirm with `Install cloudflared now? [Y/n]` (Enter accepts).
|
|
289
|
+
* - Run the curl into /usr/local/bin/cloudflared + chmod +x. Root writes
|
|
290
|
+
* directly; non-root wraps each step in `sudo -n` (non-interactive — only
|
|
291
|
+
* succeeds when sudo creds are already cached / passwordless). We use `-n`
|
|
292
|
+
* deliberately: init often runs detached/unattended on a fresh server (SSH
|
|
293
|
+
* + tmux, a cloud-init script), where a blocking interactive sudo password
|
|
294
|
+
* prompt would hang the whole flow. `-n` fails fast instead, and we fall
|
|
295
|
+
* back to printing the manual command.
|
|
296
|
+
* - Verify with `cloudflared --version`.
|
|
297
|
+
*
|
|
298
|
+
* Returns true only when cloudflared is on PATH afterward. On decline, missing
|
|
299
|
+
* download URL (unknown arch), or any install/verify failure, prints the
|
|
300
|
+
* canonical manual instructions + the `--cloudflare` re-run hint and returns
|
|
301
|
+
* false. Per hub#565 the caller's `false` does NOT abort init.
|
|
302
|
+
*/
|
|
303
|
+
async function offerLinuxCloudflaredInstall(r: Resolved): Promise<boolean> {
|
|
304
|
+
r.log("");
|
|
305
|
+
r.log("Cloudflare Tunnel uses the `cloudflared` binary, which isn't installed yet.");
|
|
306
|
+
const downloadUrl = cloudflaredLinuxDownloadUrl(r.arch);
|
|
307
|
+
|
|
308
|
+
const printManualAndBail = () => {
|
|
309
|
+
r.log("");
|
|
310
|
+
for (const line of cloudflaredInstallHint("linux", r.arch).split("\n")) r.log(line);
|
|
311
|
+
r.log("");
|
|
312
|
+
r.log("After install, re-run: parachute expose public --cloudflare");
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// No published artifact for this arch → can't auto-install; print + bail.
|
|
316
|
+
if (!downloadUrl) {
|
|
317
|
+
printManualAndBail();
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const answer = (await r.prompt("Install cloudflared now? [Y/n] ")).trim().toLowerCase();
|
|
322
|
+
if (answer === "n" || answer === "no") {
|
|
323
|
+
r.log("Skipped auto-install.");
|
|
324
|
+
printManualAndBail();
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const isRoot = r.getuid() === 0;
|
|
329
|
+
const dest = "/usr/local/bin/cloudflared";
|
|
330
|
+
// Root writes directly; non-root prefixes each privileged step with `sudo -n`
|
|
331
|
+
// (non-interactive). `-n` never prompts for a password: it exits non-zero
|
|
332
|
+
// when creds aren't cached, so a detached/unattended init (SSH + tmux, a
|
|
333
|
+
// cloud-init script) fails fast and falls back to the printed instructions
|
|
334
|
+
// rather than hanging on a password prompt nobody's there to answer.
|
|
335
|
+
const sudo = isRoot ? [] : ["sudo", "-n"];
|
|
336
|
+
const curlCmd = [...sudo, "curl", "-L", "-o", dest, downloadUrl];
|
|
337
|
+
const chmodCmd = [...sudo, "chmod", "+x", dest];
|
|
338
|
+
|
|
339
|
+
r.log("");
|
|
340
|
+
r.log(`Downloading cloudflared → ${dest} …`);
|
|
341
|
+
const curlCode = await r.interactiveRunner(curlCmd);
|
|
342
|
+
if (curlCode !== 0) {
|
|
343
|
+
r.log(`Download failed (exit ${curlCode}).`);
|
|
344
|
+
if (!isRoot) {
|
|
345
|
+
r.log(
|
|
346
|
+
"(`sudo -n` needs cached credentials; run `sudo -v` first, or use the commands below.)",
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
printManualAndBail();
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
const chmodCode = await r.interactiveRunner(chmodCmd);
|
|
353
|
+
if (chmodCode !== 0) {
|
|
354
|
+
r.log(`chmod failed (exit ${chmodCode}).`);
|
|
355
|
+
printManualAndBail();
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!(await isCloudflaredInstalled(r.runner))) {
|
|
360
|
+
r.log("Install ran but `cloudflared` still isn't on PATH.");
|
|
361
|
+
r.log(
|
|
362
|
+
"Open a fresh shell (so PATH picks up the new binary), then re-run: parachute expose public --cloudflare",
|
|
363
|
+
);
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
r.log("✓ cloudflared installed.");
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
|
|
241
370
|
/**
|
|
242
371
|
* Walks the user through installing and logging in cloudflared. On macOS we
|
|
243
|
-
* auto-install via brew (with confirmation); on Linux we
|
|
244
|
-
*
|
|
245
|
-
*
|
|
372
|
+
* auto-install via brew (with confirmation); on Linux we auto-install the
|
|
373
|
+
* static binary (hub#566) with confirmation; everywhere else we print
|
|
374
|
+
* manual-install pointers and bail. Returns true only when cloudflared is
|
|
375
|
+
* both present and logged in afterwards.
|
|
246
376
|
*/
|
|
247
377
|
async function guideCloudflareSetup(
|
|
248
378
|
r: Resolved,
|
|
@@ -259,7 +389,11 @@ async function guideCloudflareSetup(
|
|
|
259
389
|
.trim()
|
|
260
390
|
.toLowerCase();
|
|
261
391
|
if (answer === "n" || answer === "no") {
|
|
262
|
-
|
|
392
|
+
// hub#566: re-run with `--cloudflare` (bare `expose public` defaults
|
|
393
|
+
// to Tailscale Funnel, the wrong provider for someone who chose CF).
|
|
394
|
+
r.log(
|
|
395
|
+
"Skipped auto-install. Install manually, then re-run: parachute expose public --cloudflare",
|
|
396
|
+
);
|
|
263
397
|
return false;
|
|
264
398
|
}
|
|
265
399
|
const code = await r.interactiveRunner(["brew", "install", "cloudflared"]);
|
|
@@ -273,20 +407,26 @@ async function guideCloudflareSetup(
|
|
|
273
407
|
r.log("Open a fresh shell (so PATH picks up the new binary) and re-run.");
|
|
274
408
|
return false;
|
|
275
409
|
}
|
|
410
|
+
} else if (r.platform === "linux") {
|
|
411
|
+
// hub#566: on Linux the install is a single static-binary download we
|
|
412
|
+
// already know how to do — offer to run it in place instead of dumping
|
|
413
|
+
// the operator back to a shell. Auto-install requires root (write to
|
|
414
|
+
// /usr/local/bin) or a working passwordless `sudo -n`. If we can't, or
|
|
415
|
+
// the operator declines, fall back to printing the instructions (and
|
|
416
|
+
// per hub#565 init continues regardless of the `false` return here).
|
|
417
|
+
installed = await offerLinuxCloudflaredInstall(r);
|
|
418
|
+
if (!installed) return false;
|
|
276
419
|
} else {
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// cloudflared` on Amazon Linux 2023 — and the
|
|
280
|
-
// pkg.cloudflare.com / developers.cloudflare.com paths the old hint
|
|
281
|
-
// pointed at now serve HTML/404. Defer to `cloudflaredInstallHint`,
|
|
282
|
-
// which writes the canonical GitHub-release static-binary path
|
|
283
|
-
// matching the host's architecture.
|
|
420
|
+
// Non-darwin/linux (e.g. Windows / misc): no auto-install path. Print
|
|
421
|
+
// the canonical pointer and bail (init continues per hub#565).
|
|
284
422
|
r.log("");
|
|
285
423
|
r.log("Cloudflare Tunnel uses the `cloudflared` binary, which isn't installed yet.");
|
|
286
424
|
r.log("");
|
|
287
|
-
for (const line of cloudflaredInstallHint(r.platform).split("\n")) r.log(line);
|
|
425
|
+
for (const line of cloudflaredInstallHint(r.platform, r.arch).split("\n")) r.log(line);
|
|
288
426
|
r.log("");
|
|
289
|
-
|
|
427
|
+
// hub#566: the bare `parachute expose public` defaults to Tailscale
|
|
428
|
+
// Funnel — an operator who chose Cloudflare must re-run with the flag.
|
|
429
|
+
r.log("After install, re-run: parachute expose public --cloudflare");
|
|
290
430
|
return false;
|
|
291
431
|
}
|
|
292
432
|
}
|
|
@@ -310,7 +450,7 @@ async function guideCloudflareSetup(
|
|
|
310
450
|
loggedIn = isCloudflaredLoggedIn(r.cloudflaredHome);
|
|
311
451
|
if (!loggedIn) {
|
|
312
452
|
r.log("Login ran but cert.pem didn't appear in ~/.cloudflared.");
|
|
313
|
-
r.log("Check the browser flow completed, then re-run: parachute expose public");
|
|
453
|
+
r.log("Check the browser flow completed, then re-run: parachute expose public --cloudflare");
|
|
314
454
|
return false;
|
|
315
455
|
}
|
|
316
456
|
}
|
|
@@ -391,7 +531,13 @@ export async function exposePublicInteractive(opts: ExposeInteractiveOpts = {}):
|
|
|
391
531
|
}
|
|
392
532
|
writeLastProvider("cloudflare", { path: r.lastProviderPath, now: r.now });
|
|
393
533
|
const code = await r.exposeCloudflareUpImpl(hostname, r.cloudflareOpts);
|
|
394
|
-
if (code === 0)
|
|
534
|
+
if (code === 0) {
|
|
535
|
+
// hub#567: routing succeeded — the tunnel record now carries the live
|
|
536
|
+
// hostname, so drop the pending one (a retry shouldn't pre-fill a
|
|
537
|
+
// hostname that's already exposed).
|
|
538
|
+
clearPendingHostname(r.statePath);
|
|
539
|
+
await runPreflightSafely(r);
|
|
540
|
+
}
|
|
395
541
|
return code;
|
|
396
542
|
}
|
|
397
543
|
|
package/src/commands/init.ts
CHANGED
|
@@ -619,7 +619,12 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
619
619
|
exposeTailnetImpl,
|
|
620
620
|
exposeCloudflareImpl,
|
|
621
621
|
});
|
|
622
|
-
|
|
622
|
+
// hub#565: exposure is an ENHANCEMENT, not a prerequisite. A failed
|
|
623
|
+
// expose chain must NOT abort init — warn + print the exact retry
|
|
624
|
+
// command, then fall through to vault install + the admin-URL/wizard
|
|
625
|
+
// handoff on the loopback URL. Init's contract is hub up → vault module
|
|
626
|
+
// installed → admin URL → wizard, ALWAYS.
|
|
627
|
+
if (code !== 0) warnExposeFailedContinue(opts.exposeChoice, log);
|
|
623
628
|
// Refresh state — the chain may have brought up an FQDN.
|
|
624
629
|
exposeState = readExposeStateFn();
|
|
625
630
|
} else if (opts.noExposePrompt) {
|
|
@@ -642,7 +647,9 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
642
647
|
exposeTailnetImpl,
|
|
643
648
|
exposeCloudflareImpl,
|
|
644
649
|
});
|
|
645
|
-
|
|
650
|
+
// hub#565: warn + continue on a failed expose chain rather than
|
|
651
|
+
// aborting init (same contract as the non-interactive branch above).
|
|
652
|
+
if (code !== 0) warnExposeFailedContinue(picked, log);
|
|
646
653
|
exposeState = readExposeStateFn();
|
|
647
654
|
}
|
|
648
655
|
}
|
|
@@ -713,6 +720,13 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
713
720
|
log("");
|
|
714
721
|
log(` ${adminUrl}`);
|
|
715
722
|
log("");
|
|
723
|
+
// hub#565: when we're on the loopback URL (no public exposure active),
|
|
724
|
+
// remind the operator they can expose later. Skipped once an FQDN is up.
|
|
725
|
+
if (!exposeState?.canonicalFqdn) {
|
|
726
|
+
log("(Reachable on this machine. To expose it publicly later, run");
|
|
727
|
+
log(" `parachute expose public --cloudflare` or `parachute expose public --tailnet`.)");
|
|
728
|
+
log("");
|
|
729
|
+
}
|
|
716
730
|
|
|
717
731
|
// Step 4.5: offer the operator the CLI wizard vs. the browser wizard
|
|
718
732
|
// (hub#168 Cut 4). Aaron's 2026-05-28 directive: "we should be able to
|
|
@@ -784,6 +798,32 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
784
798
|
return 0;
|
|
785
799
|
}
|
|
786
800
|
|
|
801
|
+
/** The exact retry command for a given exposure choice (hub#565 / #566). */
|
|
802
|
+
export function exposeRetryCommand(choice: ExposeChoice): string {
|
|
803
|
+
if (choice === "tailnet") return "parachute expose public --tailnet";
|
|
804
|
+
// `none` never reaches here in practice — `runExposureChoice("none")` always
|
|
805
|
+
// returns 0, so `warnExposeFailedContinue` (the only caller) is never invoked
|
|
806
|
+
// for it. It falls through to the `--cloudflare` branch below; harmless, and
|
|
807
|
+
// spelled out so the fallthrough isn't read as a bug.
|
|
808
|
+
// Cloudflare (and the unreachable `none`): default the bare command to
|
|
809
|
+
// `--cloudflare` so the operator who picked Cloudflare lands in the right
|
|
810
|
+
// provider on retry (bare `parachute expose public` defaults to Tailscale
|
|
811
|
+
// Funnel — hub#566).
|
|
812
|
+
return "parachute expose public --cloudflare";
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* hub#565: warn that the exposure chain failed but init is continuing anyway,
|
|
817
|
+
* and print the exact retry command. Exposure is an enhancement, not a
|
|
818
|
+
* prerequisite — init still installs the vault module and hands off to the
|
|
819
|
+
* wizard on the loopback URL.
|
|
820
|
+
*/
|
|
821
|
+
function warnExposeFailedContinue(choice: ExposeChoice, log: (line: string) => void): void {
|
|
822
|
+
log("");
|
|
823
|
+
log("⚠ Couldn't finish setting up public access — continuing without it.");
|
|
824
|
+
log(` To expose publicly later, run: ${exposeRetryCommand(choice)}`);
|
|
825
|
+
}
|
|
826
|
+
|
|
787
827
|
/**
|
|
788
828
|
* Dispatch the chosen exposure path. Returns the exit code of the
|
|
789
829
|
* downstream chain. `none` is a no-op (success).
|
package/src/commands/install.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from "../service-spec.ts";
|
|
28
28
|
import { findService, readManifest, upsertService } from "../services-manifest.ts";
|
|
29
29
|
import { WELL_KNOWN_PATH } from "../well-known.ts";
|
|
30
|
-
import { start as lifecycleStart } from "./lifecycle.ts";
|
|
30
|
+
import { type LifecycleOpts, start as lifecycleStart } from "./lifecycle.ts";
|
|
31
31
|
import { migrateNotice } from "./migrate.ts";
|
|
32
32
|
import {
|
|
33
33
|
type InteractiveAvailability,
|
|
@@ -553,6 +553,39 @@ function resolveInstallTarget(
|
|
|
553
553
|
return { kind: "npm", packageName: input };
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Build the LifecycleOpts the install auto-start uses (hub#573).
|
|
558
|
+
*
|
|
559
|
+
* The auto-start MUST thread the SAME supervisor + migrate-offer opts the
|
|
560
|
+
* production CLI dispatch passes for `parachute start <svc>` (cli.ts:
|
|
561
|
+
* `supervisor: {}` + `migrateOffer: { enabled: true }`). Without them, `start`
|
|
562
|
+
* resolved `unitInstalled` to its omitted-supervisor default of `false` and
|
|
563
|
+
* `migrateOffer.enabled` to `false` — so the auto-start ALWAYS took the no-unit
|
|
564
|
+
* path, printed "No supervised hub unit is installed. Run `parachute migrate
|
|
565
|
+
* --to-supervised`…", and returned non-zero → the "⚠ didn't start cleanly"
|
|
566
|
+
* warning. Meanwhile `parachute migrate` (which DOES run the real
|
|
567
|
+
* `isHubUnitInstalled` probe + /health) reported the unit already installed +
|
|
568
|
+
* healthy: the two paths disagreed because only `migrate` opted into real
|
|
569
|
+
* detection. `supervisor: {}` makes the auto-start run the same probe;
|
|
570
|
+
* `migrateOffer: { enabled: true }` makes it offer the cutover on a genuinely-
|
|
571
|
+
* unmigrated box instead of dumping a bare error mid-install.
|
|
572
|
+
*
|
|
573
|
+
* Exported so the convergence is unit-testable without driving a real start.
|
|
574
|
+
*/
|
|
575
|
+
export function defaultStartLifecycleOpts(ctx: {
|
|
576
|
+
manifestPath: string;
|
|
577
|
+
configDir: string;
|
|
578
|
+
log: (line: string) => void;
|
|
579
|
+
}): LifecycleOpts {
|
|
580
|
+
return {
|
|
581
|
+
manifestPath: ctx.manifestPath,
|
|
582
|
+
configDir: ctx.configDir,
|
|
583
|
+
log: ctx.log,
|
|
584
|
+
supervisor: {},
|
|
585
|
+
migrateOffer: { enabled: true },
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
556
589
|
export async function install(input: string, opts: InstallOpts = {}): Promise<number> {
|
|
557
590
|
const runner = opts.runner ?? defaultRunner;
|
|
558
591
|
const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
@@ -883,7 +916,8 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
|
|
|
883
916
|
if (!opts.noStart && !opts.noCreate) {
|
|
884
917
|
const startService =
|
|
885
918
|
opts.startService ??
|
|
886
|
-
((short: string) =>
|
|
919
|
+
((short: string) =>
|
|
920
|
+
lifecycleStart(short, defaultStartLifecycleOpts({ manifestPath, configDir, log })));
|
|
887
921
|
const startCode = await startService(short);
|
|
888
922
|
if (startCode !== 0) {
|
|
889
923
|
log(`⚠ ${short} didn't start cleanly. Run manually: parachute start ${short}`);
|