@openparachute/hub 0.6.1-rc.3 → 0.6.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.
@@ -119,12 +119,16 @@ describe("exposeCloudflareUp", () => {
119
119
  const env = makeEnv();
120
120
  try {
121
121
  const uuid = "2c1a7c7e-1234-5678-9abc-def012345678";
122
+ // Default tunnel name is now per-hostname (#491): vault.example.com →
123
+ // parachute-vault-example-com. Each machine gets its own dedicated tunnel
124
+ // so account-wide tunnels don't collide across boxes.
125
+ const derived = "parachute-vault-example-com";
122
126
  const { runner, calls } = queueRunner([
123
127
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version preflight
124
128
  { code: 0, stdout: "[]", stderr: "" }, // tunnel list (none yet)
125
129
  {
126
130
  code: 0,
127
- stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
131
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel ${derived} with id ${uuid}\n`,
128
132
  stderr: "",
129
133
  }, // tunnel create
130
134
  { code: 0, stdout: "", stderr: "" }, // route dns
@@ -158,14 +162,14 @@ describe("exposeCloudflareUp", () => {
158
162
  ]);
159
163
  expect(calls[0]!.cmd).toEqual(["cloudflared", "--version"]);
160
164
  expect(calls[1]!.cmd).toEqual(["cloudflared", "tunnel", "list", "--output", "json"]);
161
- expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create", "parachute"]);
165
+ expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create", derived]);
162
166
  expect(calls[3]!.cmd).toEqual([
163
167
  "cloudflared",
164
168
  "tunnel",
165
169
  "route",
166
170
  "dns",
167
171
  "--overwrite-dns",
168
- "parachute",
172
+ derived,
169
173
  "vault.example.com",
170
174
  ]);
171
175
  expect(seen[0]).toEqual(["cloudflared", "tunnel", "--config", env.configPath, "run"]);
@@ -174,10 +178,10 @@ describe("exposeCloudflareUp", () => {
174
178
  expect(state).toEqual({
175
179
  version: 2,
176
180
  tunnels: {
177
- parachute: {
181
+ [derived]: {
178
182
  pid: 42000,
179
183
  tunnelUuid: uuid,
180
- tunnelName: "parachute",
184
+ tunnelName: derived,
181
185
  hostname: "vault.example.com",
182
186
  startedAt: "2026-04-22T12:00:00.000Z",
183
187
  configPath: env.configPath,
@@ -248,6 +252,10 @@ describe("exposeCloudflareUp", () => {
248
252
  cloudflaredHome: env.cloudflaredHome,
249
253
  configDir: env.configDir,
250
254
  skipHub: true,
255
+ // Pin the legacy shared name so this test's substance (expose-state
256
+ // write) is isolated from the per-hostname-derivation change (#491);
257
+ // the queued runner output names the "parachute" tunnel.
258
+ tunnelName: "parachute",
251
259
  });
252
260
 
253
261
  expect(code).toBe(0);
@@ -419,6 +427,10 @@ describe("exposeCloudflareUp", () => {
419
427
  cloudflaredHome: env.cloudflaredHome,
420
428
  configDir: env.configDir,
421
429
  skipHub: true,
430
+ // Pin the legacy name: the queued `tunnel list` reports a "parachute"
431
+ // tunnel, so reuse only happens when we look it up by that name. The
432
+ // per-hostname default (#491) is exercised in the happy-path test.
433
+ tunnelName: "parachute",
422
434
  });
423
435
  expect(code).toBe(0);
424
436
  // No `tunnel create` — only list + route.
@@ -604,6 +616,10 @@ describe("exposeCloudflareUp", () => {
604
616
  cloudflaredHome: env.cloudflaredHome,
605
617
  configDir: env.configDir,
606
618
  skipHub: true,
619
+ // Pin the legacy name so reuse (queued "parachute" list) drives the
620
+ // route-dns failure under test, not a tunnel-create from the
621
+ // per-hostname default (#491).
622
+ tunnelName: "parachute",
607
623
  });
608
624
 
609
625
  expect(code).toBe(1);
@@ -656,6 +672,10 @@ describe("exposeCloudflareUp", () => {
656
672
  cloudflaredHome: env.cloudflaredHome,
657
673
  configDir: env.configDir,
658
674
  skipHub: true,
675
+ // Pin the legacy name so the prior record (keyed "parachute") matches
676
+ // this invocation's tunnel — the orphan-sweep behavior under test is
677
+ // independent of the per-hostname-derivation change (#491).
678
+ tunnelName: "parachute",
659
679
  });
660
680
 
661
681
  expect(code).toBe(0);
@@ -712,6 +732,9 @@ describe("exposeCloudflareUp", () => {
712
732
  cloudflaredHome: env.cloudflaredHome,
713
733
  configDir: env.configDir,
714
734
  skipHub: true,
735
+ // Pin the legacy name so the prior record (keyed "parachute") matches —
736
+ // the orphan-sweep behavior under test is independent of #491.
737
+ tunnelName: "parachute",
715
738
  });
716
739
 
717
740
  expect(code).toBe(0);
@@ -757,6 +780,9 @@ describe("exposeCloudflareUp", () => {
757
780
  cloudflaredHome: env.cloudflaredHome,
758
781
  configDir: env.configDir,
759
782
  skipHub: true,
783
+ // Pin the legacy name so reuse drives the DNS-diagnosis path under test
784
+ // (queued "parachute" list), not a create from the #491 default.
785
+ tunnelName: "parachute",
760
786
  });
761
787
 
762
788
  expect(code).toBe(0); // non-fatal — the expose still completes
@@ -801,6 +827,9 @@ describe("exposeCloudflareUp", () => {
801
827
  cloudflaredHome: env.cloudflaredHome,
802
828
  configDir: env.configDir,
803
829
  skipHub: true,
830
+ // Pin the legacy name so reuse drives the shadowed-DNS path under test
831
+ // (queued "parachute" list), not a create from the #491 default.
832
+ tunnelName: "parachute",
804
833
  });
805
834
 
806
835
  expect(code).toBe(0);
@@ -841,6 +870,9 @@ describe("exposeCloudflareUp", () => {
841
870
  cloudflaredHome: env.cloudflaredHome,
842
871
  configDir: env.configDir,
843
872
  skipHub: true,
873
+ // Pin the legacy name so reuse drives the no-warning path under test
874
+ // (queued "parachute" list), not a create from the #491 default.
875
+ tunnelName: "parachute",
844
876
  });
845
877
 
846
878
  expect(code).toBe(0);
@@ -857,13 +889,15 @@ describe("exposeCloudflareUp", () => {
857
889
  try {
858
890
  const uuidA = "aaaa1111-aaaa-1111-aaaa-111111111111";
859
891
  const uuidB = "bbbb2222-bbbb-2222-bbbb-222222222222";
860
- // Up #1 — default name "parachute"
892
+ // Up #1 — per-hostname default (#491): alpha.example.com →
893
+ // parachute-alpha-example-com.
894
+ const derivedA = "parachute-alpha-example-com";
861
895
  const r1 = queueRunner([
862
896
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
863
897
  { code: 0, stdout: "[]", stderr: "" },
864
898
  {
865
899
  code: 0,
866
- stdout: `Created tunnel parachute with id ${uuidA}\n`,
900
+ stdout: `Created tunnel ${derivedA} with id ${uuidA}\n`,
867
901
  stderr: "",
868
902
  },
869
903
  { code: 0, stdout: "", stderr: "" },
@@ -881,10 +915,11 @@ describe("exposeCloudflareUp", () => {
881
915
  cloudflaredHome: env.cloudflaredHome,
882
916
  configDir: env.configDir,
883
917
  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/.
918
+ // Omit configPath/logPath AND tunnelName so the name is the per-hostname
919
+ // derived default (#491) and the paths are per-tunnel-derived against
920
+ // the tmp `configDir` above — so the generated config.yml lands under
921
+ // tmp/cloudflared/parachute-alpha-example-com/, not the operator's real
922
+ // ~/.parachute.
888
923
  });
889
924
  expect(code1).toBe(0);
890
925
 
@@ -916,19 +951,20 @@ describe("exposeCloudflareUp", () => {
916
951
  });
917
952
  expect(code2).toBe(0);
918
953
 
919
- // Both tunnels should be present in state, keyed by tunnel name.
954
+ // Both tunnels should be present in state, keyed by tunnel name: the
955
+ // per-hostname derived name for #1, the explicit override for #2.
920
956
  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");
957
+ expect(Object.keys(state?.tunnels ?? {}).sort()).toEqual([derivedA, "second"]);
958
+ expect(findTunnelRecord(state, derivedA)?.hostname).toBe("alpha.example.com");
923
959
  expect(findTunnelRecord(state, "second")?.hostname).toBe("beta.example.com");
924
960
  expect(findTunnelRecord(state, "second")?.pid).toBe(50002);
925
961
 
926
962
  // Each tunnel should have written its own config file at the per-tunnel
927
963
  // path under `~/.parachute/cloudflared/<tunnelName>/config.yml`.
928
- const cfgA = findTunnelRecord(state, "parachute")?.configPath ?? "";
964
+ const cfgA = findTunnelRecord(state, derivedA)?.configPath ?? "";
929
965
  const cfgB = findTunnelRecord(state, "second")?.configPath ?? "";
930
966
  expect(cfgA).not.toBe(cfgB);
931
- expect(cfgA.endsWith("/parachute/config.yml")).toBe(true);
967
+ expect(cfgA.endsWith(`/${derivedA}/config.yml`)).toBe(true);
932
968
  expect(cfgB.endsWith("/second/config.yml")).toBe(true);
933
969
  expect(existsSync(cfgA)).toBe(true);
934
970
  expect(existsSync(cfgB)).toBe(true);
@@ -937,6 +973,267 @@ describe("exposeCloudflareUp", () => {
937
973
  }
938
974
  });
939
975
 
976
+ describe("#491: per-hostname tunnel naming + legacy migration", () => {
977
+ test("explicit --tunnel-name overrides the per-hostname default", async () => {
978
+ const env = makeEnv();
979
+ try {
980
+ const uuid = "11112222-3333-4444-5555-666677778888";
981
+ const { runner, calls } = queueRunner([
982
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
983
+ { code: 0, stdout: "[]", stderr: "" },
984
+ { code: 0, stdout: `Created tunnel custom-name with id ${uuid}\n`, stderr: "" },
985
+ { code: 0, stdout: "", stderr: "" },
986
+ ]);
987
+ const { spawner } = fakeSpawner(43000);
988
+
989
+ const code = await exposeCloudflareUp("our.parachute.computer", {
990
+ runner,
991
+ spawner,
992
+ alive: () => false,
993
+ kill: () => {},
994
+ log: () => {},
995
+ manifestPath: env.manifestPath,
996
+ statePath: env.statePath,
997
+ exposeStatePath: env.exposeStatePath,
998
+ configPath: env.configPath,
999
+ logPath: env.logPath,
1000
+ cloudflaredHome: env.cloudflaredHome,
1001
+ configDir: env.configDir,
1002
+ skipHub: true,
1003
+ tunnelName: "custom-name",
1004
+ });
1005
+
1006
+ expect(code).toBe(0);
1007
+ // The explicit name wins — NOT the derived parachute-our-parachute-computer.
1008
+ expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create", "custom-name"]);
1009
+ const state = readCloudflaredState(env.statePath);
1010
+ expect(findTunnelRecord(state, "custom-name")?.hostname).toBe("our.parachute.computer");
1011
+ expect(findTunnelRecord(state, "parachute-our-parachute-computer")).toBeUndefined();
1012
+ } finally {
1013
+ env.cleanup();
1014
+ }
1015
+ });
1016
+
1017
+ test("legacy-sweep: stops a live shared 'parachute' connector when migrating to a derived name", async () => {
1018
+ const env = makeEnv();
1019
+ try {
1020
+ // A box that was exposed under the old shared "parachute" tunnel.
1021
+ const legacy: CloudflaredTunnelRecord = {
1022
+ pid: 70001,
1023
+ tunnelUuid: "legacy-uuid",
1024
+ tunnelName: "parachute",
1025
+ hostname: "our.parachute.computer",
1026
+ startedAt: "2026-05-01T00:00:00.000Z",
1027
+ configPath: "/tmp/legacy/parachute/config.yml",
1028
+ };
1029
+ writeCloudflaredState({ version: 2, tunnels: { parachute: legacy } }, env.statePath);
1030
+
1031
+ const uuid = "99990000-1111-2222-3333-444455556666";
1032
+ const { runner } = queueRunner([
1033
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
1034
+ { code: 0, stdout: "[]", stderr: "" }, // new derived tunnel doesn't exist yet
1035
+ {
1036
+ code: 0,
1037
+ stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
1038
+ stderr: "",
1039
+ },
1040
+ { code: 0, stdout: "", stderr: "" }, // route dns (--overwrite-dns repoints the CNAME)
1041
+ ]);
1042
+ const { spawner } = fakeSpawner(70100);
1043
+ const killed: number[] = [];
1044
+ const logs: string[] = [];
1045
+
1046
+ const code = await exposeCloudflareUp("our.parachute.computer", {
1047
+ runner,
1048
+ spawner,
1049
+ // The legacy connector (70001) is alive; the new spawn is 70100.
1050
+ alive: (pid) => pid === 70001,
1051
+ kill: (pid) => killed.push(pid),
1052
+ connectorPids: () => [],
1053
+ resolveHost: async () => ["104.16.0.1"],
1054
+ log: (l) => logs.push(l),
1055
+ manifestPath: env.manifestPath,
1056
+ statePath: env.statePath,
1057
+ exposeStatePath: env.exposeStatePath,
1058
+ configPath: env.configPath,
1059
+ logPath: env.logPath,
1060
+ cloudflaredHome: env.cloudflaredHome,
1061
+ configDir: env.configDir,
1062
+ skipHub: true,
1063
+ });
1064
+
1065
+ expect(code).toBe(0);
1066
+ // The legacy shared connector got SIGTERM'd.
1067
+ expect(killed).toContain(70001);
1068
+ const joined = logs.join("\n");
1069
+ expect(joined).toContain("Stopped legacy shared-tunnel connector");
1070
+ expect(joined).toContain("migrated our.parachute.computer to dedicated tunnel");
1071
+ // The legacy "parachute" record is gone; only the new derived one remains.
1072
+ const state = readCloudflaredState(env.statePath);
1073
+ expect(findTunnelRecord(state, "parachute")).toBeUndefined();
1074
+ expect(findTunnelRecord(state, "parachute-our-parachute-computer")?.pid).toBe(70100);
1075
+ } finally {
1076
+ env.cleanup();
1077
+ }
1078
+ });
1079
+
1080
+ test("legacy-sweep: does NOT fire when no legacy 'parachute' record exists", async () => {
1081
+ const env = makeEnv();
1082
+ try {
1083
+ const uuid = "aaaa9999-1111-2222-3333-444455556666";
1084
+ const { runner } = queueRunner([
1085
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
1086
+ { code: 0, stdout: "[]", stderr: "" },
1087
+ {
1088
+ code: 0,
1089
+ stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
1090
+ stderr: "",
1091
+ },
1092
+ { code: 0, stdout: "", stderr: "" },
1093
+ ]);
1094
+ const { spawner } = fakeSpawner(70200);
1095
+ const logs: string[] = [];
1096
+
1097
+ const code = await exposeCloudflareUp("our.parachute.computer", {
1098
+ runner,
1099
+ spawner,
1100
+ alive: () => false,
1101
+ kill: () => {},
1102
+ connectorPids: () => [],
1103
+ resolveHost: async () => ["104.16.0.1"],
1104
+ log: (l) => logs.push(l),
1105
+ manifestPath: env.manifestPath,
1106
+ statePath: env.statePath,
1107
+ exposeStatePath: env.exposeStatePath,
1108
+ configPath: env.configPath,
1109
+ logPath: env.logPath,
1110
+ cloudflaredHome: env.cloudflaredHome,
1111
+ configDir: env.configDir,
1112
+ skipHub: true,
1113
+ });
1114
+
1115
+ expect(code).toBe(0);
1116
+ expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
1117
+ } finally {
1118
+ env.cleanup();
1119
+ }
1120
+ });
1121
+
1122
+ test("legacy-sweep: drops a DEAD legacy 'parachute' record without killing, when migrating", async () => {
1123
+ const env = makeEnv();
1124
+ try {
1125
+ // A leftover shared-tunnel record whose connector is no longer running.
1126
+ const deadLegacy: CloudflaredTunnelRecord = {
1127
+ pid: 72001,
1128
+ tunnelUuid: "dead-legacy-uuid",
1129
+ tunnelName: "parachute",
1130
+ hostname: "our.parachute.computer",
1131
+ startedAt: "2026-05-01T00:00:00.000Z",
1132
+ configPath: "/tmp/legacy/parachute/config.yml",
1133
+ };
1134
+ writeCloudflaredState({ version: 2, tunnels: { parachute: deadLegacy } }, env.statePath);
1135
+
1136
+ const uuid = "cccc7777-1111-2222-3333-444455556666";
1137
+ const { runner } = queueRunner([
1138
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
1139
+ { code: 0, stdout: "[]", stderr: "" },
1140
+ {
1141
+ code: 0,
1142
+ stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
1143
+ stderr: "",
1144
+ },
1145
+ { code: 0, stdout: "", stderr: "" },
1146
+ ]);
1147
+ const { spawner } = fakeSpawner(72100);
1148
+ const killed: number[] = [];
1149
+ const logs: string[] = [];
1150
+
1151
+ const code = await exposeCloudflareUp("our.parachute.computer", {
1152
+ runner,
1153
+ spawner,
1154
+ alive: () => false, // nothing alive — including the dead legacy pid
1155
+ kill: (pid) => killed.push(pid),
1156
+ connectorPids: () => [],
1157
+ resolveHost: async () => ["104.16.0.1"],
1158
+ log: (l) => logs.push(l),
1159
+ manifestPath: env.manifestPath,
1160
+ statePath: env.statePath,
1161
+ exposeStatePath: env.exposeStatePath,
1162
+ configPath: env.configPath,
1163
+ logPath: env.logPath,
1164
+ cloudflaredHome: env.cloudflaredHome,
1165
+ configDir: env.configDir,
1166
+ skipHub: true,
1167
+ });
1168
+
1169
+ expect(code).toBe(0);
1170
+ // Connector wasn't alive → nothing killed, no sweep log.
1171
+ expect(killed).not.toContain(72001);
1172
+ expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
1173
+ // …but the stale dead record is cleared, leaving only the new derived one.
1174
+ const state = readCloudflaredState(env.statePath);
1175
+ expect(findTunnelRecord(state, "parachute")).toBeUndefined();
1176
+ expect(findTunnelRecord(state, "parachute-our-parachute-computer")?.pid).toBe(72100);
1177
+ } finally {
1178
+ env.cleanup();
1179
+ }
1180
+ });
1181
+
1182
+ test("legacy-sweep: does NOT fire when the derived name IS 'parachute' (no migration)", async () => {
1183
+ // A live "parachute" record AND an invocation that resolves to the
1184
+ // "parachute" name (here via explicit --tunnel-name parachute) must not
1185
+ // self-sweep — the connector we'd kill is the very one we're about to
1186
+ // reuse. Reuse-flow: queued list reports the parachute tunnel.
1187
+ const env = makeEnv();
1188
+ try {
1189
+ const uuid = "bbbb8888-1111-2222-3333-444455556666";
1190
+ const legacy: CloudflaredTunnelRecord = {
1191
+ pid: 71001,
1192
+ tunnelUuid: uuid,
1193
+ tunnelName: "parachute",
1194
+ hostname: "our.parachute.computer",
1195
+ startedAt: "2026-05-01T00:00:00.000Z",
1196
+ configPath: env.configPath,
1197
+ };
1198
+ writeCloudflaredState({ version: 2, tunnels: { parachute: legacy } }, env.statePath);
1199
+
1200
+ const { runner } = queueRunner([
1201
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
1202
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
1203
+ { code: 0, stdout: "", stderr: "" }, // route dns
1204
+ ]);
1205
+ const { spawner } = fakeSpawner(71100);
1206
+ const logs: string[] = [];
1207
+
1208
+ const code = await exposeCloudflareUp("our.parachute.computer", {
1209
+ runner,
1210
+ spawner,
1211
+ alive: () => true,
1212
+ kill: () => {},
1213
+ connectorPids: () => [],
1214
+ resolveHost: async () => ["104.16.0.1"],
1215
+ log: (l) => logs.push(l),
1216
+ manifestPath: env.manifestPath,
1217
+ statePath: env.statePath,
1218
+ exposeStatePath: env.exposeStatePath,
1219
+ configPath: env.configPath,
1220
+ logPath: env.logPath,
1221
+ cloudflaredHome: env.cloudflaredHome,
1222
+ configDir: env.configDir,
1223
+ skipHub: true,
1224
+ tunnelName: "parachute",
1225
+ });
1226
+
1227
+ expect(code).toBe(0);
1228
+ // No legacy-migration log line: we resolved TO "parachute", so there's
1229
+ // nothing to migrate away from.
1230
+ expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
1231
+ } finally {
1232
+ env.cleanup();
1233
+ }
1234
+ });
1235
+ });
1236
+
940
1237
  // 2FA-enrollment warning (#186). The cloudflare path is always public —
941
1238
  // every successful bringup makes /admin/login reachable on the open
942
1239
  // internet, where 2FA is the primary defense beyond #188's rate-limit floor.
@@ -1348,4 +1645,103 @@ describe("exposeCloudflareOff", () => {
1348
1645
  env.cleanup();
1349
1646
  }
1350
1647
  });
1648
+
1649
+ describe("#491: state-driven off (no --tunnel-name)", () => {
1650
+ test("0 tunnels → 'Nothing to tear down' (exit 0)", async () => {
1651
+ const env = makeEnv();
1652
+ try {
1653
+ const logs: string[] = [];
1654
+ const code = await exposeCloudflareOff({
1655
+ statePath: env.statePath,
1656
+ exposeStatePath: env.exposeStatePath,
1657
+ log: (l) => logs.push(l),
1658
+ });
1659
+ expect(code).toBe(0);
1660
+ expect(logs.join("\n")).toContain("Nothing to tear down");
1661
+ } finally {
1662
+ env.cleanup();
1663
+ }
1664
+ });
1665
+
1666
+ test("exactly 1 tunnel → tears it down by reading state (even a derived non-'parachute' name)", async () => {
1667
+ const env = makeEnv();
1668
+ try {
1669
+ const record: CloudflaredTunnelRecord = {
1670
+ pid: 80001,
1671
+ tunnelUuid: "derived-uuid",
1672
+ tunnelName: "parachute-our-parachute-computer",
1673
+ hostname: "our.parachute.computer",
1674
+ startedAt: "2026-05-20T10:00:00.000Z",
1675
+ configPath: "/tmp/derived/config.yml",
1676
+ };
1677
+ writeCloudflaredState(
1678
+ { version: 2, tunnels: { "parachute-our-parachute-computer": record } },
1679
+ env.statePath,
1680
+ );
1681
+
1682
+ const killed: number[] = [];
1683
+ const code = await exposeCloudflareOff({
1684
+ statePath: env.statePath,
1685
+ exposeStatePath: env.exposeStatePath,
1686
+ alive: () => true,
1687
+ kill: (pid) => killed.push(pid),
1688
+ log: () => {},
1689
+ // No tunnelName — resolved from state.
1690
+ });
1691
+ expect(code).toBe(0);
1692
+ expect(killed).toEqual([80001]);
1693
+ expect(existsSync(env.statePath)).toBe(false);
1694
+ } finally {
1695
+ env.cleanup();
1696
+ }
1697
+ });
1698
+
1699
+ test("≥2 tunnels → tears down ALL of them and lists each", async () => {
1700
+ const env = makeEnv();
1701
+ try {
1702
+ const recordA: CloudflaredTunnelRecord = {
1703
+ pid: 81001,
1704
+ tunnelUuid: "aaaa-uuid",
1705
+ tunnelName: "parachute-alpha-example-com",
1706
+ hostname: "alpha.example.com",
1707
+ startedAt: "2026-05-20T10:00:00.000Z",
1708
+ configPath: "/tmp/alpha/config.yml",
1709
+ };
1710
+ const recordB: CloudflaredTunnelRecord = {
1711
+ pid: 81002,
1712
+ tunnelUuid: "bbbb-uuid",
1713
+ tunnelName: "parachute-beta-example-com",
1714
+ hostname: "beta.example.com",
1715
+ startedAt: "2026-05-20T11:00:00.000Z",
1716
+ configPath: "/tmp/beta/config.yml",
1717
+ };
1718
+ writeCloudflaredState(
1719
+ withTunnelRecord(withTunnelRecord(undefined, recordA), recordB),
1720
+ env.statePath,
1721
+ );
1722
+
1723
+ const killed: number[] = [];
1724
+ const logs: string[] = [];
1725
+ const code = await exposeCloudflareOff({
1726
+ statePath: env.statePath,
1727
+ exposeStatePath: env.exposeStatePath,
1728
+ alive: () => true,
1729
+ kill: (pid) => killed.push(pid),
1730
+ log: (l) => logs.push(l),
1731
+ // No tunnelName — bare `off` means "stop all public Cloudflare exposure".
1732
+ });
1733
+ expect(code).toBe(0);
1734
+ // Both connectors stopped.
1735
+ expect(killed.sort()).toEqual([81001, 81002]);
1736
+ // State fully cleared (no tunnels remain).
1737
+ expect(existsSync(env.statePath)).toBe(false);
1738
+ const joined = logs.join("\n");
1739
+ expect(joined).toContain("Tearing down all 2 recorded Cloudflare tunnels");
1740
+ expect(joined).toContain("parachute-alpha-example-com");
1741
+ expect(joined).toContain("parachute-beta-example-com");
1742
+ } finally {
1743
+ env.cleanup();
1744
+ }
1745
+ });
1746
+ });
1351
1747
  });