@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.
Files changed (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -4,6 +4,14 @@ import { dirname, join } from "node:path";
4
4
  import { autoWireScribeAuth } from "../auto-wire.ts";
5
5
  import { bunGlobalPrefixes, isLinked as defaultIsLinkedShared } from "../bun-link.ts";
6
6
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
7
+ import { type ExposeState, readExposeState } from "../expose-state.ts";
8
+ import {
9
+ HUB_DEFAULT_PORT,
10
+ type PidOnPortFn,
11
+ defaultPidOnPort,
12
+ readHubPort,
13
+ } from "../hub-control.ts";
14
+ import { type HubUnitDeps, defaultHubUnitDeps, isHubUnitInstalled } from "../hub-unit.ts";
7
15
  import {
8
16
  type ModuleManifest,
9
17
  ModuleManifestError,
@@ -26,8 +34,14 @@ import {
26
34
  synthesizeManifestForKnownModule,
27
35
  } from "../service-spec.ts";
28
36
  import { findService, readManifest, upsertService } from "../services-manifest.ts";
37
+ import {
38
+ type DisableStaleModuleUnitsOpts,
39
+ type DisableStaleModuleUnitsResult,
40
+ disableStaleModuleUnits as defaultDisableStaleModuleUnits,
41
+ } from "../stale-module-units.ts";
42
+ import { type OwnerProbeFn, defaultOwnerOfPid } from "../supervisor.ts";
29
43
  import { WELL_KNOWN_PATH } from "../well-known.ts";
30
- import { start as lifecycleStart } from "./lifecycle.ts";
44
+ import { type LifecycleOpts, start as lifecycleStart } from "./lifecycle.ts";
31
45
  import { migrateNotice } from "./migrate.ts";
32
46
  import {
33
47
  type InteractiveAvailability,
@@ -215,6 +229,60 @@ export interface InstallOpts {
215
229
  * leave it false so today's behavior is unchanged.
216
230
  */
217
231
  noCreate?: boolean;
232
+ /**
233
+ * `parachute install vault --interactive` (#579 / #580 item 1): opt back into
234
+ * the FULL interactive module setup — the service's own `spec.init` (vault's
235
+ * vault-name prompt, "install as MCP in Claude Code?", "mint an API token?")
236
+ * and, for vault, its self-registered standalone daemon.
237
+ *
238
+ * Default: false. The manual `parachute install <svc>` path is now LIGHT
239
+ * (matching `parachute init`'s Step 2.5): install the package, seed/register
240
+ * services.json, start under the supervisor, and print a short guidance block
241
+ * pointing at the admin UI + the optional extras (`parachute-vault
242
+ * mcp-install`, token minting in the UI). No interactive interview, no
243
+ * vault-side daemon registration that would race the supervisor for :1940.
244
+ *
245
+ * The old "drag me through the full init" behavior is opt-in via this flag.
246
+ * When `true` AND the spec ships an `init` command, install runs `spec.init`
247
+ * as it did pre-#579. When `false` (the default) for a module whose `init`
248
+ * would otherwise run an interview, install SKIPS `spec.init` (the
249
+ * `noCreate`-equivalent quiet path) and emits the guidance block instead.
250
+ *
251
+ * Orthogonal to `noCreate` (which `parachute init` uses to ALSO skip the
252
+ * post-install start). The light manual path still starts the module under
253
+ * the supervisor; only the interactive interview is suppressed.
254
+ */
255
+ interactive?: boolean;
256
+ /**
257
+ * Test seam for the supervised-hub probe + admin-URL resolution that drive
258
+ * the light-install guidance block. Production reads the real expose-state /
259
+ * hub-port / hub-unit deps; tests inject deterministic values so the guidance
260
+ * assertions don't depend on the operator's live box.
261
+ */
262
+ guidanceCtx?: {
263
+ /** Is a hub unit installed (→ supervised box)? Defaults to the real probe. */
264
+ hubUnitInstalled?: boolean;
265
+ /** Hub-unit deps for the real `isHubUnitInstalled` probe. */
266
+ hubUnitDeps?: HubUnitDeps;
267
+ /** Live expose state (→ public admin URL). Defaults to `readExposeState()`. */
268
+ exposeState?: ExposeState | undefined;
269
+ /** Hub loopback port for the admin URL fallback. Defaults to `readHubPort()`. */
270
+ hubPort?: number | undefined;
271
+ };
272
+ /**
273
+ * Test seam for the install-time stale-unit sweep (#580 item 3). Production
274
+ * wires `disableStaleModuleUnits` (the #522 migrate/teardown sweep, reused
275
+ * verbatim — known-module shorts only, hub + cloudflared skipped, idempotent,
276
+ * non-fatal). Tests inject a fake so no real launchctl/systemctl runs and the
277
+ * sweep's invocation (and logged actions) can be asserted.
278
+ *
279
+ * The sweep fires only when a supervised hub is present (the same
280
+ * `guidanceCtx.hubUnitInstalled` discriminant) and the module is being
281
+ * started — a leftover standalone `parachute-<short>` unit (KeepAlive /
282
+ * RunAtLoad) would otherwise keep an unsupervised module bound to the port,
283
+ * crash-looping the supervisor's own child (the #580 field signature).
284
+ */
285
+ disableStaleModuleUnits?: (opts?: DisableStaleModuleUnitsOpts) => DisableStaleModuleUnitsResult;
218
286
  /**
219
287
  * `parachute install scribe` only: pre-pick the transcription provider so
220
288
  * the prompt doesn't fire. Validated against scribe's known providers — an
@@ -239,6 +307,22 @@ export interface InstallOpts {
239
307
  * unless the test populates services.json directly.
240
308
  */
241
309
  portProbe?: (port: number) => Promise<boolean>;
310
+ /**
311
+ * Test seam for the install-time port-squatter naming (#590 item 2). When the
312
+ * canonical port walk has to assign a fallback port because the canonical one
313
+ * is held, this looks up the pid LISTENing on the canonical port so the
314
+ * warning can name the holder (`pid 1234 (bun .../vault/src/server.ts)`) — the
315
+ * same #581 `pidOnPort` / `ownerOfPid` seams the supervisor start-path uses,
316
+ * reused (not duplicated). Detection-only — never kills. Production wires
317
+ * `defaultPidOnPort` (`lsof -ti :<port>`); tests inject a deterministic stub.
318
+ */
319
+ pidOnPort?: PidOnPortFn;
320
+ /**
321
+ * Test seam for the install-time port-squatter naming (#590 item 2): the
322
+ * best-effort command line of the squatting pid. Production wires
323
+ * `defaultOwnerOfPid` (`ps -o command= -p <pid>`); tests inject a stub.
324
+ */
325
+ ownerOfPid?: OwnerProbeFn;
242
326
  /**
243
327
  * Test seam for reading `<packageDir>/.parachute/module.json`. Production
244
328
  * uses the real file reader; tests inject a map from package-dir → manifest
@@ -553,6 +637,111 @@ function resolveInstallTarget(
553
637
  return { kind: "npm", packageName: input };
554
638
  }
555
639
 
640
+ /**
641
+ * Build the LifecycleOpts the install auto-start uses (hub#573).
642
+ *
643
+ * The auto-start MUST thread the SAME supervisor + migrate-offer opts the
644
+ * production CLI dispatch passes for `parachute start <svc>` (cli.ts:
645
+ * `supervisor: {}` + `migrateOffer: { enabled: true }`). Without them, `start`
646
+ * resolved `unitInstalled` to its omitted-supervisor default of `false` and
647
+ * `migrateOffer.enabled` to `false` — so the auto-start ALWAYS took the no-unit
648
+ * path, printed "No supervised hub unit is installed. Run `parachute migrate
649
+ * --to-supervised`…", and returned non-zero → the "⚠ didn't start cleanly"
650
+ * warning. Meanwhile `parachute migrate` (which DOES run the real
651
+ * `isHubUnitInstalled` probe + /health) reported the unit already installed +
652
+ * healthy: the two paths disagreed because only `migrate` opted into real
653
+ * detection. `supervisor: {}` makes the auto-start run the same probe;
654
+ * `migrateOffer: { enabled: true }` makes it offer the cutover on a genuinely-
655
+ * unmigrated box instead of dumping a bare error mid-install.
656
+ *
657
+ * Exported so the convergence is unit-testable without driving a real start.
658
+ */
659
+ export function defaultStartLifecycleOpts(ctx: {
660
+ manifestPath: string;
661
+ configDir: string;
662
+ log: (line: string) => void;
663
+ }): LifecycleOpts {
664
+ return {
665
+ manifestPath: ctx.manifestPath,
666
+ configDir: ctx.configDir,
667
+ log: ctx.log,
668
+ supervisor: {},
669
+ migrateOffer: { enabled: true },
670
+ };
671
+ }
672
+
673
+ /**
674
+ * Read the expose-state, swallowing a malformed-file error to undefined so the
675
+ * guidance block degrades to the loopback admin URL instead of throwing mid-
676
+ * install. Mirrors init's tolerant read of the same file.
677
+ */
678
+ function safeReadExposeState(): ExposeState | undefined {
679
+ try {
680
+ return readExposeState();
681
+ } catch {
682
+ return undefined;
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Resolve the canonical admin URL the light-install guidance points at — the
688
+ * SAME resolution `parachute init` uses (`init.ts:resolveAdminUrl`): the live
689
+ * expose-state public FQDN when the hub is exposed, otherwise the loopback
690
+ * `http://127.0.0.1:<port>/admin/`. Kept as a thin local copy (rather than
691
+ * importing init.ts) so the install command doesn't pull in the wizard module
692
+ * graph; the shape is asserted against init's in tests.
693
+ */
694
+ function resolveGuidanceAdminUrl(
695
+ exposeState: ExposeState | undefined,
696
+ hubPort: number | undefined,
697
+ ): string {
698
+ if (exposeState?.canonicalFqdn) {
699
+ return `https://${exposeState.canonicalFqdn}/admin/`;
700
+ }
701
+ return `http://127.0.0.1:${hubPort ?? HUB_DEFAULT_PORT}/admin/`;
702
+ }
703
+
704
+ /**
705
+ * The post-install guidance block for the LIGHT manual install path (#579).
706
+ *
707
+ * Replaces the old interactive interview ("name your vault / install MCP / mint
708
+ * a token") with a short pointer to where the operator manages + creates vaults
709
+ * (the admin UI) plus one-liners for the optional extras they used to be dragged
710
+ * through up front. Aaron's framing: "I just wanna install vault and then I'm
711
+ * managing it through the UI" — the install confirms the module is up and tells
712
+ * them where to go next, no token minted, no MCP wired, until they ask.
713
+ *
714
+ * Returns an empty array for modules that don't carry the interactive-init
715
+ * footprint (so the generic `postInstallFooter` stays the surface for those).
716
+ *
717
+ * VAULT-ONLY for now, intentionally (N4). Vault is the only SERVICE_SPECS module
718
+ * that ships an interactive `spec.init` today, so it's the only one whose light
719
+ * path drops an interview that needs replacing with guidance. When a FUTURE
720
+ * module ships its own `spec.init` (and thus takes the light-path skip), add its
721
+ * guidance arm HERE — or, if the per-module copy starts to diverge meaningfully,
722
+ * lift the guidance text onto the ServiceSpec shape (e.g. a
723
+ * `lightInstallGuidance?: (adminUrl) => string[]` extra) so each module owns its
724
+ * own next-steps block instead of this central switch. The empty-array fallback
725
+ * keeps every other module silent here regardless.
726
+ */
727
+ export function buildLightInstallGuidance(short: string, adminUrl: string): string[] {
728
+ if (short === "vault") {
729
+ return [
730
+ "",
731
+ "Vault is installed and running under the hub supervisor.",
732
+ "Manage + create vaults in the admin UI:",
733
+ ` ${adminUrl}`,
734
+ "",
735
+ "Optional, when you want them (not needed to start):",
736
+ " • Connect a vault to Claude Code: parachute-vault mcp-install",
737
+ " • Mint an API token for other MCP clients: do it from the admin UI (Tokens).",
738
+ "",
739
+ "Run the full interactive setup instead with: parachute install vault --interactive",
740
+ ];
741
+ }
742
+ return [];
743
+ }
744
+
556
745
  export async function install(input: string, opts: InstallOpts = {}): Promise<number> {
557
746
  const runner = opts.runner ?? defaultRunner;
558
747
  const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
@@ -733,7 +922,35 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
733
922
  ? spec.manifestName
734
923
  : manifest.name;
735
924
 
736
- if (spec.init && !opts.noCreate) {
925
+ // Whether to run the module's interactive `spec.init` (#579 / #580 item 1).
926
+ //
927
+ // The manual `parachute install <svc>` path is now LIGHT by default: we do
928
+ // NOT drag the operator through `spec.init`'s interview (for vault: vault-name
929
+ // prompt, "install as MCP?", "mint a token?", and a self-registered standalone
930
+ // daemon that would race the supervisor for :1940). The operator installs the
931
+ // module and manages it from the admin UI. `spec.init` runs ONLY when the
932
+ // caller explicitly opts back in with `--interactive` (and isn't in the
933
+ // `noCreate` quiet path the wizard uses). Modules without a `spec.init` are
934
+ // unaffected — there's no interview to suppress.
935
+ const runInteractiveInit = spec.init !== undefined && opts.interactive === true && !opts.noCreate;
936
+ if (runInteractiveInit && spec.init) {
937
+ // Reviewer surprise 2 / #580: the interactive path runs the module's OWN
938
+ // init, which (for vault today) registers a standalone platform daemon
939
+ // (launchd KeepAlive / systemd Restart=always). On a SUPERVISED hub that
940
+ // daemon races the supervisor for the module's port — the exact #580
941
+ // EADDRINUSE-crash-loop condition the light path avoids by not running init.
942
+ // Warn so an operator who reaches for --interactive on a supervised box
943
+ // knows to pass the daemon-off flag (or prefer the light default).
944
+ const supervisedForWarn =
945
+ opts.guidanceCtx?.hubUnitInstalled ??
946
+ (opts.guidanceCtx !== undefined || manifestPath === SERVICES_MANIFEST_PATH
947
+ ? isHubUnitInstalled(opts.guidanceCtx?.hubUnitDeps ?? defaultHubUnitDeps)
948
+ : false);
949
+ if (supervisedForWarn) {
950
+ log(
951
+ `⚠ --interactive runs ${short}'s own setup, which may register a standalone daemon. On a supervised hub that daemon races the supervisor for ${short}'s port (#580). Prefer the light default, or pass --no-autostart through to ${short}'s init.`,
952
+ );
953
+ }
737
954
  // Forward --vault-name from the InstallOpts when set so `parachute setup`
738
955
  // (and any future programmatic caller) can pre-answer the name prompt.
739
956
  const initCmd =
@@ -748,6 +965,15 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
748
965
  }
749
966
  } else if (spec.init && opts.noCreate) {
750
967
  log(`(skipping ${spec.init.join(" ")} — --no-create: module installed, no instance created)`);
968
+ } else if (spec.init) {
969
+ // Light path: the module ships an interactive init but the operator didn't
970
+ // ask for it. Skip the interview; the guidance block at the end of install
971
+ // tells them where to manage + create instances. The supervisor (started
972
+ // below) owns the lifecycle, so vault's own daemon registration is
973
+ // deliberately NOT triggered here — that's the :1940 race #580 fixed.
974
+ log(
975
+ `(skipping ${spec.init.join(" ")} — manage ${short} from the admin UI; re-run with --interactive for the full setup)`,
976
+ );
751
977
  }
752
978
 
753
979
  // Hub-as-port-authority (#53): pick the service's port now and reflect it
@@ -770,6 +996,25 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
770
996
  });
771
997
  if (portResult.warning) {
772
998
  log(`⚠ ${portResult.warning}`);
999
+ // #590 item 2: the canonical port was held, so we walked to a fallback. Name
1000
+ // the squatter — the supervisor start-path does this post-#581; do it here at
1001
+ // install-time too. Reuse the #581 pidOnPort / ownerOfPid seams (detection
1002
+ // only; never kill). When the holder is a foreign pid (not one of OUR rows —
1003
+ // which is the common case when a stale pre-supervisor daemon is squatting),
1004
+ // surface its pid + command line + a hint.
1005
+ if (canonicalPort !== undefined && portResult.source !== "canonical") {
1006
+ const pidOnPort = opts.pidOnPort ?? defaultPidOnPort;
1007
+ const ownerOfPid = opts.ownerOfPid ?? defaultOwnerOfPid;
1008
+ const holder = pidOnPort(canonicalPort);
1009
+ if (holder !== undefined) {
1010
+ const cmdline = ownerOfPid(holder);
1011
+ const who = cmdline ? `pid ${holder} (${cmdline})` : `pid ${holder}`;
1012
+ log(` canonical port ${canonicalPort} is held by ${who}.`);
1013
+ log(
1014
+ ` This may be a stale pre-supervisor daemon. If so, stop it (kill ${holder}) and re-run \`parachute install ${entryName}\` to reclaim the canonical port.`,
1015
+ );
1016
+ }
1017
+ }
773
1018
  }
774
1019
 
775
1020
  // Find-or-seed the manifest entry. Re-read after the seed write so a silent
@@ -870,6 +1115,40 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
870
1115
  const notice = migrateNotice(configDir, now());
871
1116
  if (notice) log(notice);
872
1117
 
1118
+ // Install-time stale-unit sweep (#580 item 3 / #522 part 2). Before we start
1119
+ // the module under the supervisor, disable any leftover STANDALONE per-module
1120
+ // autostart unit (a pre-supervisor `parachute-<short>.service` with
1121
+ // Restart=always, or a `computer.parachute.<short>` LaunchAgent with
1122
+ // KeepAlive). Such a unit keeps RESPAWNING an unsupervised module that binds
1123
+ // the module's port; the supervised child then EADDRINUSE-crash-loops and
1124
+ // lands `crashed` — the recurring field signature in #580 / #522. Reuses the
1125
+ // exact #522 migrate/teardown sweep (`disableStaleModuleUnits`): known-module
1126
+ // shorts only, hub + cloudflared explicitly skipped, idempotent (already-
1127
+ // disabled/absent = silent no-op), non-fatal (a failed disable warns +
1128
+ // continues). Gated on a supervised hub being present — on a non-supervised
1129
+ // box the per-module unit IS the legitimate lifecycle and we must not touch
1130
+ // it. Only runs on the start path (skipped under --no-start / --no-create).
1131
+ const willStart = !opts.noStart && !opts.noCreate;
1132
+ if (willStart) {
1133
+ const gctx = opts.guidanceCtx;
1134
+ const sweepAllowed =
1135
+ opts.disableStaleModuleUnits !== undefined || manifestPath === SERVICES_MANIFEST_PATH;
1136
+ const supervisedForSweep =
1137
+ gctx?.hubUnitInstalled ?? isHubUnitInstalled(gctx?.hubUnitDeps ?? defaultHubUnitDeps);
1138
+ if (sweepAllowed && supervisedForSweep) {
1139
+ const sweep = opts.disableStaleModuleUnits ?? defaultDisableStaleModuleUnits;
1140
+ const result = sweep({ log: (l) => log(l) });
1141
+ const disabled = result.actions.filter((a) => a.result === "disabled");
1142
+ if (disabled.length > 0) {
1143
+ log(
1144
+ `Swept ${disabled.length} stale per-module autostart unit(s) so the supervisor owns the port(s): ${disabled
1145
+ .map((a) => a.unit)
1146
+ .join(", ")}.`,
1147
+ );
1148
+ }
1149
+ }
1150
+ }
1151
+
873
1152
  // Auto-start: vault and notes' inits historically left a daemon running, but
874
1153
  // scribe (and any service without a daemon-launching init) didn't — so
875
1154
  // launch-day `install scribe` ended with a silent install and the user
@@ -883,7 +1162,8 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
883
1162
  if (!opts.noStart && !opts.noCreate) {
884
1163
  const startService =
885
1164
  opts.startService ??
886
- ((short: string) => lifecycleStart(short, { manifestPath, configDir, log }));
1165
+ ((short: string) =>
1166
+ lifecycleStart(short, defaultStartLifecycleOpts({ manifestPath, configDir, log })));
887
1167
  const startCode = await startService(short);
888
1168
  if (startCode !== 0) {
889
1169
  log(`⚠ ${short} didn't start cleanly. Run manually: parachute start ${short}`);
@@ -898,6 +1178,44 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
898
1178
  for (const line of footer) log(line);
899
1179
  }
900
1180
 
1181
+ // Light-install guidance block (#579 / #580 item 1). When we suppressed the
1182
+ // module's interactive init (light path: it ships an init, the operator
1183
+ // didn't pass --interactive, and this isn't the wizard's noCreate path),
1184
+ // replace the absent interview with a short pointer to the admin UI + the
1185
+ // optional extras. Skipped for --interactive (the service's own footer
1186
+ // covers it) and for noCreate (the wizard prints its own admin URL).
1187
+ //
1188
+ // INFORMATIONAL, independent of the start path (N3): this block is *guidance*,
1189
+ // not an action, so it deliberately does NOT gate on `willStart` /
1190
+ // `!opts.noStart` the way the stale-unit sweep above does. Even under
1191
+ // `--no-start` (CI / piped installs) the operator still benefits from "here's
1192
+ // where to manage it once it's up" — the admin URL + extras are equally true
1193
+ // whether or not THIS invocation started the daemon.
1194
+ //
1195
+ // The supervised-hub probe + admin-URL resolution touch real on-disk state
1196
+ // (the hub plist / expose-state / hub-port file). Gate the production probe
1197
+ // on `manifestPath === SERVICES_MANIFEST_PATH` — the same isolation gate the
1198
+ // well-known regen uses — so a test driving install against a tempdir
1199
+ // manifestPath never reads the operator's real `~/.parachute`. Tests opt into
1200
+ // the guidance assertions by passing `guidanceCtx` explicitly.
1201
+ const guidanceProbeAllowed =
1202
+ opts.guidanceCtx !== undefined || manifestPath === SERVICES_MANIFEST_PATH;
1203
+ if (spec.init && !opts.interactive && !opts.noCreate && guidanceProbeAllowed) {
1204
+ const gctx = opts.guidanceCtx;
1205
+ const supervised =
1206
+ gctx?.hubUnitInstalled ?? isHubUnitInstalled(gctx?.hubUnitDeps ?? defaultHubUnitDeps);
1207
+ // Only emit the "managed under the supervisor" guidance when there's a
1208
+ // supervised hub to manage it through. On a non-supervised box (no hub
1209
+ // unit) the admin UI may not be reachable, so we stay quiet and let the
1210
+ // generic install output stand — the operator can run --interactive.
1211
+ if (supervised) {
1212
+ const exposeState = gctx && "exposeState" in gctx ? gctx.exposeState : safeReadExposeState();
1213
+ const hubPort = gctx && "hubPort" in gctx ? gctx.hubPort : readHubPort(configDir);
1214
+ const adminUrl = resolveGuidanceAdminUrl(exposeState, hubPort);
1215
+ for (const line of buildLightInstallGuidance(short, adminUrl)) log(line);
1216
+ }
1217
+ }
1218
+
901
1219
  // Final registration check — the service may have written its own
902
1220
  // authoritative entry during init or first boot, replacing the seed (or
903
1221
  // filling a gap when the service had no seedEntry). Re-read at exit so the
@@ -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
- const path = defaultUnitPath(bunInstall);
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) override the bare PORT.
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
- const env: Record<string, string> = { PORT: String(entry.port), ...fileEnv };
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