@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/commands/install.ts
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|