@openparachute/hub 0.6.3 → 0.6.4-rc.10
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__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
package/src/cli.ts
CHANGED
|
@@ -9,19 +9,16 @@
|
|
|
9
9
|
import { MissingDependencyError } from "@openparachute/depcheck";
|
|
10
10
|
import pkg from "../package.json" with { type: "json" };
|
|
11
11
|
import { CloudflaredStateError } from "./cloudflare/state.ts";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { setup } from "./commands/setup.ts";
|
|
21
|
-
import {
|
|
22
|
-
import { upgrade } from "./commands/upgrade.ts";
|
|
23
|
-
import { dispatchVault } from "./commands/vault.ts";
|
|
24
|
-
import { runSetupWizardCommand } from "./commands/wizard.ts";
|
|
12
|
+
// Command-implementation modules are loaded LAZILY inside their switch arms (see
|
|
13
|
+
// `loadCommand` + each `case`), so a module that throws at eval-time is isolated
|
|
14
|
+
// to its own command instead of aborting the whole CLI at top-level import. The
|
|
15
|
+
// `import type`s below are erased at compile time (they trigger no module
|
|
16
|
+
// evaluation) and exist only so the arms can reference each command's options
|
|
17
|
+
// type for `Parameters<typeof …>`.
|
|
18
|
+
import type { init } from "./commands/init.ts";
|
|
19
|
+
import type { install } from "./commands/install.ts";
|
|
20
|
+
import type { setup } from "./commands/setup.ts";
|
|
21
|
+
import type { upgrade } from "./commands/upgrade.ts";
|
|
25
22
|
import { ExposeStateError } from "./expose-state.ts";
|
|
26
23
|
import {
|
|
27
24
|
exposeHelp,
|
|
@@ -273,6 +270,34 @@ function extractExposeProviderFlags(args: string[]): {
|
|
|
273
270
|
return out;
|
|
274
271
|
}
|
|
275
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Lazy-load a command-implementation module, isolating an eval-time throw to the
|
|
275
|
+
* command that asked for it.
|
|
276
|
+
*
|
|
277
|
+
* `cli.ts` used to eagerly `import` every command module at top-level. That made
|
|
278
|
+
* a single broken module (e.g. a half-built `migrate-cutover.ts` with a
|
|
279
|
+
* ReferenceError at eval) abort the *entire* CLI load — even `parachute --help`
|
|
280
|
+
* — because top-level import evaluation runs before `run()`'s try/catch is ever
|
|
281
|
+
* reached. Loading each module lazily inside its switch arm (the same pattern
|
|
282
|
+
* the expose subcommands already use, e.g. `await import("./commands/expose-
|
|
283
|
+
* cloudflare.ts")`) means an import rejection touches only its own command.
|
|
284
|
+
*
|
|
285
|
+
* On rejection we print `parachute <cmd>: failed to load (<err>)` and return
|
|
286
|
+
* `undefined`; the arm turns that into exit code 1. This keeps a broken module
|
|
287
|
+
* from surfacing as an unhandled promise rejection (which the top-level
|
|
288
|
+
* `run()` boundary doesn't shape — it wraps execution, not import).
|
|
289
|
+
*/
|
|
290
|
+
async function loadCommand<T>(cmd: string, importer: () => Promise<T>): Promise<T | undefined> {
|
|
291
|
+
try {
|
|
292
|
+
return await importer();
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error(
|
|
295
|
+
`parachute ${cmd}: failed to load (${err instanceof Error ? err.message : String(err)})`,
|
|
296
|
+
);
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
276
301
|
async function main(argv: string[]): Promise<number> {
|
|
277
302
|
const [command, ...rest] = argv;
|
|
278
303
|
|
|
@@ -309,7 +334,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
309
334
|
const setupOpts: Parameters<typeof setup>[0] = {};
|
|
310
335
|
if (tagExtract.tag) setupOpts.tag = tagExtract.tag;
|
|
311
336
|
if (noStart) setupOpts.noStart = true;
|
|
312
|
-
|
|
337
|
+
const mod = await loadCommand("setup", () => import("./commands/setup.ts"));
|
|
338
|
+
if (!mod) return 1;
|
|
339
|
+
return await mod.setup(setupOpts);
|
|
313
340
|
}
|
|
314
341
|
|
|
315
342
|
case "setup-wizard": {
|
|
@@ -323,7 +350,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
323
350
|
console.log(setupWizardHelp());
|
|
324
351
|
return 0;
|
|
325
352
|
}
|
|
326
|
-
|
|
353
|
+
const mod = await loadCommand("setup-wizard", () => import("./commands/wizard.ts"));
|
|
354
|
+
if (!mod) return 1;
|
|
355
|
+
return await mod.runSetupWizardCommand(rest);
|
|
327
356
|
}
|
|
328
357
|
|
|
329
358
|
case "init": {
|
|
@@ -379,7 +408,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
379
408
|
}
|
|
380
409
|
if (cliWizard) initOpts.wizardChoice = "cli";
|
|
381
410
|
else if (browserWizard) initOpts.wizardChoice = "browser";
|
|
382
|
-
|
|
411
|
+
const mod = await loadCommand("init", () => import("./commands/init.ts"));
|
|
412
|
+
if (!mod) return 1;
|
|
413
|
+
return await mod.init(initOpts);
|
|
383
414
|
}
|
|
384
415
|
|
|
385
416
|
case "install": {
|
|
@@ -418,11 +449,14 @@ async function main(argv: string[]): Promise<number> {
|
|
|
418
449
|
return 1;
|
|
419
450
|
}
|
|
420
451
|
const noStart = keyExtract.rest.includes("--no-start");
|
|
421
|
-
const
|
|
452
|
+
const interactive = keyExtract.rest.includes("--interactive");
|
|
453
|
+
const installArgs = keyExtract.rest.filter(
|
|
454
|
+
(a) => a !== "--no-start" && a !== "--interactive",
|
|
455
|
+
);
|
|
422
456
|
const service = installArgs[0];
|
|
423
457
|
if (!service) {
|
|
424
458
|
console.error(
|
|
425
|
-
"usage: parachute install <service|all> [--channel rc|latest] [--tag <name>] [--no-start]",
|
|
459
|
+
"usage: parachute install <service|all> [--channel rc|latest] [--tag <name>] [--no-start] [--interactive]",
|
|
426
460
|
);
|
|
427
461
|
console.error(
|
|
428
462
|
" parachute install scribe [--scribe-provider <name>] [--scribe-key <key>]",
|
|
@@ -436,18 +470,21 @@ async function main(argv: string[]): Promise<number> {
|
|
|
436
470
|
installOpts.channel = channelExtract.value;
|
|
437
471
|
}
|
|
438
472
|
if (noStart) installOpts.noStart = true;
|
|
473
|
+
if (interactive) installOpts.interactive = true;
|
|
439
474
|
if (providerExtract.value) installOpts.scribeProvider = providerExtract.value;
|
|
440
475
|
if (keyExtract.value) installOpts.scribeKey = keyExtract.value;
|
|
476
|
+
const mod = await loadCommand("install", () => import("./commands/install.ts"));
|
|
477
|
+
if (!mod) return 1;
|
|
441
478
|
if (service === "all") {
|
|
442
479
|
// Bootstrap the whole ecosystem to one dist-tag — the RC-testing payload.
|
|
443
480
|
// Bail on first failure so a broken channel doesn't mask a working tag.
|
|
444
481
|
for (const svc of knownServices()) {
|
|
445
|
-
const code = await install(svc, installOpts);
|
|
482
|
+
const code = await mod.install(svc, installOpts);
|
|
446
483
|
if (code !== 0) return code;
|
|
447
484
|
}
|
|
448
485
|
return 0;
|
|
449
486
|
}
|
|
450
|
-
return await install(service, installOpts);
|
|
487
|
+
return await mod.install(service, installOpts);
|
|
451
488
|
}
|
|
452
489
|
|
|
453
490
|
case "status":
|
|
@@ -459,7 +496,11 @@ async function main(argv: string[]): Promise<number> {
|
|
|
459
496
|
// dual-dispatch: on a box with a hub unit installed it reads the platform
|
|
460
497
|
// manager + the running supervisor; on a legacy detached box it falls back
|
|
461
498
|
// to the pidfile readout (design §6.4). Tests drive the seams directly.
|
|
462
|
-
|
|
499
|
+
{
|
|
500
|
+
const mod = await loadCommand("status", () => import("./commands/status.ts"));
|
|
501
|
+
if (!mod) return 1;
|
|
502
|
+
return await mod.status({ supervisor: {} });
|
|
503
|
+
}
|
|
463
504
|
|
|
464
505
|
case "expose": {
|
|
465
506
|
const hubExtract = extractHubOrigin(rest);
|
|
@@ -585,6 +626,14 @@ async function main(argv: string[]): Promise<number> {
|
|
|
585
626
|
...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
|
|
586
627
|
};
|
|
587
628
|
|
|
629
|
+
// Lazy-load the Tailscale-Funnel entry points the same way the Cloudflare /
|
|
630
|
+
// interactive / auto-pick paths above load theirs. Reaching here means we're
|
|
631
|
+
// past the early Cloudflare returns, so `exposePublic` / `exposeTailnet` are
|
|
632
|
+
// about to be needed by one of the branches below.
|
|
633
|
+
const exposeMod = await loadCommand("expose", () => import("./commands/expose.ts"));
|
|
634
|
+
if (!exposeMod) return 1;
|
|
635
|
+
const { exposePublic, exposeTailnet } = exposeMod;
|
|
636
|
+
|
|
588
637
|
// `--tailnet` is the explicit Tailscale Funnel pin — bypass both the
|
|
589
638
|
// interactive picker and the non-TTY auto-pick. Goes straight to
|
|
590
639
|
// exposePublic so today's Funnel flow keeps working unchanged.
|
|
@@ -677,7 +726,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
677
726
|
migrateOffer: { enabled: true },
|
|
678
727
|
...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
|
|
679
728
|
};
|
|
680
|
-
|
|
729
|
+
const mod = await loadCommand("start", () => import("./commands/lifecycle.ts"));
|
|
730
|
+
if (!mod) return 1;
|
|
731
|
+
return await mod.start(hubExtract.rest[0], startOpts);
|
|
681
732
|
}
|
|
682
733
|
|
|
683
734
|
case "stop": {
|
|
@@ -685,7 +736,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
685
736
|
console.log(stopHelp());
|
|
686
737
|
return 0;
|
|
687
738
|
}
|
|
688
|
-
|
|
739
|
+
const mod = await loadCommand("stop", () => import("./commands/lifecycle.ts"));
|
|
740
|
+
if (!mod) return 1;
|
|
741
|
+
return await mod.stop(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
|
|
689
742
|
}
|
|
690
743
|
|
|
691
744
|
case "restart": {
|
|
@@ -693,7 +746,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
693
746
|
console.log(restartHelp());
|
|
694
747
|
return 0;
|
|
695
748
|
}
|
|
696
|
-
|
|
749
|
+
const mod = await loadCommand("restart", () => import("./commands/lifecycle.ts"));
|
|
750
|
+
if (!mod) return 1;
|
|
751
|
+
return await mod.restart(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
|
|
697
752
|
}
|
|
698
753
|
|
|
699
754
|
case "upgrade": {
|
|
@@ -744,7 +799,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
744
799
|
upgradeOpts.channel = channelExtract.value;
|
|
745
800
|
}
|
|
746
801
|
if (allowDowngrade) upgradeOpts.allowDowngrade = true;
|
|
747
|
-
|
|
802
|
+
const mod = await loadCommand("upgrade", () => import("./commands/upgrade.ts"));
|
|
803
|
+
if (!mod) return 1;
|
|
804
|
+
return await mod.upgrade(remaining[0], upgradeOpts);
|
|
748
805
|
}
|
|
749
806
|
|
|
750
807
|
case "logs": {
|
|
@@ -759,7 +816,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
759
816
|
return 1;
|
|
760
817
|
}
|
|
761
818
|
const follow = rest.includes("-f") || rest.includes("--follow");
|
|
762
|
-
|
|
819
|
+
const mod = await loadCommand("logs", () => import("./commands/lifecycle.ts"));
|
|
820
|
+
if (!mod) return 1;
|
|
821
|
+
return await mod.logs(svc, { follow });
|
|
763
822
|
}
|
|
764
823
|
|
|
765
824
|
case "migrate": {
|
|
@@ -775,7 +834,31 @@ async function main(argv: string[]): Promise<number> {
|
|
|
775
834
|
console.error("usage: parachute migrate --teardown");
|
|
776
835
|
return 1;
|
|
777
836
|
}
|
|
778
|
-
|
|
837
|
+
const mod = await loadCommand("migrate", () => import("./commands/migrate-cutover.ts"));
|
|
838
|
+
if (!mod) return 1;
|
|
839
|
+
// teardownHubUnit logs the human-facing lines itself (the success
|
|
840
|
+
// guidance, or "nothing to tear down"). hub#534: the CLI must still
|
|
841
|
+
// own the EXIT CODE + surface any failure detail the function's
|
|
842
|
+
// false-branch doesn't print — pre-fix it ignored `removed` + `messages`
|
|
843
|
+
// and always exited 0, so a non-removal looked like success to a script.
|
|
844
|
+
const result = mod.teardownHubUnit();
|
|
845
|
+
if (result.removed) return 0;
|
|
846
|
+
// removed === false: either a clean no-op (nothing was installed —
|
|
847
|
+
// `messages` empty) or a real failure (the removal carried a reason in
|
|
848
|
+
// `messages` the internal log didn't surface). The no-op is informational
|
|
849
|
+
// (exit 0); a failure with detail is an error (print it, exit 1).
|
|
850
|
+
//
|
|
851
|
+
// DELIBERATE double-print on the failure path (not a bug): the function's
|
|
852
|
+
// own log() already wrote a human-readable summary ("Hub-unit teardown did
|
|
853
|
+
// not complete: …") to STDOUT; here we re-emit the raw reason(s) to STDERR.
|
|
854
|
+
// The split is intentional — a person reading the terminal sees the framed
|
|
855
|
+
// summary, while a script that captures `2>` gets the machine-parseable
|
|
856
|
+
// reason alongside the non-zero exit. Mirrors the streams convention the
|
|
857
|
+
// rest of the CLI uses (human guidance on stdout, error detail on stderr).
|
|
858
|
+
if (result.messages.length > 0) {
|
|
859
|
+
for (const line of result.messages) console.error(line);
|
|
860
|
+
return 1;
|
|
861
|
+
}
|
|
779
862
|
return 0;
|
|
780
863
|
}
|
|
781
864
|
// §7.1 detached→supervised cutover. Opt-in surface (the archive sweep
|
|
@@ -788,7 +871,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
788
871
|
console.error("usage: parachute migrate --to-supervised");
|
|
789
872
|
return 1;
|
|
790
873
|
}
|
|
791
|
-
const
|
|
874
|
+
const mod = await loadCommand("migrate", () => import("./commands/migrate-cutover.ts"));
|
|
875
|
+
if (!mod) return 1;
|
|
876
|
+
const result = await mod.cutoverToSupervised();
|
|
792
877
|
for (const line of result.messages) console.log(line);
|
|
793
878
|
// "already-migrated" / "migrated" are success; every other outcome is a
|
|
794
879
|
// recoverable failure that should exit non-zero so scripts can retry.
|
|
@@ -807,7 +892,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
807
892
|
);
|
|
808
893
|
return 1;
|
|
809
894
|
}
|
|
810
|
-
|
|
895
|
+
const mod = await loadCommand("migrate", () => import("./commands/migrate.ts"));
|
|
896
|
+
if (!mod) return 1;
|
|
897
|
+
return await mod.migrate({ dryRun, list, yes });
|
|
811
898
|
}
|
|
812
899
|
|
|
813
900
|
case "serve": {
|
|
@@ -824,7 +911,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
824
911
|
// event loop alive until SIGINT/SIGTERM, at which point we stop the
|
|
825
912
|
// server cleanly and exit. Container supervisor (tini, Render, Docker)
|
|
826
913
|
// reaps us once the event loop drains.
|
|
827
|
-
const
|
|
914
|
+
const mod = await loadCommand("serve", () => import("./commands/serve.ts"));
|
|
915
|
+
if (!mod) return 1;
|
|
916
|
+
const { stop: stopServer } = await mod.serve();
|
|
828
917
|
await new Promise<void>((resolve) => {
|
|
829
918
|
const handler = async () => {
|
|
830
919
|
await stopServer();
|
|
@@ -836,14 +925,19 @@ async function main(argv: string[]): Promise<number> {
|
|
|
836
925
|
return 0;
|
|
837
926
|
}
|
|
838
927
|
|
|
839
|
-
case "auth":
|
|
840
|
-
|
|
928
|
+
case "auth": {
|
|
929
|
+
const mod = await loadCommand("auth", () => import("./commands/auth.ts"));
|
|
930
|
+
if (!mod) return 1;
|
|
931
|
+
return await mod.auth(rest);
|
|
932
|
+
}
|
|
841
933
|
|
|
842
934
|
case "vault": {
|
|
935
|
+
const mod = await loadCommand("vault", () => import("./commands/vault.ts"));
|
|
936
|
+
if (!mod) return 1;
|
|
843
937
|
// `parachute vault` with no args forwards --help to parachute-vault so
|
|
844
938
|
// users see the actual vault surface, not a CLI-side stub. Anything
|
|
845
939
|
// after `vault` (including --help) is passed through verbatim.
|
|
846
|
-
if (rest.length === 0) return await dispatchVault(["--help"]);
|
|
940
|
+
if (rest.length === 0) return await mod.dispatchVault(["--help"]);
|
|
847
941
|
|
|
848
942
|
// Everything under `vault` forwards transparently to `parachute-vault`.
|
|
849
943
|
// `vault tokens create` used to route through a guided interactive
|
|
@@ -852,7 +946,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
852
946
|
// hub-issued JWTs; mint them with `parachute auth mint-token` or the
|
|
853
947
|
// admin SPA Connect card. We forward verbatim so the operator sees
|
|
854
948
|
// vault's own migration error rather than a hub-side stub.
|
|
855
|
-
return await dispatchVault(rest);
|
|
949
|
+
return await mod.dispatchVault(rest);
|
|
856
950
|
}
|
|
857
951
|
|
|
858
952
|
default:
|
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. */
|
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* is the second wall.
|
|
7
7
|
*
|
|
8
8
|
* 2FA at the hub login layer is real as of hub#473: "password +
|
|
9
|
-
* something-you-have." This
|
|
10
|
-
*
|
|
9
|
+
* something-you-have." This prints a STRONG RECOMMENDATION to enroll — via the
|
|
10
|
+
* browser at `/account/2fa` or `parachute auth 2fa enroll` (which now gates hub
|
|
11
|
+
* `/login` for real) — when the operator hasn't enrolled.
|
|
11
12
|
*
|
|
12
|
-
* Why this is a
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Why this is a recommendation, not a hard gate (Aaron's explicit call, 2026-06:
|
|
14
|
+
* "I don't think we need to require 2FA for public expose but we should strongly
|
|
15
|
+
* recommend it"): hard-gating would surprise operators mid-flow — they ran
|
|
16
|
+
* `parachute expose public` to expose, not to be told "set up 2FA first." A
|
|
17
|
+
* clear, friendly, contextual recommendation + an obvious remediation is the
|
|
15
18
|
* right shape; the operator decides whether to act now or later. The tunnel is
|
|
16
19
|
* up regardless.
|
|
17
20
|
*
|
|
@@ -68,16 +71,17 @@ export function printPublic2FAWarning(opts: Public2FAWarningOpts): boolean {
|
|
|
68
71
|
return false;
|
|
69
72
|
}
|
|
70
73
|
log("");
|
|
71
|
-
log("
|
|
72
|
-
log(
|
|
74
|
+
log("→ Strongly recommended: turn on two-factor authentication.");
|
|
75
|
+
log(" Your login page is now reachable from the public internet");
|
|
76
|
+
log(` (${opts.publicUrl}/login) — anyone online can reach it, so your`);
|
|
77
|
+
log(" password is the only wall. A second factor (a one-time code from");
|
|
78
|
+
log(" your authenticator app) materially raises the bar:");
|
|
73
79
|
log("");
|
|
74
|
-
log(
|
|
75
|
-
log("
|
|
80
|
+
log(` ${opts.publicUrl}/account/2fa # scan a QR code in your browser`);
|
|
81
|
+
log(" parachute auth 2fa enroll # or enroll from the terminal");
|
|
76
82
|
log("");
|
|
77
|
-
log("
|
|
78
|
-
log("");
|
|
79
|
-
log(" (Or set it up in the browser at /account/2fa for a scannable QR code.)");
|
|
80
|
-
log(" Either way, also make sure your owner password is a strong one:");
|
|
83
|
+
log(" It's a recommendation, not a requirement — your hub is up either way.");
|
|
84
|
+
log(" While you're at it, make sure your owner password is a strong one:");
|
|
81
85
|
log("");
|
|
82
86
|
log(" parachute auth set-password");
|
|
83
87
|
return true;
|