@openparachute/hub 0.6.3 → 0.6.4-rc.2
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 -1
- 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 +125 -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 +187 -1
- 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__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -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__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- 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 +27 -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 +54 -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 +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- 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/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -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 +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- 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": {
|
|
@@ -438,16 +469,18 @@ async function main(argv: string[]): Promise<number> {
|
|
|
438
469
|
if (noStart) installOpts.noStart = true;
|
|
439
470
|
if (providerExtract.value) installOpts.scribeProvider = providerExtract.value;
|
|
440
471
|
if (keyExtract.value) installOpts.scribeKey = keyExtract.value;
|
|
472
|
+
const mod = await loadCommand("install", () => import("./commands/install.ts"));
|
|
473
|
+
if (!mod) return 1;
|
|
441
474
|
if (service === "all") {
|
|
442
475
|
// Bootstrap the whole ecosystem to one dist-tag — the RC-testing payload.
|
|
443
476
|
// Bail on first failure so a broken channel doesn't mask a working tag.
|
|
444
477
|
for (const svc of knownServices()) {
|
|
445
|
-
const code = await install(svc, installOpts);
|
|
478
|
+
const code = await mod.install(svc, installOpts);
|
|
446
479
|
if (code !== 0) return code;
|
|
447
480
|
}
|
|
448
481
|
return 0;
|
|
449
482
|
}
|
|
450
|
-
return await install(service, installOpts);
|
|
483
|
+
return await mod.install(service, installOpts);
|
|
451
484
|
}
|
|
452
485
|
|
|
453
486
|
case "status":
|
|
@@ -459,7 +492,11 @@ async function main(argv: string[]): Promise<number> {
|
|
|
459
492
|
// dual-dispatch: on a box with a hub unit installed it reads the platform
|
|
460
493
|
// manager + the running supervisor; on a legacy detached box it falls back
|
|
461
494
|
// to the pidfile readout (design §6.4). Tests drive the seams directly.
|
|
462
|
-
|
|
495
|
+
{
|
|
496
|
+
const mod = await loadCommand("status", () => import("./commands/status.ts"));
|
|
497
|
+
if (!mod) return 1;
|
|
498
|
+
return await mod.status({ supervisor: {} });
|
|
499
|
+
}
|
|
463
500
|
|
|
464
501
|
case "expose": {
|
|
465
502
|
const hubExtract = extractHubOrigin(rest);
|
|
@@ -585,6 +622,14 @@ async function main(argv: string[]): Promise<number> {
|
|
|
585
622
|
...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
|
|
586
623
|
};
|
|
587
624
|
|
|
625
|
+
// Lazy-load the Tailscale-Funnel entry points the same way the Cloudflare /
|
|
626
|
+
// interactive / auto-pick paths above load theirs. Reaching here means we're
|
|
627
|
+
// past the early Cloudflare returns, so `exposePublic` / `exposeTailnet` are
|
|
628
|
+
// about to be needed by one of the branches below.
|
|
629
|
+
const exposeMod = await loadCommand("expose", () => import("./commands/expose.ts"));
|
|
630
|
+
if (!exposeMod) return 1;
|
|
631
|
+
const { exposePublic, exposeTailnet } = exposeMod;
|
|
632
|
+
|
|
588
633
|
// `--tailnet` is the explicit Tailscale Funnel pin — bypass both the
|
|
589
634
|
// interactive picker and the non-TTY auto-pick. Goes straight to
|
|
590
635
|
// exposePublic so today's Funnel flow keeps working unchanged.
|
|
@@ -677,7 +722,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
677
722
|
migrateOffer: { enabled: true },
|
|
678
723
|
...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
|
|
679
724
|
};
|
|
680
|
-
|
|
725
|
+
const mod = await loadCommand("start", () => import("./commands/lifecycle.ts"));
|
|
726
|
+
if (!mod) return 1;
|
|
727
|
+
return await mod.start(hubExtract.rest[0], startOpts);
|
|
681
728
|
}
|
|
682
729
|
|
|
683
730
|
case "stop": {
|
|
@@ -685,7 +732,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
685
732
|
console.log(stopHelp());
|
|
686
733
|
return 0;
|
|
687
734
|
}
|
|
688
|
-
|
|
735
|
+
const mod = await loadCommand("stop", () => import("./commands/lifecycle.ts"));
|
|
736
|
+
if (!mod) return 1;
|
|
737
|
+
return await mod.stop(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
|
|
689
738
|
}
|
|
690
739
|
|
|
691
740
|
case "restart": {
|
|
@@ -693,7 +742,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
693
742
|
console.log(restartHelp());
|
|
694
743
|
return 0;
|
|
695
744
|
}
|
|
696
|
-
|
|
745
|
+
const mod = await loadCommand("restart", () => import("./commands/lifecycle.ts"));
|
|
746
|
+
if (!mod) return 1;
|
|
747
|
+
return await mod.restart(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
|
|
697
748
|
}
|
|
698
749
|
|
|
699
750
|
case "upgrade": {
|
|
@@ -744,7 +795,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
744
795
|
upgradeOpts.channel = channelExtract.value;
|
|
745
796
|
}
|
|
746
797
|
if (allowDowngrade) upgradeOpts.allowDowngrade = true;
|
|
747
|
-
|
|
798
|
+
const mod = await loadCommand("upgrade", () => import("./commands/upgrade.ts"));
|
|
799
|
+
if (!mod) return 1;
|
|
800
|
+
return await mod.upgrade(remaining[0], upgradeOpts);
|
|
748
801
|
}
|
|
749
802
|
|
|
750
803
|
case "logs": {
|
|
@@ -759,7 +812,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
759
812
|
return 1;
|
|
760
813
|
}
|
|
761
814
|
const follow = rest.includes("-f") || rest.includes("--follow");
|
|
762
|
-
|
|
815
|
+
const mod = await loadCommand("logs", () => import("./commands/lifecycle.ts"));
|
|
816
|
+
if (!mod) return 1;
|
|
817
|
+
return await mod.logs(svc, { follow });
|
|
763
818
|
}
|
|
764
819
|
|
|
765
820
|
case "migrate": {
|
|
@@ -775,7 +830,31 @@ async function main(argv: string[]): Promise<number> {
|
|
|
775
830
|
console.error("usage: parachute migrate --teardown");
|
|
776
831
|
return 1;
|
|
777
832
|
}
|
|
778
|
-
|
|
833
|
+
const mod = await loadCommand("migrate", () => import("./commands/migrate-cutover.ts"));
|
|
834
|
+
if (!mod) return 1;
|
|
835
|
+
// teardownHubUnit logs the human-facing lines itself (the success
|
|
836
|
+
// guidance, or "nothing to tear down"). hub#534: the CLI must still
|
|
837
|
+
// own the EXIT CODE + surface any failure detail the function's
|
|
838
|
+
// false-branch doesn't print — pre-fix it ignored `removed` + `messages`
|
|
839
|
+
// and always exited 0, so a non-removal looked like success to a script.
|
|
840
|
+
const result = mod.teardownHubUnit();
|
|
841
|
+
if (result.removed) return 0;
|
|
842
|
+
// removed === false: either a clean no-op (nothing was installed —
|
|
843
|
+
// `messages` empty) or a real failure (the removal carried a reason in
|
|
844
|
+
// `messages` the internal log didn't surface). The no-op is informational
|
|
845
|
+
// (exit 0); a failure with detail is an error (print it, exit 1).
|
|
846
|
+
//
|
|
847
|
+
// DELIBERATE double-print on the failure path (not a bug): the function's
|
|
848
|
+
// own log() already wrote a human-readable summary ("Hub-unit teardown did
|
|
849
|
+
// not complete: …") to STDOUT; here we re-emit the raw reason(s) to STDERR.
|
|
850
|
+
// The split is intentional — a person reading the terminal sees the framed
|
|
851
|
+
// summary, while a script that captures `2>` gets the machine-parseable
|
|
852
|
+
// reason alongside the non-zero exit. Mirrors the streams convention the
|
|
853
|
+
// rest of the CLI uses (human guidance on stdout, error detail on stderr).
|
|
854
|
+
if (result.messages.length > 0) {
|
|
855
|
+
for (const line of result.messages) console.error(line);
|
|
856
|
+
return 1;
|
|
857
|
+
}
|
|
779
858
|
return 0;
|
|
780
859
|
}
|
|
781
860
|
// §7.1 detached→supervised cutover. Opt-in surface (the archive sweep
|
|
@@ -788,7 +867,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
788
867
|
console.error("usage: parachute migrate --to-supervised");
|
|
789
868
|
return 1;
|
|
790
869
|
}
|
|
791
|
-
const
|
|
870
|
+
const mod = await loadCommand("migrate", () => import("./commands/migrate-cutover.ts"));
|
|
871
|
+
if (!mod) return 1;
|
|
872
|
+
const result = await mod.cutoverToSupervised();
|
|
792
873
|
for (const line of result.messages) console.log(line);
|
|
793
874
|
// "already-migrated" / "migrated" are success; every other outcome is a
|
|
794
875
|
// recoverable failure that should exit non-zero so scripts can retry.
|
|
@@ -807,7 +888,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
807
888
|
);
|
|
808
889
|
return 1;
|
|
809
890
|
}
|
|
810
|
-
|
|
891
|
+
const mod = await loadCommand("migrate", () => import("./commands/migrate.ts"));
|
|
892
|
+
if (!mod) return 1;
|
|
893
|
+
return await mod.migrate({ dryRun, list, yes });
|
|
811
894
|
}
|
|
812
895
|
|
|
813
896
|
case "serve": {
|
|
@@ -824,7 +907,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
824
907
|
// event loop alive until SIGINT/SIGTERM, at which point we stop the
|
|
825
908
|
// server cleanly and exit. Container supervisor (tini, Render, Docker)
|
|
826
909
|
// reaps us once the event loop drains.
|
|
827
|
-
const
|
|
910
|
+
const mod = await loadCommand("serve", () => import("./commands/serve.ts"));
|
|
911
|
+
if (!mod) return 1;
|
|
912
|
+
const { stop: stopServer } = await mod.serve();
|
|
828
913
|
await new Promise<void>((resolve) => {
|
|
829
914
|
const handler = async () => {
|
|
830
915
|
await stopServer();
|
|
@@ -836,14 +921,19 @@ async function main(argv: string[]): Promise<number> {
|
|
|
836
921
|
return 0;
|
|
837
922
|
}
|
|
838
923
|
|
|
839
|
-
case "auth":
|
|
840
|
-
|
|
924
|
+
case "auth": {
|
|
925
|
+
const mod = await loadCommand("auth", () => import("./commands/auth.ts"));
|
|
926
|
+
if (!mod) return 1;
|
|
927
|
+
return await mod.auth(rest);
|
|
928
|
+
}
|
|
841
929
|
|
|
842
930
|
case "vault": {
|
|
931
|
+
const mod = await loadCommand("vault", () => import("./commands/vault.ts"));
|
|
932
|
+
if (!mod) return 1;
|
|
843
933
|
// `parachute vault` with no args forwards --help to parachute-vault so
|
|
844
934
|
// users see the actual vault surface, not a CLI-side stub. Anything
|
|
845
935
|
// after `vault` (including --help) is passed through verbatim.
|
|
846
|
-
if (rest.length === 0) return await dispatchVault(["--help"]);
|
|
936
|
+
if (rest.length === 0) return await mod.dispatchVault(["--help"]);
|
|
847
937
|
|
|
848
938
|
// Everything under `vault` forwards transparently to `parachute-vault`.
|
|
849
939
|
// `vault tokens create` used to route through a guided interactive
|
|
@@ -852,7 +942,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
852
942
|
// hub-issued JWTs; mint them with `parachute auth mint-token` or the
|
|
853
943
|
// admin SPA Connect card. We forward verbatim so the operator sees
|
|
854
944
|
// vault's own migration error rather than a hub-side stub.
|
|
855
|
-
return await dispatchVault(rest);
|
|
945
|
+
return await mod.dispatchVault(rest);
|
|
856
946
|
}
|
|
857
947
|
|
|
858
948
|
default:
|
|
@@ -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;
|
|
@@ -86,6 +86,7 @@ import { type PortListeningFn, defaultPortListening } from "../port-probe.ts";
|
|
|
86
86
|
import { type AliveFn, clearPid, readPid } from "../process-state.ts";
|
|
87
87
|
import { shortNameForManifest } from "../service-spec.ts";
|
|
88
88
|
import { type ServiceEntry, readManifestLenient } from "../services-manifest.ts";
|
|
89
|
+
import { enrichedUnitPath } from "../spawn-path.ts";
|
|
89
90
|
import {
|
|
90
91
|
type DisableStaleModuleUnitsOpts,
|
|
91
92
|
type DisableStaleModuleUnitsResult,
|
|
@@ -232,14 +233,14 @@ export interface WriteUnitResult {
|
|
|
232
233
|
* and `installManagedUnit(start:false)` — daemon-reload / write-the-plist but
|
|
233
234
|
* NEVER enable --now / bootstrap. The §7.1 step-2 race-avoider.
|
|
234
235
|
*/
|
|
235
|
-
function defaultUnitPath(bunInstall: string): string {
|
|
236
|
-
return `${bunInstall}/bin:/usr/local/bin:/usr/bin:/bin`;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
236
|
export function defaultWriteUnitWithoutStarting(opts: WriteUnitOpts): WriteUnitResult {
|
|
240
237
|
const { deps } = opts;
|
|
241
238
|
const bunInstall = `${deps.homeDir()}/.bun`;
|
|
242
|
-
|
|
239
|
+
// Shared with the init-bringup path (hub-unit.ts) so the two unit-generation
|
|
240
|
+
// sites can't drift — enriches the unit PATH with operator-tool dirs
|
|
241
|
+
// (`$HOME/.local/bin`, brew bin) so a migrated launchd/systemd hub can find
|
|
242
|
+
// scribe's `parakeet-mlx` + `ffmpeg`. See `spawn-path.ts`.
|
|
243
|
+
const path = enrichedUnitPath(bunInstall, deps.homeDir(), deps.platform);
|
|
243
244
|
const logPath = `${opts.parachuteHome}/hub/logs/hub.log`;
|
|
244
245
|
let unit: ManagedUnit;
|
|
245
246
|
try {
|
|
@@ -878,6 +879,12 @@ export function teardownHubUnit(opts: TeardownOpts = {}): { removed: boolean; me
|
|
|
878
879
|
log("The supervised hub unit is gone. To run the hub now, either:");
|
|
879
880
|
log(" - `parachute serve` (foreground), or");
|
|
880
881
|
log(" - `parachute migrate --to-supervised` to reinstall the unit.");
|
|
882
|
+
} else if (res.messages.length > 0) {
|
|
883
|
+
// removed === false WITH detail: a real removal failure, not a clean
|
|
884
|
+
// no-op. Surface the reason rather than the misleading "nothing was
|
|
885
|
+
// installed" line (hub#534 — the CLI also maps this to a non-zero exit).
|
|
886
|
+
log("Hub-unit teardown did not complete:");
|
|
887
|
+
for (const m of res.messages) log(` ${m}`);
|
|
881
888
|
} else {
|
|
882
889
|
log("No hub unit was installed — nothing to tear down.");
|
|
883
890
|
}
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
shortNameForManifest,
|
|
28
28
|
} from "../service-spec.ts";
|
|
29
29
|
import { type ServiceEntry, readManifestLenient } from "../services-manifest.ts";
|
|
30
|
+
import { enrichedPath } from "../spawn-path.ts";
|
|
30
31
|
import type { Supervisor } from "../supervisor.ts";
|
|
31
32
|
|
|
32
33
|
export interface BootOpts {
|
|
@@ -75,9 +76,14 @@ export interface BuildSpawnRequestOpts {
|
|
|
75
76
|
* Env layering (later wins):
|
|
76
77
|
* 1. `PORT` from the services.json `entry.port` — overrides hub's own PORT
|
|
77
78
|
* so supervised children honor their canonical port assignment
|
|
78
|
-
* (hub#356/#357).
|
|
79
|
+
* (hub#356/#357). This is authoritative and is NOT overridable by a
|
|
80
|
+
* `.env` `PORT` (see below) — services.json is the single source of truth
|
|
81
|
+
* for the port (scribe#41 4-tier ladder; hub#206).
|
|
79
82
|
* 2. per-service `.env` at `<configDir>/<short>/.env` — operator-configured
|
|
80
|
-
* values (e.g. scribe provider keys)
|
|
83
|
+
* values (e.g. scribe provider keys) merge on top. A `PORT` key here is
|
|
84
|
+
* dropped: a stale pre-#206 `.env` `PORT` must not shadow `entry.port`
|
|
85
|
+
* (hub#537 — a leftover scribe `PORT=1944` ≠ services.json `1943` leaked
|
|
86
|
+
* into the injected PORT and broke the supervisor's readiness probe).
|
|
81
87
|
* 3. `PARACHUTE_HUB_ORIGIN` = `opts.hubOrigin` — anchors the child's `iss`
|
|
82
88
|
* expectation to the value hub mints with (hub#365).
|
|
83
89
|
* 4. `opts.extraEnv` — test seam / first-boot pass-through; wins last.
|
|
@@ -93,7 +99,31 @@ export function buildModuleSpawnRequest(
|
|
|
93
99
|
opts: BuildSpawnRequestOpts,
|
|
94
100
|
): SpawnReqShape {
|
|
95
101
|
const fileEnv = readEnvFileValues(join(opts.configDir, short, ".env"));
|
|
96
|
-
|
|
102
|
+
// Drop a `PORT` from the per-service .env: services.json `entry.port` is the
|
|
103
|
+
// canonical port and must win (scribe#41 ladder; hub#206). A stale pre-#206
|
|
104
|
+
// `.env` PORT (e.g. scribe's `1944` vs services.json `1943`) would otherwise
|
|
105
|
+
// leak into the injected PORT and the supervisor's readiness probe would
|
|
106
|
+
// check the wrong port → false `started_but_unbound` (hub#537). The module's
|
|
107
|
+
// own resolvePort ladder already prefers services.json, so this keeps the
|
|
108
|
+
// injected PORT + probe in agreement with what the child actually binds.
|
|
109
|
+
const { PORT: _staleEnvPort, ...fileEnvSansPort } = fileEnv;
|
|
110
|
+
// PATH enrichment (hub launchd-PATH regression): the hub unit bakes a minimal
|
|
111
|
+
// PATH and `Bun.spawn` defaults to empty env, so without this the child only
|
|
112
|
+
// ever sees the unit's PATH — which omits `$HOME/.local/bin` (scribe's
|
|
113
|
+
// `parakeet-mlx`) + the Homebrew bin (`ffmpeg`), killing transcription on
|
|
114
|
+
// canonical installs. `enrichedPath` appends those dirs (when they exist) to
|
|
115
|
+
// the inherited PATH; inherited entries keep their order. A per-service `.env`
|
|
116
|
+
// PATH (operator intent) still wins via the spread below. See `spawn-path.ts`.
|
|
117
|
+
// The API-start path builds its own env — see `api-modules-ops.ts`
|
|
118
|
+
// `spawnSupervised`, which calls `enrichedPath()` too (keep the two in sync).
|
|
119
|
+
// `process.env.PATH` may ALREADY be enriched by serve startup (serve.ts);
|
|
120
|
+
// re-enriching here is a harmless no-op — `enrichedPath` is idempotent
|
|
121
|
+
// (dedupe + append-only), so double-enrichment can't duplicate or reorder.
|
|
122
|
+
const env: Record<string, string> = {
|
|
123
|
+
PATH: enrichedPath(),
|
|
124
|
+
PORT: String(entry.port),
|
|
125
|
+
...fileEnvSansPort,
|
|
126
|
+
};
|
|
97
127
|
if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
|
|
98
128
|
if (opts.extraEnv) Object.assign(env, opts.extraEnv);
|
|
99
129
|
|