@openparachute/hub 0.6.1 → 0.6.3-rc.1

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.
@@ -39,6 +39,7 @@ import { MissingDependencyError, type MissingDependencyWire } from "@openparachu
39
39
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
40
40
  import { isLinked as defaultIsLinked } from "./bun-link.ts";
41
41
  import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
42
+ import { buildModuleSpawnRequest } from "./commands/serve-boot.ts";
42
43
  import { getModuleInstallChannel } from "./hub-settings.ts";
43
44
  import { validateAccessToken } from "./jwt-sign.ts";
44
45
  import { readModuleManifest } from "./module-manifest.ts";
@@ -456,6 +457,11 @@ async function spawnSupervised(
456
457
  // anchors the child's iss expectation to the same value hub mints with.
457
458
  //
458
459
  // `deps.spawnEnv` still wins (test seam + first-boot vault-name pass-through).
460
+ //
461
+ // No per-service `.env` here, by design: the install path runs before the
462
+ // operator has had a chance to write `configDir/<short>/.env`, so install
463
+ // spawns with install-env only. The per-service `.env` is layered in by
464
+ // `buildModuleSpawnRequest` (serve-boot.ts) on the next `boot` or `start`.
459
465
  const childEnv: Record<string, string> = {
460
466
  PORT: String(entry.port),
461
467
  ...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
@@ -713,6 +719,115 @@ export async function runInstall(
713
719
  registry.update(opId, { status: "succeeded" }, `${short} installed + spawned (pid ${state.pid})`);
714
720
  }
715
721
 
722
+ /**
723
+ * POST /api/modules/:short/start — synchronous.
724
+ *
725
+ * A pure `supervisor.start(req)` of an ALREADY-INSTALLED module, using
726
+ * the same boot-derived SpawnRequest `bootSupervisedModules` builds
727
+ * (PORT / per-service .env / PARACHUTE_HUB_ORIGIN injection via the
728
+ * shared `buildModuleSpawnRequest`). This is the §3.3 endpoint Phase 3
729
+ * will repoint `parachute start <svc>` onto.
730
+ *
731
+ * Explicitly NOT an install: it does not run `bun add -g`, seed
732
+ * services.json, stamp installDir, or refresh well-known. If the module
733
+ * isn't in services.json (never installed) it returns 400 `not_installed`
734
+ * with an actionable hint — not a silent install. If services.json
735
+ * carries the row but no startCmd is resolvable (CLI-only module,
736
+ * unreadable module.json), it returns 422 `no_start_cmd`.
737
+ *
738
+ * Synchronous like restart: `supervisor.start` returns the new state in
739
+ * the body; no operation poll needed. Idempotent — starting an
740
+ * already-running module returns its existing state (the supervisor's
741
+ * own idempotent `start`).
742
+ */
743
+ export async function handleStart(
744
+ req: Request,
745
+ short: CuratedModuleShort,
746
+ deps: ApiModulesOpsDeps,
747
+ ): Promise<Response> {
748
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
749
+ const authFail = await authorize(req, deps);
750
+ if (authFail) return authFail;
751
+
752
+ const spec = specFor(short);
753
+
754
+ // Pure-spawn precondition: the module must already be installed
755
+ // (present in services.json). `start` never installs — that's the
756
+ // install endpoint's job, which is far heavier (bun add -g / seed /
757
+ // stamp). A missing row is an operator error worth a clear message.
758
+ const entry = findService(spec.manifestName, deps.manifestPath);
759
+ if (!entry) {
760
+ return jsonError(
761
+ 400,
762
+ "not_installed",
763
+ `${short} is not installed (no services.json entry) — install it first via POST /api/modules/${short}/install`,
764
+ );
765
+ }
766
+
767
+ // KNOWN_MODULES shorts (vault / scribe / runner): module.json is the
768
+ // canonical source for startCmd. Re-resolve from
769
+ // `<installDir>/.parachute/module.json` when installDir is stamped so the
770
+ // module is authoritative for its own spawn cmd — mirroring runInstall's
771
+ // post-bun-add re-resolve. Falls back to the imperative `extras.startCmd`
772
+ // carried by `spec` when installDir is absent or module.json is unreadable.
773
+ let spawnSpec: ServiceSpec = spec;
774
+ if (entry.installDir && KNOWN_MODULES[short]) {
775
+ const resolved = await resolveSpawnSpec(short, entry.installDir);
776
+ if (resolved) spawnSpec = resolved;
777
+ }
778
+
779
+ const cmd = spawnSpec.startCmd?.(entry);
780
+ if (!cmd || cmd.length === 0) {
781
+ return jsonError(
782
+ 422,
783
+ "no_start_cmd",
784
+ `${short} has no resolvable startCmd (CLI-only module, or <installDir>/.parachute/module.json missing a startCmd)`,
785
+ );
786
+ }
787
+
788
+ // Build the SpawnRequest identically to the serve-boot path so `start`
789
+ // and boot produce the same child env (PORT / .env / HUB_ORIGIN). The
790
+ // test-seam / first-boot `spawnEnv` rides the shared helper's `extraEnv`
791
+ // and wins last, matching `spawnSupervised`'s precedence.
792
+ const spawnReq = buildModuleSpawnRequest(short, entry, cmd, {
793
+ configDir: deps.configDir,
794
+ ...(deps.issuer ? { hubOrigin: deps.issuer } : {}),
795
+ ...(deps.spawnEnv ? { extraEnv: deps.spawnEnv } : {}),
796
+ });
797
+
798
+ const state = await deps.supervisor.start(spawnReq);
799
+ return jsonOk({ short, state });
800
+ }
801
+
802
+ /**
803
+ * POST /api/modules/:short/stop — synchronous.
804
+ *
805
+ * A pure `supervisor.stop(short)` — SIGTERM the child, await exit (with
806
+ * SIGKILL escalation), mark `stopped`. Distinct from uninstall, which
807
+ * stops-then-removes the services.json row + `bun remove`s the package.
808
+ * `stop` leaves the module installed; it's the §3.3 endpoint Phase 3
809
+ * will repoint `parachute stop <svc>` onto.
810
+ *
811
+ * Idempotent: stopping a not-supervised module returns 200 with a
812
+ * `stopped: false` flag (nothing to stop) rather than erroring — the
813
+ * caller's intent ("ensure it's not running") is already satisfied.
814
+ */
815
+ export async function handleStop(
816
+ req: Request,
817
+ short: CuratedModuleShort,
818
+ deps: ApiModulesOpsDeps,
819
+ ): Promise<Response> {
820
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
821
+ const authFail = await authorize(req, deps);
822
+ if (authFail) return authFail;
823
+
824
+ const state = await deps.supervisor.stop(short);
825
+ if (!state) {
826
+ return jsonOk({ short, stopped: false });
827
+ }
828
+ return jsonOk({ short, stopped: true, state });
829
+ }
830
+
716
831
  /**
717
832
  * POST /api/modules/:short/restart — synchronous.
718
833
  *
@@ -741,6 +856,112 @@ export async function handleRestart(
741
856
  return jsonOk({ short, state });
742
857
  }
743
858
 
859
+ /**
860
+ * GET /api/modules/:short/logs — synchronous.
861
+ *
862
+ * Serves the supervisor's bounded per-module ring buffer (§6.5): the most
863
+ * recent output the child wrote, INCLUDING the boot/crash lines that happened
864
+ * before the caller connected — which a naive connect-time SSE tap would lose
865
+ * (and which are "likely the most important one — the exit cause"). This is
866
+ * the §6 endpoint Phase 3 will repoint `parachute logs <svc>` onto.
867
+ *
868
+ * Returns the buffer as both a joined `text` blob and a `lines` array (the CLI
869
+ * tail wants the blob; a structured consumer wants lines). A module that isn't
870
+ * supervised returns 404 `not_supervised`, matching the `restart` handler's
871
+ * error contract for the same state — the caller can fall through to `start`.
872
+ *
873
+ * `?follow=1` is accepted as a best-effort streaming tap: we replay the buffer
874
+ * first (the must-have), then stream subsequent lines as `text/plain` chunks.
875
+ * The buffer replay is what captures the crash cause; the follow tail is the
876
+ * nice-to-have. Without `follow`, it's a one-shot JSON snapshot.
877
+ */
878
+ export async function handleLogs(
879
+ req: Request,
880
+ short: CuratedModuleShort,
881
+ deps: ApiModulesOpsDeps,
882
+ ): Promise<Response> {
883
+ if (req.method !== "GET") return jsonError(405, "method_not_allowed", "use GET");
884
+ const authFail = await authorize(req, deps);
885
+ if (authFail) return authFail;
886
+
887
+ const lines = deps.supervisor.logs(short);
888
+ if (lines === undefined) {
889
+ // Same shape + status as `restart` for a not-supervised module, so the
890
+ // CLI client can treat both identically (fall through to `start`).
891
+ return jsonError(
892
+ 404,
893
+ "not_supervised",
894
+ `${short} is not currently supervised — install or start it first`,
895
+ );
896
+ }
897
+
898
+ const follow = new URL(req.url).searchParams.get("follow");
899
+ if (follow === "1" || follow === "true") {
900
+ return streamModuleLogs(short, lines, deps);
901
+ }
902
+
903
+ // One-shot snapshot: the buffered lines as both a joined blob + the array.
904
+ return jsonOk({ short, lines, text: lines.join("") });
905
+ }
906
+
907
+ /**
908
+ * Best-effort follow stream (§6.5 nice-to-have). Replays the buffered lines
909
+ * (the must-have — captures the boot/crash cause) then forwards subsequent
910
+ * output as `text/plain` chunks by subscribing to a tee of the supervisor's
911
+ * live tap. The buffer replay is guaranteed; the live tail is opportunistic
912
+ * (it ends when the client disconnects or the module stops). Implemented via
913
+ * a polling diff of the ring buffer so it stays decoupled from `pumpLines`'
914
+ * internal sink and needs no new supervisor wiring.
915
+ */
916
+ function streamModuleLogs(
917
+ short: CuratedModuleShort,
918
+ initial: string[],
919
+ deps: ApiModulesOpsDeps,
920
+ ): Response {
921
+ const encoder = new TextEncoder();
922
+ let lastLen = initial.length;
923
+ let timer: ReturnType<typeof setInterval> | undefined;
924
+ const stream = new ReadableStream<Uint8Array>({
925
+ start(controller) {
926
+ // Replay the buffered lines first — the boot/crash cause.
927
+ for (const line of initial) controller.enqueue(encoder.encode(line));
928
+ timer = setInterval(() => {
929
+ const current = deps.supervisor.logs(short);
930
+ if (current === undefined) {
931
+ // Module went away (uninstalled / never-supervised) — end the stream.
932
+ if (timer) clearInterval(timer);
933
+ try {
934
+ controller.close();
935
+ } catch {
936
+ // already closed
937
+ }
938
+ return;
939
+ }
940
+ // The ring buffer may have dropped old lines off the front; only
941
+ // forward genuinely-new tail lines. If the buffer shrank below our
942
+ // cursor (eviction), reset to its current length to avoid replaying.
943
+ // Limitation: new lines written during a heavy eviction burst (a chatty
944
+ // module overflowing the 64KiB cap between two polls) may be skipped in
945
+ // the live tail — use the one-shot snapshot (no ?follow) for crash investigation.
946
+ if (current.length < lastLen) lastLen = current.length;
947
+ for (let i = lastLen; i < current.length; i++) {
948
+ const line = current[i];
949
+ if (line !== undefined) controller.enqueue(encoder.encode(line));
950
+ }
951
+ lastLen = current.length;
952
+ }, 500);
953
+ },
954
+ cancel() {
955
+ // Stop polling when the consumer disconnects.
956
+ if (timer) clearInterval(timer);
957
+ },
958
+ });
959
+ return new Response(stream, {
960
+ status: 200,
961
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
962
+ });
963
+ }
964
+
744
965
  /**
745
966
  * POST /api/modules/:short/upgrade — async.
746
967
  *
@@ -42,8 +42,13 @@ import { FIRST_PARTY_FALLBACKS, KNOWN_MODULES } from "./service-spec.ts";
42
42
  // still required) and the latter for vault/scribe/runner (post-FALLBACK
43
43
  // retirement, hub#310). The local helper hides the split from the rest of
44
44
  // this file.
45
- import { type UiSubUnit, type UiSubUnitStatus, readManifest, readManifestLenient } from "./services-manifest.ts";
46
- import type { ModuleState, Supervisor } from "./supervisor.ts";
45
+ import {
46
+ type UiSubUnit,
47
+ type UiSubUnitStatus,
48
+ readManifest,
49
+ readManifestLenient,
50
+ } from "./services-manifest.ts";
51
+ import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts";
47
52
 
48
53
  /**
49
54
  * Resolve a curated module to the display + install bootstrap data the
@@ -174,6 +179,16 @@ interface ModuleWireShape {
174
179
  latest_version: string | null;
175
180
  supervisor_status: ModuleState["status"] | null;
176
181
  pid: number | null;
182
+ /**
183
+ * Structured supervisor start-failure detail (§6.5 / §6.4), when the
184
+ * supervisor recorded one for this module — a preflight `MissingDependencyError`
185
+ * or the alive-but-never-bound shape (hub#487). Mirrors the services.json
186
+ * `lastStartError` the detached path persists, so `parachute status` and the
187
+ * SPA keep the SAME friendly missing-dependency surface (#188) whether a
188
+ * module was started via the supervisor or the detached path. Null when the
189
+ * module started cleanly (or the hub is in pidfile/CLI mode with no supervisor).
190
+ */
191
+ supervisor_start_error: ModuleStartError | null;
177
192
  /**
178
193
  * The path on disk where the module is installed, if known. Surfaces
179
194
  * the BUN_INSTALL or bun-link install location for operator debug —
@@ -477,6 +492,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
477
492
  latest_version: latestByShort.get(short) ?? null,
478
493
  supervisor_status: state?.status ?? null,
479
494
  pid: state?.pid ?? null,
495
+ supervisor_start_error: state?.startError ?? null,
480
496
  install_dir: installed?.installDir ?? null,
481
497
  uis: toUisWireShape(installed?.uis),
482
498
  management_url: managementUrlByShort.get(short) ?? null,
package/src/cli.ts CHANGED
@@ -454,7 +454,11 @@ async function main(argv: string[]): Promise<number> {
454
454
  console.log(statusHelp());
455
455
  return 0;
456
456
  }
457
- return await status();
457
+ // Pass an empty `supervisor` block so `status` takes the Phase 3c
458
+ // dual-dispatch: on a box with a hub unit installed it reads the platform
459
+ // manager + the running supervisor; on a legacy detached box it falls back
460
+ // to the pidfile readout (design §6.4). Tests drive the seams directly.
461
+ return await status({ supervisor: {} });
458
462
 
459
463
  case "expose": {
460
464
  const hubExtract = extractHubOrigin(rest);
@@ -624,7 +628,13 @@ async function main(argv: string[]): Promise<number> {
624
628
  console.error(`parachute start: ${hubExtract.error}`);
625
629
  return 1;
626
630
  }
627
- const startOpts = hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {};
631
+ // `supervisor: {}` opts into the Phase 3b dual-dispatch: on a box with a
632
+ // hub unit installed, drive the running supervisor; on a legacy detached
633
+ // box (no unit), fall through to the unchanged detached path (design §3.3).
634
+ const startOpts = {
635
+ supervisor: {},
636
+ ...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
637
+ };
628
638
  return await start(hubExtract.rest[0], startOpts);
629
639
  }
630
640
 
@@ -633,7 +643,7 @@ async function main(argv: string[]): Promise<number> {
633
643
  console.log(stopHelp());
634
644
  return 0;
635
645
  }
636
- return await stop(rest[0]);
646
+ return await stop(rest[0], { supervisor: {} });
637
647
  }
638
648
 
639
649
  case "restart": {
@@ -641,7 +651,7 @@ async function main(argv: string[]): Promise<number> {
641
651
  console.log(restartHelp());
642
652
  return 0;
643
653
  }
644
- return await restart(rest[0]);
654
+ return await restart(rest[0], { supervisor: {} });
645
655
  }
646
656
 
647
657
  case "upgrade": {
@@ -0,0 +1,273 @@
1
+ import {
2
+ type ManagedUnit,
3
+ type ManagedUnitDeps,
4
+ type ManagedUnitInstallResult,
5
+ type ManagedUnitMessages,
6
+ type ManagedUnitRemoveResult,
7
+ type ServiceCommandResult,
8
+ defaultManagedUnitDeps,
9
+ installManagedUnit,
10
+ launchdPlistPathForLabel,
11
+ removeManagedUnit,
12
+ renderManagedLaunchdPlist,
13
+ renderManagedSystemdUnit,
14
+ systemdUnitPathForName,
15
+ } from "../managed-unit.ts";
16
+
17
+ /**
18
+ * Reboot-persistent cloudflared connector.
19
+ *
20
+ * Pre-0.6.2 `parachute expose public --cloudflare` spawned the connector as a
21
+ * bare detached background process (`Bun.spawn(...).unref()`), which dies on
22
+ * reboot — the operator had to re-run the expose command every time the box
23
+ * restarted. This module installs a per-tunnel OS service that runs the same
24
+ * `cloudflared tunnel --config <path> run` command on boot, so the connector
25
+ * survives reboots.
26
+ *
27
+ * Platform shapes:
28
+ * - macOS → a launchd LaunchAgent plist at
29
+ * `~/Library/LaunchAgents/computer.parachute.cloudflared.<tunnelName>.plist`
30
+ * (RunAtLoad + KeepAlive), bootstrapped into the per-user GUI domain. No
31
+ * sudo: a LaunchAgent runs as the logged-in user.
32
+ * - Linux (non-root) → a systemd *user* unit at
33
+ * `~/.config/systemd/user/parachute-cloudflared-<tunnelName>.service`,
34
+ * `systemctl --user enable --now`, plus a best-effort
35
+ * `loginctl enable-linger $USER` so the unit runs without an active login.
36
+ * - Linux (root) → a systemd *system* unit at
37
+ * `/etc/systemd/system/parachute-cloudflared-<tunnelName>.service`,
38
+ * `systemctl enable --now`. No linger needed — system units run on boot.
39
+ *
40
+ * As of Phase 2b of the hub-as-supervisor unification, the per-platform
41
+ * install/remove/render machinery lives in `src/managed-unit.ts` as a reusable
42
+ * `ManagedUnit` abstraction (so the hub unit can reuse it — design §4.1). This
43
+ * module is now a thin connector-specific layer over that machinery: it builds
44
+ * the connector's `ManagedUnit` descriptor (empty env, no crash-loop ceiling —
45
+ * which keeps its rendered output BYTE-IDENTICAL to the pre-extraction code, the
46
+ * hard constraint since the connector is live on production) and supplies the
47
+ * connector-flavored install/remove messages. The public exports below keep
48
+ * their original signatures so `expose-cloudflare.ts` and the connector tests
49
+ * need no behavioral change.
50
+ *
51
+ * Everything is behind an injectable deps seam (re-exported `ConnectorServiceDeps`
52
+ * = the generalized `ManagedUnitDeps`) so tests drive the install/remove without
53
+ * touching real launchctl/systemctl or the operator's home directory.
54
+ *
55
+ * Service name keyed by the same per-host tunnel name the 0.6.1 work derives
56
+ * (`deriveTunnelName`), so install / remove always target the connector for
57
+ * exactly one tunnel and the expose off / legacy-sweep paths can tear it down.
58
+ */
59
+
60
+ /** Synchronous command result from the injected service runner. */
61
+ export type { ServiceCommandResult };
62
+
63
+ /**
64
+ * Injectable side-effect seam for the connector-service module. This is the
65
+ * generalized `ManagedUnitDeps` re-exported under the connector's historical
66
+ * name so `expose-cloudflare.ts` + the connector tests are unchanged.
67
+ */
68
+ export type ConnectorServiceDeps = ManagedUnitDeps;
69
+
70
+ export const defaultServiceDeps: ConnectorServiceDeps = defaultManagedUnitDeps;
71
+
72
+ /** Reverse-DNS prefix for the launchd label + plist filename. */
73
+ const LAUNCHD_LABEL_PREFIX = "computer.parachute.cloudflared";
74
+ /** systemd unit name prefix. */
75
+ const SYSTEMD_UNIT_PREFIX = "parachute-cloudflared-";
76
+ /** Provenance comment baked into every rendered connector unit file. */
77
+ const CONNECTOR_HEADER = "Generated by parachute expose public --cloudflare — do not edit by hand.";
78
+
79
+ /** launchd label for a tunnel (also the plist basename, minus `.plist`). */
80
+ export function launchdLabel(tunnelName: string): string {
81
+ return `${LAUNCHD_LABEL_PREFIX}.${tunnelName}`;
82
+ }
83
+
84
+ /** launchd plist path under the user's LaunchAgents dir. */
85
+ export function launchdPlistPath(tunnelName: string, home: string): string {
86
+ return launchdPlistPathForLabel(launchdLabel(tunnelName), home);
87
+ }
88
+
89
+ /** systemd unit name (with `.service` suffix). */
90
+ export function systemdUnitName(tunnelName: string): string {
91
+ return `${SYSTEMD_UNIT_PREFIX}${tunnelName}.service`;
92
+ }
93
+
94
+ /** systemd unit path — user-level under $HOME, system-level under /etc. */
95
+ export function systemdUnitPath(tunnelName: string, home: string, root: boolean): string {
96
+ return systemdUnitPathForName(systemdUnitName(tunnelName), home, root);
97
+ }
98
+
99
+ /**
100
+ * Build the connector's `ManagedUnit` descriptor. Empty `env` + no `crashLoop`
101
+ * are what keep the rendered output byte-identical to the pre-extraction code.
102
+ * `runAsInvokingUserOnSystemUnit: true` reproduces the connector's historical
103
+ * `User=` pin on the root/system unit.
104
+ */
105
+ function connectorUnit(opts: {
106
+ tunnelName: string;
107
+ cloudflaredPath: string;
108
+ configPath: string;
109
+ logPath: string;
110
+ }): ManagedUnit {
111
+ return {
112
+ launchdLabel: launchdLabel(opts.tunnelName),
113
+ systemdUnitName: systemdUnitName(opts.tunnelName),
114
+ headerComment: CONNECTOR_HEADER,
115
+ systemdDescription: `Parachute Cloudflare connector (${opts.tunnelName})`,
116
+ execStart: [opts.cloudflaredPath, "tunnel", "--config", opts.configPath, "run"],
117
+ env: {},
118
+ logPath: opts.logPath,
119
+ runAsInvokingUserOnSystemUnit: true,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Render the launchd LaunchAgent plist. Thin wrapper over the generalized
125
+ * renderer with the connector's descriptor — preserved signature so existing
126
+ * callers/tests are unchanged.
127
+ */
128
+ export function renderLaunchdPlist(opts: {
129
+ tunnelName: string;
130
+ cloudflaredPath: string;
131
+ configPath: string;
132
+ logPath: string;
133
+ }): string {
134
+ return renderManagedLaunchdPlist(connectorUnit(opts));
135
+ }
136
+
137
+ /**
138
+ * Render a systemd unit. Thin wrapper over the generalized renderer — preserved
139
+ * signature (`{ root, userName }` threaded through) so existing callers/tests
140
+ * are unchanged.
141
+ */
142
+ export function renderSystemdUnit(opts: {
143
+ tunnelName: string;
144
+ cloudflaredPath: string;
145
+ configPath: string;
146
+ logPath: string;
147
+ root: boolean;
148
+ userName: string;
149
+ }): string {
150
+ const { root, userName } = opts;
151
+ return renderManagedSystemdUnit(connectorUnit(opts), { root, userName });
152
+ }
153
+
154
+ export interface InstallResult {
155
+ /**
156
+ * "installed" → an OS service now owns the connector (survives reboot).
157
+ * "fallback" → the service tool was unavailable / failed; the caller should
158
+ * fall back to the transient `proc.unref()` spawn (does NOT survive reboot).
159
+ */
160
+ outcome: "installed" | "fallback";
161
+ /** Which init system installed the service (when outcome === "installed"). */
162
+ kind?: "launchd" | "systemd-user" | "systemd-system" | "unsupported";
163
+ /** Path of the written service file (when outcome === "installed"). */
164
+ servicePath?: string;
165
+ /** Human-readable lines for the CLI to print (warnings, hints). */
166
+ messages: string[];
167
+ }
168
+
169
+ export interface ConnectorServiceOpts {
170
+ tunnelName: string;
171
+ configPath: string;
172
+ logPath: string;
173
+ deps?: ConnectorServiceDeps;
174
+ }
175
+
176
+ /** Connector-flavored install/remove messages, supplied to the generalized installer. */
177
+ function connectorMessages(): ManagedUnitMessages {
178
+ const lingerWarning =
179
+ "Note: could not enable lingering (loginctl enable-linger) — the connector will run while you're logged in but may not start on a cold boot before login. To run on cold boot without an active login, re-run this command as root (installs a system unit that needs no linger).";
180
+ return {
181
+ launchctlMissing: "launchctl not found; using a transient connector (won't survive a reboot).",
182
+ systemctlMissing: "systemctl not found; using a transient connector (won't survive a reboot).",
183
+ lingerWarning,
184
+ writeFailedPrefix:
185
+ "Failed to write service file; using a transient connector (won't survive a reboot)",
186
+ launchctlLoadFailedPrefix:
187
+ "launchctl could not load the connector service; using a transient connector (won't survive a reboot)",
188
+ daemonReloadFailedPrefix:
189
+ "systemctl daemon-reload failed; using a transient connector (won't survive a reboot)",
190
+ enableFailedPrefix:
191
+ "systemctl enable --now failed; using a transient connector (won't survive a reboot)",
192
+ // The connector always installs with start:true, so the `started` param these
193
+ // callbacks receive is unused here — the message is always the "starts now" variant.
194
+ launchdInstalled: (label, _started) =>
195
+ `Installed launchd LaunchAgent ${label} — the connector now starts on login/boot.`,
196
+ systemdInstalled: (unitName, root, _started) =>
197
+ `Installed systemd ${root ? "system" : "user"} unit ${unitName} — the connector now starts on boot.`,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Install (or refresh) the reboot-persistent connector service for one tunnel
203
+ * and start it. Idempotent: re-installing overwrites the service file and
204
+ * re-loads it, so re-`expose` of the same hostname converges on exactly one
205
+ * managed connector.
206
+ *
207
+ * Graceful fallback: if the platform's service tool is missing or any step
208
+ * fails, returns `{ outcome: "fallback", messages }` WITHOUT throwing — the
209
+ * caller then spawns the transient connector and warns it won't survive a
210
+ * reboot. We never hard-fail the expose because the service install didn't take.
211
+ */
212
+ export function installConnectorService(opts: ConnectorServiceOpts): InstallResult {
213
+ const deps = opts.deps ?? defaultServiceDeps;
214
+ const cloudflaredPath = deps.which("cloudflared");
215
+ if (!cloudflaredPath) {
216
+ return {
217
+ outcome: "fallback",
218
+ messages: ["Could not resolve the cloudflared binary path; skipping boot-service install."],
219
+ };
220
+ }
221
+ if (deps.platform !== "darwin" && deps.platform !== "linux") {
222
+ return {
223
+ outcome: "fallback",
224
+ messages: [
225
+ `Boot-persistent connector isn't supported on ${deps.platform}; using a transient connector.`,
226
+ ],
227
+ };
228
+ }
229
+
230
+ const result: ManagedUnitInstallResult = installManagedUnit({
231
+ unit: connectorUnit({
232
+ tunnelName: opts.tunnelName,
233
+ cloudflaredPath,
234
+ configPath: opts.configPath,
235
+ logPath: opts.logPath,
236
+ }),
237
+ deps,
238
+ messages: connectorMessages(),
239
+ // start: true (default) — the connector's behavior is unchanged.
240
+ });
241
+ return result;
242
+ }
243
+
244
+ export interface RemoveResult {
245
+ /** True when a service file was found + removed (best-effort tool teardown ran). */
246
+ removed: boolean;
247
+ messages: string[];
248
+ }
249
+
250
+ /**
251
+ * Stop + remove the reboot-persistent connector service for one tunnel.
252
+ * Idempotent + best-effort: a missing service file is a no-op; tool failures
253
+ * never throw (the expose-off path must always succeed at clearing state even
254
+ * if the OS service tool hiccups). Mirrors `installConnectorService`'s seam.
255
+ *
256
+ * Called by `exposeCloudflareOff` (and the legacy-tunnel sweep) so tearing down
257
+ * a tunnel also tears down its boot service — otherwise the service would
258
+ * resurrect a dead connector on the next reboot.
259
+ */
260
+ export function removeConnectorService(opts: {
261
+ tunnelName: string;
262
+ deps?: ConnectorServiceDeps;
263
+ }): RemoveResult {
264
+ const deps = opts.deps ?? defaultServiceDeps;
265
+ const result: ManagedUnitRemoveResult = removeManagedUnit({
266
+ launchdLabel: launchdLabel(opts.tunnelName),
267
+ systemdUnitName: systemdUnitName(opts.tunnelName),
268
+ deps,
269
+ removedLaunchdMessage: (label) => `Removed launchd LaunchAgent ${label}.`,
270
+ removedSystemdMessage: (unitName) => `Removed systemd unit ${unitName}.`,
271
+ });
272
+ return result;
273
+ }
@@ -25,6 +25,14 @@ export interface CloudflaredTunnelRecord {
25
25
  startedAt: string;
26
26
  /** Absolute path to the cloudflared config.yml driving this tunnel. */
27
27
  configPath: string;
28
+ /**
29
+ * True when a reboot-persistent OS service (launchd/systemd) owns this
30
+ * connector (0.6.2). Drives the off-path to remove the service (not just
31
+ * SIGTERM the pid — a still-enabled service would otherwise restart the
32
+ * connector it just killed). Optional + defaults false so pre-0.6.2 state
33
+ * files (and the transient-fallback path) validate + read as unmanaged.
34
+ */
35
+ serviceManaged?: boolean;
28
36
  }
29
37
 
30
38
  /**
@@ -64,7 +72,7 @@ function validateRecord(raw: unknown, path: string): CloudflaredTunnelRecord {
64
72
  throw new CloudflaredStateError(`${path}: tunnel record must be an object`);
65
73
  }
66
74
  const r = raw as Record<string, unknown>;
67
- return {
75
+ const record: CloudflaredTunnelRecord = {
68
76
  pid: requirePositiveInt(r, "pid", path),
69
77
  tunnelUuid: requireString(r, "tunnelUuid", path),
70
78
  tunnelName: requireString(r, "tunnelName", path),
@@ -72,6 +80,10 @@ function validateRecord(raw: unknown, path: string): CloudflaredTunnelRecord {
72
80
  startedAt: requireString(r, "startedAt", path),
73
81
  configPath: requireString(r, "configPath", path),
74
82
  };
83
+ // Optional — present from 0.6.2 onward. A non-boolean (or absent) value
84
+ // reads as unmanaged so legacy state files keep validating.
85
+ if (r.serviceManaged === true) record.serviceManaged = true;
86
+ return record;
75
87
  }
76
88
 
77
89
  function validate(raw: unknown, path: string): CloudflaredState {