@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.
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import type { InstallResult, RemoveResult } from "../cloudflare/connector-service.ts";
5
6
  import {
6
7
  type CloudflaredTunnelRecord,
7
8
  findTunnelRecord,
@@ -119,12 +120,16 @@ describe("exposeCloudflareUp", () => {
119
120
  const env = makeEnv();
120
121
  try {
121
122
  const uuid = "2c1a7c7e-1234-5678-9abc-def012345678";
123
+ // Default tunnel name is now per-hostname (#491): vault.example.com →
124
+ // parachute-vault-example-com. Each machine gets its own dedicated tunnel
125
+ // so account-wide tunnels don't collide across boxes.
126
+ const derived = "parachute-vault-example-com";
122
127
  const { runner, calls } = queueRunner([
123
128
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version preflight
124
129
  { code: 0, stdout: "[]", stderr: "" }, // tunnel list (none yet)
125
130
  {
126
131
  code: 0,
127
- stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
132
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel ${derived} with id ${uuid}\n`,
128
133
  stderr: "",
129
134
  }, // tunnel create
130
135
  { code: 0, stdout: "", stderr: "" }, // route dns
@@ -158,14 +163,14 @@ describe("exposeCloudflareUp", () => {
158
163
  ]);
159
164
  expect(calls[0]!.cmd).toEqual(["cloudflared", "--version"]);
160
165
  expect(calls[1]!.cmd).toEqual(["cloudflared", "tunnel", "list", "--output", "json"]);
161
- expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create", "parachute"]);
166
+ expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create", derived]);
162
167
  expect(calls[3]!.cmd).toEqual([
163
168
  "cloudflared",
164
169
  "tunnel",
165
170
  "route",
166
171
  "dns",
167
172
  "--overwrite-dns",
168
- "parachute",
173
+ derived,
169
174
  "vault.example.com",
170
175
  ]);
171
176
  expect(seen[0]).toEqual(["cloudflared", "tunnel", "--config", env.configPath, "run"]);
@@ -174,10 +179,10 @@ describe("exposeCloudflareUp", () => {
174
179
  expect(state).toEqual({
175
180
  version: 2,
176
181
  tunnels: {
177
- parachute: {
182
+ [derived]: {
178
183
  pid: 42000,
179
184
  tunnelUuid: uuid,
180
- tunnelName: "parachute",
185
+ tunnelName: derived,
181
186
  hostname: "vault.example.com",
182
187
  startedAt: "2026-04-22T12:00:00.000Z",
183
188
  configPath: env.configPath,
@@ -248,6 +253,10 @@ describe("exposeCloudflareUp", () => {
248
253
  cloudflaredHome: env.cloudflaredHome,
249
254
  configDir: env.configDir,
250
255
  skipHub: true,
256
+ // Pin the legacy shared name so this test's substance (expose-state
257
+ // write) is isolated from the per-hostname-derivation change (#491);
258
+ // the queued runner output names the "parachute" tunnel.
259
+ tunnelName: "parachute",
251
260
  });
252
261
 
253
262
  expect(code).toBe(0);
@@ -419,6 +428,10 @@ describe("exposeCloudflareUp", () => {
419
428
  cloudflaredHome: env.cloudflaredHome,
420
429
  configDir: env.configDir,
421
430
  skipHub: true,
431
+ // Pin the legacy name: the queued `tunnel list` reports a "parachute"
432
+ // tunnel, so reuse only happens when we look it up by that name. The
433
+ // per-hostname default (#491) is exercised in the happy-path test.
434
+ tunnelName: "parachute",
422
435
  });
423
436
  expect(code).toBe(0);
424
437
  // No `tunnel create` — only list + route.
@@ -604,6 +617,10 @@ describe("exposeCloudflareUp", () => {
604
617
  cloudflaredHome: env.cloudflaredHome,
605
618
  configDir: env.configDir,
606
619
  skipHub: true,
620
+ // Pin the legacy name so reuse (queued "parachute" list) drives the
621
+ // route-dns failure under test, not a tunnel-create from the
622
+ // per-hostname default (#491).
623
+ tunnelName: "parachute",
607
624
  });
608
625
 
609
626
  expect(code).toBe(1);
@@ -656,6 +673,10 @@ describe("exposeCloudflareUp", () => {
656
673
  cloudflaredHome: env.cloudflaredHome,
657
674
  configDir: env.configDir,
658
675
  skipHub: true,
676
+ // Pin the legacy name so the prior record (keyed "parachute") matches
677
+ // this invocation's tunnel — the orphan-sweep behavior under test is
678
+ // independent of the per-hostname-derivation change (#491).
679
+ tunnelName: "parachute",
659
680
  });
660
681
 
661
682
  expect(code).toBe(0);
@@ -712,6 +733,9 @@ describe("exposeCloudflareUp", () => {
712
733
  cloudflaredHome: env.cloudflaredHome,
713
734
  configDir: env.configDir,
714
735
  skipHub: true,
736
+ // Pin the legacy name so the prior record (keyed "parachute") matches —
737
+ // the orphan-sweep behavior under test is independent of #491.
738
+ tunnelName: "parachute",
715
739
  });
716
740
 
717
741
  expect(code).toBe(0);
@@ -757,6 +781,9 @@ describe("exposeCloudflareUp", () => {
757
781
  cloudflaredHome: env.cloudflaredHome,
758
782
  configDir: env.configDir,
759
783
  skipHub: true,
784
+ // Pin the legacy name so reuse drives the DNS-diagnosis path under test
785
+ // (queued "parachute" list), not a create from the #491 default.
786
+ tunnelName: "parachute",
760
787
  });
761
788
 
762
789
  expect(code).toBe(0); // non-fatal — the expose still completes
@@ -801,6 +828,9 @@ describe("exposeCloudflareUp", () => {
801
828
  cloudflaredHome: env.cloudflaredHome,
802
829
  configDir: env.configDir,
803
830
  skipHub: true,
831
+ // Pin the legacy name so reuse drives the shadowed-DNS path under test
832
+ // (queued "parachute" list), not a create from the #491 default.
833
+ tunnelName: "parachute",
804
834
  });
805
835
 
806
836
  expect(code).toBe(0);
@@ -841,6 +871,9 @@ describe("exposeCloudflareUp", () => {
841
871
  cloudflaredHome: env.cloudflaredHome,
842
872
  configDir: env.configDir,
843
873
  skipHub: true,
874
+ // Pin the legacy name so reuse drives the no-warning path under test
875
+ // (queued "parachute" list), not a create from the #491 default.
876
+ tunnelName: "parachute",
844
877
  });
845
878
 
846
879
  expect(code).toBe(0);
@@ -857,13 +890,15 @@ describe("exposeCloudflareUp", () => {
857
890
  try {
858
891
  const uuidA = "aaaa1111-aaaa-1111-aaaa-111111111111";
859
892
  const uuidB = "bbbb2222-bbbb-2222-bbbb-222222222222";
860
- // Up #1 — default name "parachute"
893
+ // Up #1 — per-hostname default (#491): alpha.example.com →
894
+ // parachute-alpha-example-com.
895
+ const derivedA = "parachute-alpha-example-com";
861
896
  const r1 = queueRunner([
862
897
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
863
898
  { code: 0, stdout: "[]", stderr: "" },
864
899
  {
865
900
  code: 0,
866
- stdout: `Created tunnel parachute with id ${uuidA}\n`,
901
+ stdout: `Created tunnel ${derivedA} with id ${uuidA}\n`,
867
902
  stderr: "",
868
903
  },
869
904
  { code: 0, stdout: "", stderr: "" },
@@ -881,10 +916,11 @@ describe("exposeCloudflareUp", () => {
881
916
  cloudflaredHome: env.cloudflaredHome,
882
917
  configDir: env.configDir,
883
918
  skipHub: true,
884
- // Omit configPath/logPath so they're per-tunnel-derived but the
885
- // derivation now resolves against the tmp `configDir` above, so the
886
- // generated config.yml lands under tmp, not the operator's real
887
- // ~/.parachute/cloudflared/parachute/.
919
+ // Omit configPath/logPath AND tunnelName so the name is the per-hostname
920
+ // derived default (#491) and the paths are per-tunnel-derived against
921
+ // the tmp `configDir` above — so the generated config.yml lands under
922
+ // tmp/cloudflared/parachute-alpha-example-com/, not the operator's real
923
+ // ~/.parachute.
888
924
  });
889
925
  expect(code1).toBe(0);
890
926
 
@@ -916,19 +952,20 @@ describe("exposeCloudflareUp", () => {
916
952
  });
917
953
  expect(code2).toBe(0);
918
954
 
919
- // Both tunnels should be present in state, keyed by tunnel name.
955
+ // Both tunnels should be present in state, keyed by tunnel name: the
956
+ // per-hostname derived name for #1, the explicit override for #2.
920
957
  const state = readCloudflaredState(env.statePath);
921
- expect(Object.keys(state?.tunnels ?? {}).sort()).toEqual(["parachute", "second"]);
922
- expect(findTunnelRecord(state, "parachute")?.hostname).toBe("alpha.example.com");
958
+ expect(Object.keys(state?.tunnels ?? {}).sort()).toEqual([derivedA, "second"]);
959
+ expect(findTunnelRecord(state, derivedA)?.hostname).toBe("alpha.example.com");
923
960
  expect(findTunnelRecord(state, "second")?.hostname).toBe("beta.example.com");
924
961
  expect(findTunnelRecord(state, "second")?.pid).toBe(50002);
925
962
 
926
963
  // Each tunnel should have written its own config file at the per-tunnel
927
964
  // path under `~/.parachute/cloudflared/<tunnelName>/config.yml`.
928
- const cfgA = findTunnelRecord(state, "parachute")?.configPath ?? "";
965
+ const cfgA = findTunnelRecord(state, derivedA)?.configPath ?? "";
929
966
  const cfgB = findTunnelRecord(state, "second")?.configPath ?? "";
930
967
  expect(cfgA).not.toBe(cfgB);
931
- expect(cfgA.endsWith("/parachute/config.yml")).toBe(true);
968
+ expect(cfgA.endsWith(`/${derivedA}/config.yml`)).toBe(true);
932
969
  expect(cfgB.endsWith("/second/config.yml")).toBe(true);
933
970
  expect(existsSync(cfgA)).toBe(true);
934
971
  expect(existsSync(cfgB)).toBe(true);
@@ -937,6 +974,267 @@ describe("exposeCloudflareUp", () => {
937
974
  }
938
975
  });
939
976
 
977
+ describe("#491: per-hostname tunnel naming + legacy migration", () => {
978
+ test("explicit --tunnel-name overrides the per-hostname default", async () => {
979
+ const env = makeEnv();
980
+ try {
981
+ const uuid = "11112222-3333-4444-5555-666677778888";
982
+ const { runner, calls } = queueRunner([
983
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
984
+ { code: 0, stdout: "[]", stderr: "" },
985
+ { code: 0, stdout: `Created tunnel custom-name with id ${uuid}\n`, stderr: "" },
986
+ { code: 0, stdout: "", stderr: "" },
987
+ ]);
988
+ const { spawner } = fakeSpawner(43000);
989
+
990
+ const code = await exposeCloudflareUp("our.parachute.computer", {
991
+ runner,
992
+ spawner,
993
+ alive: () => false,
994
+ kill: () => {},
995
+ log: () => {},
996
+ manifestPath: env.manifestPath,
997
+ statePath: env.statePath,
998
+ exposeStatePath: env.exposeStatePath,
999
+ configPath: env.configPath,
1000
+ logPath: env.logPath,
1001
+ cloudflaredHome: env.cloudflaredHome,
1002
+ configDir: env.configDir,
1003
+ skipHub: true,
1004
+ tunnelName: "custom-name",
1005
+ });
1006
+
1007
+ expect(code).toBe(0);
1008
+ // The explicit name wins — NOT the derived parachute-our-parachute-computer.
1009
+ expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create", "custom-name"]);
1010
+ const state = readCloudflaredState(env.statePath);
1011
+ expect(findTunnelRecord(state, "custom-name")?.hostname).toBe("our.parachute.computer");
1012
+ expect(findTunnelRecord(state, "parachute-our-parachute-computer")).toBeUndefined();
1013
+ } finally {
1014
+ env.cleanup();
1015
+ }
1016
+ });
1017
+
1018
+ test("legacy-sweep: stops a live shared 'parachute' connector when migrating to a derived name", async () => {
1019
+ const env = makeEnv();
1020
+ try {
1021
+ // A box that was exposed under the old shared "parachute" tunnel.
1022
+ const legacy: CloudflaredTunnelRecord = {
1023
+ pid: 70001,
1024
+ tunnelUuid: "legacy-uuid",
1025
+ tunnelName: "parachute",
1026
+ hostname: "our.parachute.computer",
1027
+ startedAt: "2026-05-01T00:00:00.000Z",
1028
+ configPath: "/tmp/legacy/parachute/config.yml",
1029
+ };
1030
+ writeCloudflaredState({ version: 2, tunnels: { parachute: legacy } }, env.statePath);
1031
+
1032
+ const uuid = "99990000-1111-2222-3333-444455556666";
1033
+ const { runner } = queueRunner([
1034
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
1035
+ { code: 0, stdout: "[]", stderr: "" }, // new derived tunnel doesn't exist yet
1036
+ {
1037
+ code: 0,
1038
+ stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
1039
+ stderr: "",
1040
+ },
1041
+ { code: 0, stdout: "", stderr: "" }, // route dns (--overwrite-dns repoints the CNAME)
1042
+ ]);
1043
+ const { spawner } = fakeSpawner(70100);
1044
+ const killed: number[] = [];
1045
+ const logs: string[] = [];
1046
+
1047
+ const code = await exposeCloudflareUp("our.parachute.computer", {
1048
+ runner,
1049
+ spawner,
1050
+ // The legacy connector (70001) is alive; the new spawn is 70100.
1051
+ alive: (pid) => pid === 70001,
1052
+ kill: (pid) => killed.push(pid),
1053
+ connectorPids: () => [],
1054
+ resolveHost: async () => ["104.16.0.1"],
1055
+ log: (l) => logs.push(l),
1056
+ manifestPath: env.manifestPath,
1057
+ statePath: env.statePath,
1058
+ exposeStatePath: env.exposeStatePath,
1059
+ configPath: env.configPath,
1060
+ logPath: env.logPath,
1061
+ cloudflaredHome: env.cloudflaredHome,
1062
+ configDir: env.configDir,
1063
+ skipHub: true,
1064
+ });
1065
+
1066
+ expect(code).toBe(0);
1067
+ // The legacy shared connector got SIGTERM'd.
1068
+ expect(killed).toContain(70001);
1069
+ const joined = logs.join("\n");
1070
+ expect(joined).toContain("Stopped legacy shared-tunnel connector");
1071
+ expect(joined).toContain("migrated our.parachute.computer to dedicated tunnel");
1072
+ // The legacy "parachute" record is gone; only the new derived one remains.
1073
+ const state = readCloudflaredState(env.statePath);
1074
+ expect(findTunnelRecord(state, "parachute")).toBeUndefined();
1075
+ expect(findTunnelRecord(state, "parachute-our-parachute-computer")?.pid).toBe(70100);
1076
+ } finally {
1077
+ env.cleanup();
1078
+ }
1079
+ });
1080
+
1081
+ test("legacy-sweep: does NOT fire when no legacy 'parachute' record exists", async () => {
1082
+ const env = makeEnv();
1083
+ try {
1084
+ const uuid = "aaaa9999-1111-2222-3333-444455556666";
1085
+ const { runner } = queueRunner([
1086
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
1087
+ { code: 0, stdout: "[]", stderr: "" },
1088
+ {
1089
+ code: 0,
1090
+ stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
1091
+ stderr: "",
1092
+ },
1093
+ { code: 0, stdout: "", stderr: "" },
1094
+ ]);
1095
+ const { spawner } = fakeSpawner(70200);
1096
+ const logs: string[] = [];
1097
+
1098
+ const code = await exposeCloudflareUp("our.parachute.computer", {
1099
+ runner,
1100
+ spawner,
1101
+ alive: () => false,
1102
+ kill: () => {},
1103
+ connectorPids: () => [],
1104
+ resolveHost: async () => ["104.16.0.1"],
1105
+ log: (l) => logs.push(l),
1106
+ manifestPath: env.manifestPath,
1107
+ statePath: env.statePath,
1108
+ exposeStatePath: env.exposeStatePath,
1109
+ configPath: env.configPath,
1110
+ logPath: env.logPath,
1111
+ cloudflaredHome: env.cloudflaredHome,
1112
+ configDir: env.configDir,
1113
+ skipHub: true,
1114
+ });
1115
+
1116
+ expect(code).toBe(0);
1117
+ expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
1118
+ } finally {
1119
+ env.cleanup();
1120
+ }
1121
+ });
1122
+
1123
+ test("legacy-sweep: drops a DEAD legacy 'parachute' record without killing, when migrating", async () => {
1124
+ const env = makeEnv();
1125
+ try {
1126
+ // A leftover shared-tunnel record whose connector is no longer running.
1127
+ const deadLegacy: CloudflaredTunnelRecord = {
1128
+ pid: 72001,
1129
+ tunnelUuid: "dead-legacy-uuid",
1130
+ tunnelName: "parachute",
1131
+ hostname: "our.parachute.computer",
1132
+ startedAt: "2026-05-01T00:00:00.000Z",
1133
+ configPath: "/tmp/legacy/parachute/config.yml",
1134
+ };
1135
+ writeCloudflaredState({ version: 2, tunnels: { parachute: deadLegacy } }, env.statePath);
1136
+
1137
+ const uuid = "cccc7777-1111-2222-3333-444455556666";
1138
+ const { runner } = queueRunner([
1139
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
1140
+ { code: 0, stdout: "[]", stderr: "" },
1141
+ {
1142
+ code: 0,
1143
+ stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
1144
+ stderr: "",
1145
+ },
1146
+ { code: 0, stdout: "", stderr: "" },
1147
+ ]);
1148
+ const { spawner } = fakeSpawner(72100);
1149
+ const killed: number[] = [];
1150
+ const logs: string[] = [];
1151
+
1152
+ const code = await exposeCloudflareUp("our.parachute.computer", {
1153
+ runner,
1154
+ spawner,
1155
+ alive: () => false, // nothing alive — including the dead legacy pid
1156
+ kill: (pid) => killed.push(pid),
1157
+ connectorPids: () => [],
1158
+ resolveHost: async () => ["104.16.0.1"],
1159
+ log: (l) => logs.push(l),
1160
+ manifestPath: env.manifestPath,
1161
+ statePath: env.statePath,
1162
+ exposeStatePath: env.exposeStatePath,
1163
+ configPath: env.configPath,
1164
+ logPath: env.logPath,
1165
+ cloudflaredHome: env.cloudflaredHome,
1166
+ configDir: env.configDir,
1167
+ skipHub: true,
1168
+ });
1169
+
1170
+ expect(code).toBe(0);
1171
+ // Connector wasn't alive → nothing killed, no sweep log.
1172
+ expect(killed).not.toContain(72001);
1173
+ expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
1174
+ // …but the stale dead record is cleared, leaving only the new derived one.
1175
+ const state = readCloudflaredState(env.statePath);
1176
+ expect(findTunnelRecord(state, "parachute")).toBeUndefined();
1177
+ expect(findTunnelRecord(state, "parachute-our-parachute-computer")?.pid).toBe(72100);
1178
+ } finally {
1179
+ env.cleanup();
1180
+ }
1181
+ });
1182
+
1183
+ test("legacy-sweep: does NOT fire when the derived name IS 'parachute' (no migration)", async () => {
1184
+ // A live "parachute" record AND an invocation that resolves to the
1185
+ // "parachute" name (here via explicit --tunnel-name parachute) must not
1186
+ // self-sweep — the connector we'd kill is the very one we're about to
1187
+ // reuse. Reuse-flow: queued list reports the parachute tunnel.
1188
+ const env = makeEnv();
1189
+ try {
1190
+ const uuid = "bbbb8888-1111-2222-3333-444455556666";
1191
+ const legacy: CloudflaredTunnelRecord = {
1192
+ pid: 71001,
1193
+ tunnelUuid: uuid,
1194
+ tunnelName: "parachute",
1195
+ hostname: "our.parachute.computer",
1196
+ startedAt: "2026-05-01T00:00:00.000Z",
1197
+ configPath: env.configPath,
1198
+ };
1199
+ writeCloudflaredState({ version: 2, tunnels: { parachute: legacy } }, env.statePath);
1200
+
1201
+ const { runner } = queueRunner([
1202
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
1203
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
1204
+ { code: 0, stdout: "", stderr: "" }, // route dns
1205
+ ]);
1206
+ const { spawner } = fakeSpawner(71100);
1207
+ const logs: string[] = [];
1208
+
1209
+ const code = await exposeCloudflareUp("our.parachute.computer", {
1210
+ runner,
1211
+ spawner,
1212
+ alive: () => true,
1213
+ kill: () => {},
1214
+ connectorPids: () => [],
1215
+ resolveHost: async () => ["104.16.0.1"],
1216
+ log: (l) => logs.push(l),
1217
+ manifestPath: env.manifestPath,
1218
+ statePath: env.statePath,
1219
+ exposeStatePath: env.exposeStatePath,
1220
+ configPath: env.configPath,
1221
+ logPath: env.logPath,
1222
+ cloudflaredHome: env.cloudflaredHome,
1223
+ configDir: env.configDir,
1224
+ skipHub: true,
1225
+ tunnelName: "parachute",
1226
+ });
1227
+
1228
+ expect(code).toBe(0);
1229
+ // No legacy-migration log line: we resolved TO "parachute", so there's
1230
+ // nothing to migrate away from.
1231
+ expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
1232
+ } finally {
1233
+ env.cleanup();
1234
+ }
1235
+ });
1236
+ });
1237
+
940
1238
  // 2FA-enrollment warning (#186). The cloudflare path is always public —
941
1239
  // every successful bringup makes /admin/login reachable on the open
942
1240
  // internet, where 2FA is the primary defense beyond #188's rate-limit floor.
@@ -1348,4 +1646,374 @@ describe("exposeCloudflareOff", () => {
1348
1646
  env.cleanup();
1349
1647
  }
1350
1648
  });
1649
+
1650
+ describe("#491: state-driven off (no --tunnel-name)", () => {
1651
+ test("0 tunnels → 'Nothing to tear down' (exit 0)", async () => {
1652
+ const env = makeEnv();
1653
+ try {
1654
+ const logs: string[] = [];
1655
+ const code = await exposeCloudflareOff({
1656
+ statePath: env.statePath,
1657
+ exposeStatePath: env.exposeStatePath,
1658
+ log: (l) => logs.push(l),
1659
+ });
1660
+ expect(code).toBe(0);
1661
+ expect(logs.join("\n")).toContain("Nothing to tear down");
1662
+ } finally {
1663
+ env.cleanup();
1664
+ }
1665
+ });
1666
+
1667
+ test("exactly 1 tunnel → tears it down by reading state (even a derived non-'parachute' name)", async () => {
1668
+ const env = makeEnv();
1669
+ try {
1670
+ const record: CloudflaredTunnelRecord = {
1671
+ pid: 80001,
1672
+ tunnelUuid: "derived-uuid",
1673
+ tunnelName: "parachute-our-parachute-computer",
1674
+ hostname: "our.parachute.computer",
1675
+ startedAt: "2026-05-20T10:00:00.000Z",
1676
+ configPath: "/tmp/derived/config.yml",
1677
+ };
1678
+ writeCloudflaredState(
1679
+ { version: 2, tunnels: { "parachute-our-parachute-computer": record } },
1680
+ env.statePath,
1681
+ );
1682
+
1683
+ const killed: number[] = [];
1684
+ const code = await exposeCloudflareOff({
1685
+ statePath: env.statePath,
1686
+ exposeStatePath: env.exposeStatePath,
1687
+ alive: () => true,
1688
+ kill: (pid) => killed.push(pid),
1689
+ log: () => {},
1690
+ // No tunnelName — resolved from state.
1691
+ });
1692
+ expect(code).toBe(0);
1693
+ expect(killed).toEqual([80001]);
1694
+ expect(existsSync(env.statePath)).toBe(false);
1695
+ } finally {
1696
+ env.cleanup();
1697
+ }
1698
+ });
1699
+
1700
+ test("≥2 tunnels → tears down ALL of them and lists each", async () => {
1701
+ const env = makeEnv();
1702
+ try {
1703
+ const recordA: CloudflaredTunnelRecord = {
1704
+ pid: 81001,
1705
+ tunnelUuid: "aaaa-uuid",
1706
+ tunnelName: "parachute-alpha-example-com",
1707
+ hostname: "alpha.example.com",
1708
+ startedAt: "2026-05-20T10:00:00.000Z",
1709
+ configPath: "/tmp/alpha/config.yml",
1710
+ };
1711
+ const recordB: CloudflaredTunnelRecord = {
1712
+ pid: 81002,
1713
+ tunnelUuid: "bbbb-uuid",
1714
+ tunnelName: "parachute-beta-example-com",
1715
+ hostname: "beta.example.com",
1716
+ startedAt: "2026-05-20T11:00:00.000Z",
1717
+ configPath: "/tmp/beta/config.yml",
1718
+ };
1719
+ writeCloudflaredState(
1720
+ withTunnelRecord(withTunnelRecord(undefined, recordA), recordB),
1721
+ env.statePath,
1722
+ );
1723
+
1724
+ const killed: number[] = [];
1725
+ const logs: string[] = [];
1726
+ const code = await exposeCloudflareOff({
1727
+ statePath: env.statePath,
1728
+ exposeStatePath: env.exposeStatePath,
1729
+ alive: () => true,
1730
+ kill: (pid) => killed.push(pid),
1731
+ log: (l) => logs.push(l),
1732
+ // No tunnelName — bare `off` means "stop all public Cloudflare exposure".
1733
+ });
1734
+ expect(code).toBe(0);
1735
+ // Both connectors stopped.
1736
+ expect(killed.sort()).toEqual([81001, 81002]);
1737
+ // State fully cleared (no tunnels remain).
1738
+ expect(existsSync(env.statePath)).toBe(false);
1739
+ const joined = logs.join("\n");
1740
+ expect(joined).toContain("Tearing down all 2 recorded Cloudflare tunnels");
1741
+ expect(joined).toContain("parachute-alpha-example-com");
1742
+ expect(joined).toContain("parachute-beta-example-com");
1743
+ } finally {
1744
+ env.cleanup();
1745
+ }
1746
+ });
1747
+ });
1748
+ });
1749
+
1750
+ describe("reboot-persistent connector service wiring", () => {
1751
+ // The default tunnel name for vault.example.com (#491 per-hostname derivation).
1752
+ const DERIVED = "parachute-vault-example-com";
1753
+
1754
+ function upRunner(uuid: string, derived = DERIVED) {
1755
+ return queueRunner([
1756
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
1757
+ { code: 0, stdout: "[]", stderr: "" }, // tunnel list
1758
+ {
1759
+ code: 0,
1760
+ stdout: `Created tunnel ${derived} with id ${uuid}\n`,
1761
+ stderr: "",
1762
+ }, // tunnel create
1763
+ { code: 0, stdout: "", stderr: "" }, // route dns
1764
+ ]);
1765
+ }
1766
+
1767
+ test("up installs the connector service (no duplicate transient spawn) + records serviceManaged", async () => {
1768
+ const env = makeEnv();
1769
+ try {
1770
+ const uuid = "aaaaaaaa-0000-0000-0000-000000000001";
1771
+ const { runner } = upRunner(uuid);
1772
+ const { spawner, seen } = fakeSpawner(91000);
1773
+ const installCalls: { tunnelName: string; configPath: string }[] = [];
1774
+ const installService = (args: {
1775
+ tunnelName: string;
1776
+ configPath: string;
1777
+ logPath: string;
1778
+ }): InstallResult => {
1779
+ installCalls.push({ tunnelName: args.tunnelName, configPath: args.configPath });
1780
+ return {
1781
+ outcome: "installed",
1782
+ kind: "launchd",
1783
+ servicePath: "/home/op/Library/LaunchAgents/x.plist",
1784
+ messages: ["Installed launchd LaunchAgent — starts on boot."],
1785
+ };
1786
+ };
1787
+ const logs: string[] = [];
1788
+
1789
+ const code = await exposeCloudflareUp("vault.example.com", {
1790
+ runner,
1791
+ spawner,
1792
+ // The service-spawned connector (93333) is alive; no prior record exists
1793
+ // so the orphan sweep finds nothing else to kill.
1794
+ alive: (pid) => pid === 93333,
1795
+ kill: () => {},
1796
+ // Service manages the connector; report a live pid so the up-path
1797
+ // records it WITHOUT spawning a transient one.
1798
+ connectorPids: () => [93333],
1799
+ installService,
1800
+ log: (l) => logs.push(l),
1801
+ manifestPath: env.manifestPath,
1802
+ statePath: env.statePath,
1803
+ exposeStatePath: env.exposeStatePath,
1804
+ configPath: env.configPath,
1805
+ logPath: env.logPath,
1806
+ cloudflaredHome: env.cloudflaredHome,
1807
+ configDir: env.configDir,
1808
+ skipHub: true,
1809
+ now: () => new Date("2026-05-31T00:00:00Z"),
1810
+ });
1811
+
1812
+ expect(code).toBe(0);
1813
+ // Service install was attempted with the derived tunnel name + the
1814
+ // config path we wrote.
1815
+ expect(installCalls).toHaveLength(1);
1816
+ expect(installCalls[0]!.tunnelName).toBe(DERIVED);
1817
+ expect(installCalls[0]!.configPath).toBe(env.configPath);
1818
+ // Exactly one connector: the service owns it, so NO transient spawn ran
1819
+ // (connectorPids surfaced the service-spawned pid).
1820
+ expect(seen).toHaveLength(0);
1821
+
1822
+ const state = readCloudflaredState(env.statePath);
1823
+ const rec = findTunnelRecord(state, DERIVED);
1824
+ expect(rec?.serviceManaged).toBe(true);
1825
+ // Recorded pid is the service-spawned connector's pid (from connectorPids).
1826
+ expect(rec?.pid).toBe(93333);
1827
+
1828
+ const joined = logs.join("\n");
1829
+ expect(joined).toContain("Installed launchd LaunchAgent");
1830
+ // Success copy: runs on boot, no "re-run after a reboot" nag.
1831
+ expect(joined).toContain("runs on boot");
1832
+ expect(joined).not.toContain("does NOT survive a reboot");
1833
+ } finally {
1834
+ env.cleanup();
1835
+ }
1836
+ });
1837
+
1838
+ test("service-managed but connector not yet visible → transient spawn keeps a live pid", async () => {
1839
+ const env = makeEnv();
1840
+ try {
1841
+ const uuid = "aaaaaaaa-0000-0000-0000-000000000002";
1842
+ const { runner } = upRunner(uuid);
1843
+ const { spawner, seen } = fakeSpawner(91500);
1844
+ const installService = (): InstallResult => ({
1845
+ outcome: "installed",
1846
+ kind: "systemd-user",
1847
+ servicePath: "/home/op/.config/systemd/user/x.service",
1848
+ messages: [],
1849
+ });
1850
+
1851
+ const code = await exposeCloudflareUp("vault.example.com", {
1852
+ runner,
1853
+ spawner,
1854
+ alive: () => false,
1855
+ kill: () => {},
1856
+ // Service is enabled but its connector isn't visible to pgrep yet.
1857
+ connectorPids: () => [],
1858
+ installService,
1859
+ log: () => {},
1860
+ manifestPath: env.manifestPath,
1861
+ statePath: env.statePath,
1862
+ exposeStatePath: env.exposeStatePath,
1863
+ configPath: env.configPath,
1864
+ logPath: env.logPath,
1865
+ cloudflaredHome: env.cloudflaredHome,
1866
+ configDir: env.configDir,
1867
+ skipHub: true,
1868
+ now: () => new Date("2026-05-31T00:00:00Z"),
1869
+ });
1870
+
1871
+ expect(code).toBe(0);
1872
+ // Belt-and-suspenders: a transient connector was spawned so the state
1873
+ // record carries a live pid + connectivity is immediate. Still marked
1874
+ // serviceManaged (the service takes over on reboot).
1875
+ expect(seen).toHaveLength(1);
1876
+ const rec = findTunnelRecord(readCloudflaredState(env.statePath), DERIVED);
1877
+ expect(rec?.pid).toBe(91500);
1878
+ expect(rec?.serviceManaged).toBe(true);
1879
+ } finally {
1880
+ env.cleanup();
1881
+ }
1882
+ });
1883
+
1884
+ test("graceful fallback: when install fails, transient spawn + honest reboot caveat", async () => {
1885
+ const env = makeEnv();
1886
+ try {
1887
+ const uuid = "aaaaaaaa-0000-0000-0000-000000000003";
1888
+ const { runner } = upRunner(uuid);
1889
+ const { spawner, seen } = fakeSpawner(92000);
1890
+ const installService = (): InstallResult => ({
1891
+ outcome: "fallback",
1892
+ messages: ["launchctl not found; using a transient connector (won't survive a reboot)."],
1893
+ });
1894
+ const logs: string[] = [];
1895
+
1896
+ const code = await exposeCloudflareUp("vault.example.com", {
1897
+ runner,
1898
+ spawner,
1899
+ alive: () => false,
1900
+ kill: () => {},
1901
+ connectorPids: () => [],
1902
+ installService,
1903
+ log: (l) => logs.push(l),
1904
+ manifestPath: env.manifestPath,
1905
+ statePath: env.statePath,
1906
+ exposeStatePath: env.exposeStatePath,
1907
+ configPath: env.configPath,
1908
+ logPath: env.logPath,
1909
+ cloudflaredHome: env.cloudflaredHome,
1910
+ configDir: env.configDir,
1911
+ skipHub: true,
1912
+ now: () => new Date("2026-05-31T00:00:00Z"),
1913
+ });
1914
+
1915
+ expect(code).toBe(0);
1916
+ // Transient connector spawned (the fallback).
1917
+ expect(seen).toHaveLength(1);
1918
+ expect(seen[0]).toEqual(["cloudflared", "tunnel", "--config", env.configPath, "run"]);
1919
+ const rec = findTunnelRecord(readCloudflaredState(env.statePath), DERIVED);
1920
+ expect(rec?.serviceManaged).toBeUndefined();
1921
+ const joined = logs.join("\n");
1922
+ // The install fallback warning surfaced + the honest reboot caveat.
1923
+ expect(joined).toContain("won't survive a reboot");
1924
+ expect(joined).toContain("does NOT survive a reboot");
1925
+ expect(joined).toContain("parachute expose public --cloudflare --domain vault.example.com");
1926
+ } finally {
1927
+ env.cleanup();
1928
+ }
1929
+ });
1930
+
1931
+ test("re-up is idempotent: install runs again, no duplicate state record", async () => {
1932
+ const env = makeEnv();
1933
+ try {
1934
+ const installCount = { n: 0 };
1935
+ const installService = (): InstallResult => {
1936
+ installCount.n++;
1937
+ return { outcome: "installed", kind: "launchd", servicePath: "/x.plist", messages: [] };
1938
+ };
1939
+ const runOnce = async (uuid: string) => {
1940
+ const { runner } = upRunner(uuid);
1941
+ const { spawner } = fakeSpawner(90000 + installCount.n);
1942
+ return exposeCloudflareUp("vault.example.com", {
1943
+ runner,
1944
+ spawner,
1945
+ alive: () => false,
1946
+ kill: () => {},
1947
+ connectorPids: () => [94000 + installCount.n],
1948
+ installService,
1949
+ log: () => {},
1950
+ manifestPath: env.manifestPath,
1951
+ statePath: env.statePath,
1952
+ exposeStatePath: env.exposeStatePath,
1953
+ configPath: env.configPath,
1954
+ logPath: env.logPath,
1955
+ cloudflaredHome: env.cloudflaredHome,
1956
+ configDir: env.configDir,
1957
+ skipHub: true,
1958
+ now: () => new Date("2026-05-31T00:00:00Z"),
1959
+ });
1960
+ };
1961
+
1962
+ expect(await runOnce("aaaaaaaa-0000-0000-0000-000000000004")).toBe(0);
1963
+ expect(await runOnce("aaaaaaaa-0000-0000-0000-000000000004")).toBe(0);
1964
+ // Install attempted on each up (idempotent — the module overwrites + reloads).
1965
+ expect(installCount.n).toBe(2);
1966
+ // Exactly one tunnel record, keyed by name (re-up replaces, not appends).
1967
+ const state = readCloudflaredState(env.statePath);
1968
+ expect(Object.keys(state?.tunnels ?? {})).toEqual([DERIVED]);
1969
+ } finally {
1970
+ env.cleanup();
1971
+ }
1972
+ });
1973
+
1974
+ test("off removes the connector service for the torn-down tunnel", async () => {
1975
+ const env = makeEnv();
1976
+ try {
1977
+ // Seed a service-managed tunnel record.
1978
+ const record: CloudflaredTunnelRecord = {
1979
+ pid: 95000,
1980
+ tunnelUuid: "bbbbbbbb-0000-0000-0000-000000000001",
1981
+ tunnelName: DERIVED,
1982
+ hostname: "vault.example.com",
1983
+ startedAt: "2026-05-31T00:00:00.000Z",
1984
+ configPath: env.configPath,
1985
+ serviceManaged: true,
1986
+ };
1987
+ writeCloudflaredState(withTunnelRecord(undefined, record), env.statePath);
1988
+
1989
+ const removeCalls: string[] = [];
1990
+ const removeService = (args: { tunnelName: string }): RemoveResult => {
1991
+ removeCalls.push(args.tunnelName);
1992
+ return { removed: true, messages: [`Removed launchd LaunchAgent for ${args.tunnelName}.`] };
1993
+ };
1994
+ const killed: number[] = [];
1995
+ const logs: string[] = [];
1996
+
1997
+ const code = await exposeCloudflareOff({
1998
+ statePath: env.statePath,
1999
+ exposeStatePath: env.exposeStatePath,
2000
+ alive: () => true,
2001
+ kill: (pid) => killed.push(pid),
2002
+ connectorPids: () => [],
2003
+ removeService,
2004
+ log: (l) => logs.push(l),
2005
+ });
2006
+
2007
+ expect(code).toBe(0);
2008
+ // The boot service was removed for the torn-down tunnel...
2009
+ expect(removeCalls).toEqual([DERIVED]);
2010
+ // ...AND the connector pid was SIGTERM'd (removal stops the service so it
2011
+ // won't restart it).
2012
+ expect(killed).toContain(95000);
2013
+ expect(existsSync(env.statePath)).toBe(false);
2014
+ expect(logs.join("\n")).toContain("Removed launchd LaunchAgent");
2015
+ } finally {
2016
+ env.cleanup();
2017
+ }
2018
+ });
1351
2019
  });