@openparachute/hub 0.6.1-rc.4 → 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.
@@ -1,7 +1,19 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, openSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
- import { DEFAULT_TUNNEL_NAME, cloudflaredPathsFor, writeConfig } from "../cloudflare/config.ts";
4
+ import {
5
+ DEFAULT_TUNNEL_NAME,
6
+ cloudflaredPathsFor,
7
+ deriveTunnelName,
8
+ writeConfig,
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";
5
17
  import {
6
18
  DEFAULT_CLOUDFLARED_HOME,
7
19
  cloudflaredInstallHint,
@@ -10,6 +22,7 @@ import {
10
22
  } from "../cloudflare/detect.ts";
11
23
  import {
12
24
  CLOUDFLARED_STATE_PATH,
25
+ type CloudflaredState,
13
26
  type CloudflaredTunnelRecord,
14
27
  clearCloudflaredState,
15
28
  findTunnelRecord,
@@ -249,6 +262,27 @@ export interface ExposeCloudflareOpts {
249
262
  * `defaultConnectorPids` (a filtered `pgrep -fl cloudflared`).
250
263
  */
251
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;
252
286
  /**
253
287
  * Resolve a hostname to its addresses, for the post-route DNS self-diagnosis
254
288
  * (hub#487). Returns the resolved IPs (empty when NXDOMAIN / not yet live).
@@ -269,9 +303,12 @@ export interface ExposeCloudflareOpts {
269
303
  */
270
304
  exposeStatePath?: string;
271
305
  /**
272
- * Tunnel name targeted by this invocation. Defaults to `parachute` —
273
- * the canonical single-tunnel name. Override to run multiple tunnels on
274
- * one box (#32).
306
+ * Tunnel name targeted by this invocation. The up-path defaults to a
307
+ * per-hostname derived name (`deriveTunnelName(hostname)`) so each machine
308
+ * gets its own tunnel and account-wide tunnels don't collide across boxes
309
+ * (#491). Override to pin a specific name (e.g. multiple tunnels on one
310
+ * box, #32). The off-path resolves the name from `cloudflared-state.json`
311
+ * when omitted (it has no hostname to derive from).
275
312
  */
276
313
  tunnelName?: string;
277
314
  /**
@@ -346,6 +383,12 @@ interface Resolved {
346
383
  alive: AliveFn;
347
384
  kill: KillFn;
348
385
  connectorPids: ConnectorPidsFn;
386
+ installService: (args: {
387
+ tunnelName: string;
388
+ configPath: string;
389
+ logPath: string;
390
+ }) => InstallResult;
391
+ removeService: (args: { tunnelName: string }) => RemoveResult;
349
392
  resolveHost: ResolveHostFn;
350
393
  log: (line: string) => void;
351
394
  manifestPath: string;
@@ -366,8 +409,20 @@ interface Resolved {
366
409
  restartService: (short: string) => Promise<number>;
367
410
  }
368
411
 
369
- function resolve(opts: ExposeCloudflareOpts): Resolved {
370
- const tunnelName = opts.tunnelName ?? DEFAULT_TUNNEL_NAME;
412
+ /**
413
+ * Resolve options into the fully-defaulted `Resolved` shape.
414
+ *
415
+ * `tunnelNameDefault` is the fallback tunnel name when the caller didn't pass
416
+ * an explicit `opts.tunnelName`. The up-path passes `deriveTunnelName(hostname)`
417
+ * so each machine/hostname gets its OWN dedicated tunnel (#491) — sharing one
418
+ * account-wide tunnel across boxes collides their connectors. An explicit
419
+ * `--tunnel-name` always wins (operators can override). The off-path has no
420
+ * hostname to derive from, so it resolves the name from state before calling
421
+ * in (see `exposeCloudflareOff`) and only relies on this default as a last
422
+ * resort.
423
+ */
424
+ function resolve(opts: ExposeCloudflareOpts, tunnelNameDefault: string): Resolved {
425
+ const tunnelName = opts.tunnelName ?? tunnelNameDefault;
371
426
  const configDir = opts.configDir ?? CONFIG_DIR;
372
427
  // Derive per-tunnel config/log paths from the *resolved* configDir, not the
373
428
  // real `CONFIG_DIR`. When a test threads a tmp `configDir` but omits explicit
@@ -387,6 +442,39 @@ function resolve(opts: ExposeCloudflareOpts): Resolved {
387
442
  // gets the real `pgrep` sweep + DNS diagnosis.
388
443
  connectorPids:
389
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: [] })),
390
478
  resolveHost:
391
479
  opts.resolveHost ??
392
480
  (opts.spawner === undefined ? defaultResolveHost : async () => ["104.16.0.1"]),
@@ -489,7 +577,12 @@ export async function exposeCloudflareUp(
489
577
  hostname: string,
490
578
  opts: ExposeCloudflareOpts = {},
491
579
  ): Promise<number> {
492
- const r = resolve(opts);
580
+ // Default to a per-hostname dedicated tunnel (#491). An explicit
581
+ // `--tunnel-name` still wins (handled inside `resolve`). Deriving from the
582
+ // hostname keeps re-expose idempotent (same hostname → same name → reuse the
583
+ // tunnel created last time) and stops two machines from colliding on the
584
+ // single account-wide `"parachute"` tunnel.
585
+ const r = resolve(opts, deriveTunnelName(hostname));
493
586
 
494
587
  if (!isValidTunnelName(r.tunnelName)) {
495
588
  r.log(
@@ -591,6 +684,9 @@ export async function exposeCloudflareUp(
591
684
  return reportCloudflaredError(err, r.log);
592
685
  }
593
686
  r.log(`✓ Created tunnel ${tunnel.id}`);
687
+ r.log(
688
+ " Each machine gets its own dedicated tunnel — you don't need to run `cloudflared tunnel create` separately; expose does it.",
689
+ );
594
690
  } else {
595
691
  r.log(`✓ Reusing existing tunnel "${r.tunnelName}" (${tunnel.id})`);
596
692
  }
@@ -672,10 +768,88 @@ export async function exposeCloudflareUp(
672
768
  }
673
769
  }
674
770
 
675
- const pid = r.spawner.spawn(
676
- ["cloudflared", "tunnel", "--config", r.configPath, "run"],
677
- r.logPath,
678
- );
771
+ // Legacy shared-tunnel migration sweep (#491). Aaron's running boxes were
772
+ // exposed under the old single account-wide `"parachute"` tunnel; the bug
773
+ // was that a second box reusing that name collided connectors. Now that the
774
+ // default is per-hostname, a box upgrading and re-exposing will create/route
775
+ // a NEW dedicated tunnel — but the OLD `"parachute"` connector is still
776
+ // running, still registered on the shared tunnel, still able to pick up
777
+ // load-balanced requests for OTHER hosts. Kill it + drop its state record so
778
+ // the box self-heals immediately on this expose instead of at the next
779
+ // reboot. Only fires when (a) we actually migrated AWAY from "parachute"
780
+ // (the new derived name differs) and (b) a live legacy record exists.
781
+ // `routeDns` above already used `--overwrite-dns`, so this hostname's CNAME
782
+ // has been repointed to the new tunnel — the legacy connector can't serve it
783
+ // anymore regardless; this just stops it from serving anyone else's.
784
+ let migratedState = stateBefore;
785
+ if (r.tunnelName !== DEFAULT_TUNNEL_NAME) {
786
+ const legacy = findTunnelRecord(stateBefore, DEFAULT_TUNNEL_NAME);
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);
794
+ if (r.alive(legacy.pid)) {
795
+ try {
796
+ r.kill(legacy.pid, "SIGTERM");
797
+ } catch {
798
+ // Already gone between read and kill — fine; we drop the record below.
799
+ }
800
+ r.log(
801
+ `Stopped legacy shared-tunnel connector (migrated ${hostname} to dedicated tunnel ${r.tunnelName}).`,
802
+ );
803
+ }
804
+ // Drop the legacy shared-tunnel record whether or not its connector was
805
+ // still alive. A dead record would otherwise linger across re-exposes
806
+ // until the next `off`; clearing it here keeps state tidy (#491 review).
807
+ migratedState = withoutTunnelRecord(stateBefore, DEFAULT_TUNNEL_NAME);
808
+ }
809
+ }
810
+
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
+ }
679
853
 
680
854
  const record: CloudflaredTunnelRecord = {
681
855
  pid,
@@ -684,8 +858,11 @@ export async function exposeCloudflareUp(
684
858
  hostname,
685
859
  startedAt: r.now().toISOString(),
686
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 } : {}),
687
864
  };
688
- writeCloudflaredState(withTunnelRecord(stateBefore, record), r.statePath);
865
+ writeCloudflaredState(withTunnelRecord(migratedState, record), r.statePath);
689
866
 
690
867
  // Persist the shared cross-provider expose record. Without this, the
691
868
  // Tailscale path was the only one writing expose-state.json — so after a
@@ -763,12 +940,29 @@ export async function exposeCloudflareUp(
763
940
 
764
941
  r.log("");
765
942
  r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
943
+ r.log(` Tunnel: ${r.tunnelName} (dedicated to this machine)`);
766
944
  r.log(` Open: ${baseUrl}/`);
767
945
  r.log(` Admin: ${baseUrl}/admin/`);
768
946
  r.log(` Vault: ${vaultUrl}`);
769
947
  r.log(` OAuth: ${hubOrigin}`);
770
948
  r.log(` Logs: ${r.logPath}`);
771
949
  r.log("");
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
+ }
965
+ r.log("");
772
966
  r.log("Point a claude.ai / ChatGPT connector at:");
773
967
  r.log(` ${vaultUrl}`);
774
968
  printAuthGuidance(r.log, vaultUrl);
@@ -784,30 +978,37 @@ export async function exposeCloudflareUp(
784
978
  return 0;
785
979
  }
786
980
 
787
- export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Promise<number> {
788
- const r = resolve(opts);
789
- const stateBefore = readCloudflaredState(r.statePath);
790
- const record = findTunnelRecord(stateBefore, r.tunnelName);
791
- if (!record) {
792
- if (stateBefore && Object.keys(stateBefore.tunnels).length > 0) {
793
- const others = listTunnelRecords(stateBefore)
794
- .map((t) => t.tunnelName)
795
- .join(", ");
796
- r.log(
797
- `No Cloudflare exposure recorded for tunnel "${r.tunnelName}". Other tunnels: ${others}.`,
798
- );
799
- } else {
800
- r.log("No Cloudflare exposure recorded. Nothing to tear down.");
801
- }
802
- return 0;
803
- }
981
+ /**
982
+ * Tear down ONE tunnel record: SIGTERM its connector, sweep any orphan
983
+ * connectors for it (hub#487), drop its state record, and emit the
984
+ * reuse-hint copy. Pure-ish over `r` + the current state: returns the state
985
+ * with the record removed (or undefined when that empties it) plus an exit
986
+ * code, so the caller commits the disk write once after tearing down one or
987
+ * many tunnels. The connector kill is non-fatal-on-already-gone, fatal only
988
+ * when SIGTERM itself errors on a live pid.
989
+ */
990
+ function teardownOne(
991
+ r: Resolved,
992
+ state: CloudflaredState | undefined,
993
+ record: CloudflaredTunnelRecord,
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);
804
1005
  if (r.alive(record.pid)) {
805
1006
  try {
806
1007
  r.kill(record.pid, "SIGTERM");
807
- r.log(`✓ Stopped cloudflared (pid ${record.pid}).`);
1008
+ r.log(`✓ Stopped cloudflared (pid ${record.pid}, tunnel "${record.tunnelName}").`);
808
1009
  } catch (err) {
809
1010
  r.log(`✗ Failed to stop cloudflared: ${err instanceof Error ? err.message : String(err)}`);
810
- return 1;
1011
+ return { state, code: 1 };
811
1012
  }
812
1013
  } else {
813
1014
  r.log(`cloudflared (pid ${record.pid}) wasn't running; clearing stale state.`);
@@ -824,9 +1025,80 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
824
1025
  // Already gone between probe and kill — fine.
825
1026
  }
826
1027
  }
827
- const stateAfter = withoutTunnelRecord(stateBefore, r.tunnelName);
828
- if (stateAfter) {
829
- writeCloudflaredState(stateAfter, r.statePath);
1028
+ r.log(` ${record.hostname} is no longer reachable through this machine.`);
1029
+ r.log(
1030
+ ` Tunnel "${record.tunnelName}" (${record.tunnelUuid}) remains defined in Cloudflare; re-running`,
1031
+ );
1032
+ // Only suggest `--tunnel-name` for a custom name. The auto-derived name
1033
+ // (and the legacy shared "parachute" name) need no flag — re-running with
1034
+ // just --domain re-derives the per-hostname name (and migrates a legacy
1035
+ // record off the shared tunnel), which is exactly what we want.
1036
+ const isAutoName =
1037
+ record.tunnelName === deriveTunnelName(record.hostname) ||
1038
+ record.tunnelName === DEFAULT_TUNNEL_NAME;
1039
+ r.log(
1040
+ ` \`parachute expose public --cloudflare --domain ${record.hostname}${isAutoName ? "" : ` --tunnel-name ${record.tunnelName}`}\` reuses it.`,
1041
+ );
1042
+ return { state: withoutTunnelRecord(state, record.tunnelName), code: 0 };
1043
+ }
1044
+
1045
+ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Promise<number> {
1046
+ // The off-path has no hostname to derive a name from. When `--tunnel-name`
1047
+ // is set we use it; otherwise we resolve from cloudflared-state.json (below).
1048
+ // `DEFAULT_TUNNEL_NAME` is only the inert `resolve` fallback here — the
1049
+ // state-driven branch never relies on it.
1050
+ const r = resolve(opts, DEFAULT_TUNNEL_NAME);
1051
+ const stateBefore = readCloudflaredState(r.statePath);
1052
+ const records = listTunnelRecords(stateBefore);
1053
+
1054
+ // Decide which records to tear down.
1055
+ // - explicit `--tunnel-name` → exactly that one (or a not-found message).
1056
+ // - no flag, 0 tunnels → nothing to do.
1057
+ // - no flag, exactly 1 → that one.
1058
+ // - no flag, ≥2 → ALL of them. A bare `expose public
1059
+ // --cloudflare off` means "stop all public Cloudflare exposure on this
1060
+ // machine"; tearing down only one would leave the box half-exposed with
1061
+ // no obvious signal which tunnel survived.
1062
+ let targets: CloudflaredTunnelRecord[];
1063
+ if (opts.tunnelName !== undefined) {
1064
+ const record = findTunnelRecord(stateBefore, r.tunnelName);
1065
+ if (!record) {
1066
+ if (records.length > 0) {
1067
+ const others = records.map((t) => t.tunnelName).join(", ");
1068
+ r.log(
1069
+ `No Cloudflare exposure recorded for tunnel "${r.tunnelName}". Other tunnels: ${others}.`,
1070
+ );
1071
+ } else {
1072
+ r.log("No Cloudflare exposure recorded. Nothing to tear down.");
1073
+ }
1074
+ return 0;
1075
+ }
1076
+ targets = [record];
1077
+ } else {
1078
+ if (records.length === 0) {
1079
+ r.log("No Cloudflare exposure recorded. Nothing to tear down.");
1080
+ return 0;
1081
+ }
1082
+ if (records.length > 1) {
1083
+ r.log(
1084
+ `Tearing down all ${records.length} recorded Cloudflare tunnels: ${records
1085
+ .map((t) => t.tunnelName)
1086
+ .join(", ")}.`,
1087
+ );
1088
+ }
1089
+ targets = records;
1090
+ }
1091
+
1092
+ let state = stateBefore;
1093
+ let failed = false;
1094
+ for (const record of targets) {
1095
+ const result = teardownOne(r, state, record);
1096
+ state = result.state;
1097
+ if (result.code !== 0) failed = true;
1098
+ }
1099
+
1100
+ if (state) {
1101
+ writeCloudflaredState(state, r.statePath);
830
1102
  } else {
831
1103
  clearCloudflaredState(r.statePath);
832
1104
  }
@@ -834,17 +1106,10 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
834
1106
  // downstream consumers stop resolving the now-dead public URL (mirrors the
835
1107
  // up-path write above + the Tailscale off-path's expose-state teardown). When
836
1108
  // other tunnels survive we leave it — a later off for the last one clears it.
837
- if (!stateAfter) {
1109
+ if (!state) {
838
1110
  clearExposeState(r.exposeStatePath);
839
1111
  }
840
- r.log(` ${record.hostname} is no longer reachable through this machine.`);
841
- r.log(
842
- ` Tunnel "${record.tunnelName}" (${record.tunnelUuid}) remains defined in Cloudflare; re-running`,
843
- );
844
- r.log(
845
- ` \`parachute expose public --cloudflare --domain ${record.hostname}${record.tunnelName === DEFAULT_TUNNEL_NAME ? "" : ` --tunnel-name ${record.tunnelName}`}\` reuses it.`,
846
- );
847
- return 0;
1112
+ return failed ? 1 : 0;
848
1113
  }
849
1114
 
850
1115
  function reportCloudflaredError(err: unknown, log: (line: string) => void): number {
package/src/help.ts CHANGED
@@ -369,8 +369,13 @@ Flags:
369
369
  --domain <hostname> fully-qualified hostname to route through the tunnel
370
370
  (e.g. vault.example.com). The apex must be a zone on
371
371
  your Cloudflare account.
372
- --tunnel-name <name> Cloudflare tunnel name (default: \`parachute\`).
373
- Use to coexist multiple named tunnels on one box.
372
+ --tunnel-name <name> Cloudflare tunnel name. Defaults to a per-hostname
373
+ name (e.g. vault.example.com parachute-vault-example-com)
374
+ so each machine gets its OWN dedicated tunnel —
375
+ Cloudflare tunnels are account-wide, and sharing one
376
+ across machines collides their connectors. You don't
377
+ need to create the tunnel yourself; expose does it.
378
+ Override only to pin a specific name.
374
379
  --skip-provider-check bypass non-TTY auto-detect, default to Tailscale
375
380
  Funnel as before. Intended for CI / scripts whose
376
381
  environment is already pre-flighted.