@openparachute/hub 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,13 @@ import {
7
7
  deriveTunnelName,
8
8
  writeConfig,
9
9
  } from "../cloudflare/config.ts";
10
+ import {
11
+ type ConnectorServiceDeps,
12
+ type InstallResult,
13
+ type RemoveResult,
14
+ installConnectorService,
15
+ removeConnectorService,
16
+ } from "../cloudflare/connector-service.ts";
10
17
  import {
11
18
  DEFAULT_CLOUDFLARED_HOME,
12
19
  cloudflaredInstallHint,
@@ -255,6 +262,27 @@ export interface ExposeCloudflareOpts {
255
262
  * `defaultConnectorPids` (a filtered `pgrep -fl cloudflared`).
256
263
  */
257
264
  connectorPids?: ConnectorPidsFn;
265
+ /**
266
+ * Install/remove the reboot-persistent connector OS service (launchd on
267
+ * macOS, systemd on Linux). Injectable so tests drive the install/remove
268
+ * without touching real launchctl/systemctl or `~/Library/LaunchAgents`.
269
+ * Defaults: the up-path installs (falls back to a transient `proc.unref()`
270
+ * connector when the tool is absent); the off / legacy-sweep paths remove.
271
+ * Tests inject fakes to assert the generated service file + command sequence.
272
+ */
273
+ installService?: (args: {
274
+ tunnelName: string;
275
+ configPath: string;
276
+ logPath: string;
277
+ }) => InstallResult;
278
+ removeService?: (args: { tunnelName: string }) => RemoveResult;
279
+ /**
280
+ * Override the side-effect deps the default install/remove implementations
281
+ * use (platform, getuid, fs, run). Only consulted when `installService` /
282
+ * `removeService` aren't injected directly. Lets a test pin `platform` /
283
+ * `getuid` while still exercising the real install/remove logic.
284
+ */
285
+ connectorServiceDeps?: ConnectorServiceDeps;
258
286
  /**
259
287
  * Resolve a hostname to its addresses, for the post-route DNS self-diagnosis
260
288
  * (hub#487). Returns the resolved IPs (empty when NXDOMAIN / not yet live).
@@ -355,6 +383,12 @@ interface Resolved {
355
383
  alive: AliveFn;
356
384
  kill: KillFn;
357
385
  connectorPids: ConnectorPidsFn;
386
+ installService: (args: {
387
+ tunnelName: string;
388
+ configPath: string;
389
+ logPath: string;
390
+ }) => InstallResult;
391
+ removeService: (args: { tunnelName: string }) => RemoveResult;
358
392
  resolveHost: ResolveHostFn;
359
393
  log: (line: string) => void;
360
394
  manifestPath: string;
@@ -408,6 +442,39 @@ function resolve(opts: ExposeCloudflareOpts, tunnelNameDefault: string): Resolve
408
442
  // gets the real `pgrep` sweep + DNS diagnosis.
409
443
  connectorPids:
410
444
  opts.connectorPids ?? (opts.spawner === undefined ? defaultConnectorPids : () => []),
445
+ // Reboot-persistent connector seam. Defaulting policy mirrors
446
+ // `connectorPids`/`resolveHost`: when a test injects a stub `spawner` (and
447
+ // no explicit service seam), default to an inert "fallback" so existing
448
+ // stub-spawner suites keep exercising the transient-spawn path without
449
+ // touching real launchctl/systemctl. Production (no spawner override) gets
450
+ // the real install/remove. An explicit `installService`/`removeService`
451
+ // always wins; `connectorServiceDeps` lets a test pin platform/getuid
452
+ // while running the real install/remove logic.
453
+ installService:
454
+ opts.installService ??
455
+ (opts.spawner === undefined || opts.connectorServiceDeps !== undefined
456
+ ? (args) =>
457
+ installConnectorService({
458
+ ...args,
459
+ ...(opts.connectorServiceDeps !== undefined
460
+ ? { deps: opts.connectorServiceDeps }
461
+ : {}),
462
+ })
463
+ : () => ({
464
+ outcome: "fallback",
465
+ messages: [],
466
+ })),
467
+ removeService:
468
+ opts.removeService ??
469
+ (opts.spawner === undefined || opts.connectorServiceDeps !== undefined
470
+ ? (args) =>
471
+ removeConnectorService({
472
+ ...args,
473
+ ...(opts.connectorServiceDeps !== undefined
474
+ ? { deps: opts.connectorServiceDeps }
475
+ : {}),
476
+ })
477
+ : () => ({ removed: false, messages: [] })),
411
478
  resolveHost:
412
479
  opts.resolveHost ??
413
480
  (opts.spawner === undefined ? defaultResolveHost : async () => ["104.16.0.1"]),
@@ -718,6 +785,12 @@ export async function exposeCloudflareUp(
718
785
  if (r.tunnelName !== DEFAULT_TUNNEL_NAME) {
719
786
  const legacy = findTunnelRecord(stateBefore, DEFAULT_TUNNEL_NAME);
720
787
  if (legacy) {
788
+ // Remove any boot service for the legacy shared tunnel before killing its
789
+ // connector, so a service doesn't restart the connector we're migrating
790
+ // away from. Best-effort + idempotent (no-op when no service file exists,
791
+ // which is the common pre-0.6.2 case).
792
+ const legacyRemoval = r.removeService({ tunnelName: DEFAULT_TUNNEL_NAME });
793
+ for (const line of legacyRemoval.messages) r.log(line);
721
794
  if (r.alive(legacy.pid)) {
722
795
  try {
723
796
  r.kill(legacy.pid, "SIGTERM");
@@ -735,10 +808,48 @@ export async function exposeCloudflareUp(
735
808
  }
736
809
  }
737
810
 
738
- const pid = r.spawner.spawn(
739
- ["cloudflared", "tunnel", "--config", r.configPath, "run"],
740
- r.logPath,
741
- );
811
+ // Install the reboot-persistent connector OS service (launchd / systemd) so
812
+ // the connector survives a reboot — replacing the bare `proc.unref()` spawn
813
+ // that died on restart (0.6.2). When the service installs successfully it
814
+ // *becomes* the connector: it spawns + supervises `cloudflared tunnel run`,
815
+ // so we do NOT also leave a duplicate transient connector — exactly one
816
+ // process serves the config we wrote. We then discover the service-spawned
817
+ // connector's pid (by UUID/config match) to record in state so the next
818
+ // up-path's orphan sweep + the off-path's kill target the right process.
819
+ //
820
+ // Graceful fallback: if the service tool is missing / the install fails, we
821
+ // fall back to the prior transient `proc.unref()` spawn and warn it won't
822
+ // survive a reboot. The expose never hard-fails because the service didn't
823
+ // take.
824
+ const installResult = r.installService({
825
+ tunnelName: r.tunnelName,
826
+ configPath: r.configPath,
827
+ logPath: r.logPath,
828
+ });
829
+ for (const line of installResult.messages) r.log(line);
830
+
831
+ let pid: number;
832
+ let serviceManaged = false;
833
+ if (installResult.outcome === "installed") {
834
+ serviceManaged = true;
835
+ // The service (`enable --now` / `bootstrap` with RunAtLoad) already started
836
+ // the connector; discover its pid for the state record so the next up-path's
837
+ // orphan sweep + the off-path's kill target the right process. In practice
838
+ // the connector is up by the time we look here.
839
+ const managedPids = r.connectorPids(tunnel.id, r.configPath).filter((p) => r.alive(p));
840
+ if (managedPids.length > 0) {
841
+ pid = managedPids[0]!;
842
+ } else {
843
+ // Service is enabled (survives reboot) but we couldn't see its connector
844
+ // yet. Spawn a transient one so state carries a live pid + connectivity
845
+ // is immediate; the service takes over on the next reboot regardless.
846
+ pid = r.spawner.spawn(["cloudflared", "tunnel", "--config", r.configPath, "run"], r.logPath);
847
+ }
848
+ } else {
849
+ // Fallback: no boot service. Spawn the transient connector (won't survive
850
+ // a reboot — warned below).
851
+ pid = r.spawner.spawn(["cloudflared", "tunnel", "--config", r.configPath, "run"], r.logPath);
852
+ }
742
853
 
743
854
  const record: CloudflaredTunnelRecord = {
744
855
  pid,
@@ -747,6 +858,9 @@ export async function exposeCloudflareUp(
747
858
  hostname,
748
859
  startedAt: r.now().toISOString(),
749
860
  configPath: r.configPath,
861
+ // Only serialize the flag when true — keep the state JSON clean for the
862
+ // common (transient-fallback) case; absent reads as unmanaged.
863
+ ...(serviceManaged ? { serviceManaged: true } : {}),
750
864
  };
751
865
  writeCloudflaredState(withTunnelRecord(migratedState, record), r.statePath);
752
866
 
@@ -833,12 +947,21 @@ export async function exposeCloudflareUp(
833
947
  r.log(` OAuth: ${hubOrigin}`);
834
948
  r.log(` Logs: ${r.logPath}`);
835
949
  r.log("");
836
- // Honest reboot caveat: the connector is a detached background process, not
837
- // yet a launchd/systemd service, so it does NOT survive a reboot (durable
838
- // connector is a tracked follow-up). Re-running the same command brings it
839
- // back idempotently same hostname same dedicated tunnel.
840
- r.log("Note: the connector runs in the background but does not survive a reboot yet. After a");
841
- r.log(`reboot, re-run: parachute expose public --cloudflare --domain ${hostname}`);
950
+ if (serviceManaged) {
951
+ // The connector is now an OS service (launchd LaunchAgent on macOS, systemd
952
+ // unit on Linux), so it starts on boot no need to re-run expose after a
953
+ // reboot. `parachute expose public --cloudflare off` stops + removes it.
954
+ r.log("The connector runs on boot (via launchd/systemd) it survives reboots. Re-running");
955
+ r.log("the same command is still idempotent (same hostname → same dedicated tunnel).");
956
+ } else {
957
+ // Honest reboot caveat for the fallback path: the boot service couldn't be
958
+ // installed (tool missing / unsupported platform / install failed — see the
959
+ // warning printed above), so the connector is a detached background process
960
+ // that does NOT survive a reboot. Re-running the same command brings it back.
961
+ r.log("Note: a boot service couldn't be installed (see above), so the connector runs in the");
962
+ r.log("background but does NOT survive a reboot. After a reboot, re-run:");
963
+ r.log(` parachute expose public --cloudflare --domain ${hostname}`);
964
+ }
842
965
  r.log("");
843
966
  r.log("Point a claude.ai / ChatGPT connector at:");
844
967
  r.log(` ${vaultUrl}`);
@@ -869,6 +992,16 @@ function teardownOne(
869
992
  state: CloudflaredState | undefined,
870
993
  record: CloudflaredTunnelRecord,
871
994
  ): { state: CloudflaredState | undefined; code: number } {
995
+ // Remove the reboot-persistent connector service FIRST (when one owns this
996
+ // tunnel) so a still-enabled launchd/systemd service doesn't immediately
997
+ // restart the connector we SIGTERM below. `removeConnectorService` is
998
+ // idempotent + best-effort: a missing service file is a no-op (covers
999
+ // transient-fallback tunnels and pre-0.6.2 records, which carry no service).
1000
+ // We always attempt it — even when `serviceManaged` is unset — so a record
1001
+ // written before this field existed, or an out-of-band service file, still
1002
+ // gets swept. The removal stops the service, after which the SIGTERM is final.
1003
+ const removal = r.removeService({ tunnelName: record.tunnelName });
1004
+ for (const line of removal.messages) r.log(line);
872
1005
  if (r.alive(record.pid)) {
873
1006
  try {
874
1007
  r.kill(record.pid, "SIGTERM");