@mycadet/cli 0.12.7-dev.202610111049.d2b52a8 → 0.12.8

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.
Files changed (30) hide show
  1. package/node_modules/@mycadet/assistant-api/events/cloud-disconnected.ts +2 -2
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
  3. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
  4. package/node_modules/@mycadet/service-contracts/src/ingress.ts +10 -4
  5. package/package.json +4 -3
  6. package/src/__tests__/{cloudflare-tunnel.test.ts → cloudflare.test.ts} +1 -1
  7. package/src/__tests__/ps-cloud-status.test.ts +53 -0
  8. package/src/__tests__/retire.test.ts +37 -178
  9. package/src/__tests__/root-process-lockfile.test.ts +99 -0
  10. package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +1 -1
  11. package/src/__tests__/tunnel.test.ts +127 -80
  12. package/src/__tests__/upgrade-local.test.ts +3 -3
  13. package/src/__tests__/wake.test.ts +16 -9
  14. package/src/commands/client.ts +1 -1
  15. package/src/commands/ps.ts +181 -72
  16. package/src/commands/retire.ts +28 -137
  17. package/src/commands/tunnel.ts +76 -118
  18. package/src/commands/upgrade.ts +2 -9
  19. package/src/commands/wake.ts +5 -9
  20. package/src/lib/assistant-config.ts +53 -0
  21. package/src/lib/detached-process.ts +1 -1
  22. package/src/lib/ingress-config.ts +7 -34
  23. package/src/lib/local.ts +6 -27
  24. package/src/lib/nginx-ingress.ts +29 -8
  25. package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
  26. package/src/lib/tunnels/cadet.ts +29 -0
  27. package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +15 -4
  28. package/src/lib/tunnels/gateway-notify.ts +61 -0
  29. package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +200 -29
  30. package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +19 -5
@@ -25,42 +25,60 @@ import { join } from "node:path";
25
25
  import { TUNNEL_PROVIDERS } from "@mycadet/service-contracts/ingress";
26
26
  import { z } from "zod";
27
27
 
28
- import * as cloudflareTunnel from "../lib/cloudflare-tunnel.js";
29
- import * as ngrok from "../lib/ngrok.js";
28
+ import * as cloudflareTunnel from "../lib/tunnels/cloudflare.js";
29
+ import * as gatewayNotify from "../lib/tunnels/gateway-notify.js";
30
+ import * as ngrok from "../lib/tunnels/ngrok.js";
30
31
  import * as nginxIngress from "../lib/nginx-ingress.js";
31
- import * as tailscaleTunnel from "../lib/tailscale-tunnel.js";
32
+ import * as tailscaleTunnel from "../lib/tunnels/tailscale.js";
32
33
  import { getLogDir } from "../lib/xdg-log.js";
33
34
  import type { AssistantEntry } from "../lib/assistant-config.js";
34
35
  import { withPreconnect } from "./helpers/http.js";
35
36
 
36
37
  const realCloudflareTunnel = { ...cloudflareTunnel };
38
+ const realGatewayNotify = { ...gatewayNotify };
37
39
  const realNgrok = { ...ngrok };
38
40
  const realNginxIngress = { ...nginxIngress };
39
41
  const realTailscaleTunnel = { ...tailscaleTunnel };
40
42
  const realChildProcess = { ...childProcess };
41
43
 
44
+ /** What a mocked provider run hands back: a URL and an already-settled exit. */
45
+ const fakeTunnelResult = (publicUrl: string) => ({
46
+ publicUrl,
47
+ waitForExit: Promise.resolve(),
48
+ });
49
+
42
50
  const runCloudflareTunnelMock = mock<
43
51
  typeof cloudflareTunnel.runCloudflareTunnel
44
- >(async () => {});
45
- mock.module("../lib/cloudflare-tunnel.js", () => ({
52
+ >(async () => fakeTunnelResult("https://test.example.cloudflare.dev"));
53
+ mock.module("../lib/tunnels/cloudflare.js", () => ({
46
54
  ...realCloudflareTunnel,
47
55
  runCloudflareTunnel: runCloudflareTunnelMock,
48
56
  }));
49
57
 
50
- const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () => {});
51
- mock.module("../lib/ngrok", () => ({
58
+ const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () =>
59
+ fakeTunnelResult("https://test.example.ngrok.dev"),
60
+ );
61
+ mock.module("../lib/tunnels/ngrok", () => ({
52
62
  ...realNgrok,
53
63
  runNgrokTunnel: runNgrokTunnelMock,
54
64
  }));
55
65
 
56
66
  const runTailscaleTunnelMock = mock<typeof tailscaleTunnel.runTailscaleTunnel>(
57
- async () => {},
67
+ async () => fakeTunnelResult("https://test-machine.tailnet.ts.net"),
58
68
  );
59
- mock.module("../lib/tailscale-tunnel.js", () => ({
69
+ mock.module("../lib/tunnels/tailscale.js", () => ({
60
70
  ...realTailscaleTunnel,
61
71
  runTailscaleTunnel: runTailscaleTunnelMock,
62
72
  }));
63
73
 
74
+ const notifyGatewayOfPublicUrlMock = mock<
75
+ typeof gatewayNotify.notifyGatewayOfPublicUrl
76
+ >(async () => {});
77
+ mock.module("../lib/tunnels/gateway-notify.js", () => ({
78
+ ...realGatewayNotify,
79
+ notifyGatewayOfPublicUrl: notifyGatewayOfPublicUrlMock,
80
+ }));
81
+
64
82
  const EDGE_PORT = 18080;
65
83
 
66
84
  const ensureTunnelEdgeMock = mock<typeof nginxIngress.ensureTunnelEdge>(
@@ -75,6 +93,7 @@ const { tunnel } = await import("../commands/tunnel.js");
75
93
 
76
94
  const originalArgv = [...process.argv];
77
95
  const originalFetch = globalThis.fetch;
96
+ const originalConfigHome = process.env.XDG_CONFIG_HOME;
78
97
  const originalLockfileDir = process.env.CADET_LOCKFILE_DIR;
79
98
  const originalWorkspaceDir = process.env.CADET_WORKSPACE_DIR;
80
99
  const tempDirs: string[] = [];
@@ -227,12 +246,24 @@ describe("tunnel edge targeting", () => {
227
246
  globalThis.fetch = withPreconnect(async () => {
228
247
  throw new Error("gateway unavailable");
229
248
  });
249
+ // The ngrok config file lives under the XDG config dir; a fresh one per
250
+ // test keeps the saved-domain state hermetic.
251
+ const configHome = mkdtempSync(join(tmpdir(), "cadet-tunnel-xdg-"));
252
+ tempDirs.push(configHome);
253
+ process.env.XDG_CONFIG_HOME = configHome;
230
254
  runCloudflareTunnelMock.mockReset();
231
- runCloudflareTunnelMock.mockResolvedValue(undefined);
255
+ runCloudflareTunnelMock.mockResolvedValue(
256
+ fakeTunnelResult("https://test.example.cloudflare.dev"),
257
+ );
232
258
  runNgrokTunnelMock.mockReset();
233
- runNgrokTunnelMock.mockResolvedValue(undefined);
259
+ runNgrokTunnelMock.mockResolvedValue(
260
+ fakeTunnelResult("https://test.example.ngrok.dev"),
261
+ );
234
262
  runTailscaleTunnelMock.mockReset();
235
- runTailscaleTunnelMock.mockResolvedValue(undefined);
263
+ runTailscaleTunnelMock.mockResolvedValue(
264
+ fakeTunnelResult("https://test-machine.tailnet.ts.net"),
265
+ );
266
+ notifyGatewayOfPublicUrlMock.mockReset();
236
267
  ensureTunnelEdgeMock.mockReset();
237
268
  ensureTunnelEdgeMock.mockResolvedValue({
238
269
  port: EDGE_PORT,
@@ -244,6 +275,12 @@ describe("tunnel edge targeting", () => {
244
275
  afterEach(() => {
245
276
  process.argv = originalArgv;
246
277
  globalThis.fetch = originalFetch;
278
+ rmSync(realNgrok.ngrokConfigPath(), { force: true });
279
+ if (originalConfigHome === undefined) {
280
+ delete process.env.XDG_CONFIG_HOME;
281
+ } else {
282
+ process.env.XDG_CONFIG_HOME = originalConfigHome;
283
+ }
247
284
  if (originalLockfileDir === undefined) {
248
285
  delete process.env.CADET_LOCKFILE_DIR;
249
286
  } else {
@@ -260,9 +297,9 @@ describe("tunnel edge targeting", () => {
260
297
  });
261
298
 
262
299
  afterAll(() => {
263
- mock.module("../lib/cloudflare-tunnel.js", () => realCloudflareTunnel);
264
- mock.module("../lib/ngrok", () => realNgrok);
265
- mock.module("../lib/tailscale-tunnel.js", () => realTailscaleTunnel);
300
+ mock.module("../lib/tunnels/cloudflare.js", () => realCloudflareTunnel);
301
+ mock.module("../lib/tunnels/ngrok", () => realNgrok);
302
+ mock.module("../lib/tunnels/tailscale.js", () => realTailscaleTunnel);
266
303
  mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
267
304
  });
268
305
 
@@ -290,6 +327,20 @@ describe("tunnel edge targeting", () => {
290
327
  expect(logs).toContain("serves remote web + webhooks");
291
328
  });
292
329
 
330
+ test("the tunnel level notifies the gateway of the provider's public URL", async () => {
331
+ writeLockfile(makeLocalEntry());
332
+ process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
333
+
334
+ await runTunnelCapturingLogs();
335
+
336
+ // The provider hands its URL back; the notification is the tunnel
337
+ // level's job, so it holds no matter which provider ran.
338
+ expect(notifyGatewayOfPublicUrlMock).toHaveBeenCalledWith(
339
+ "https://test.example.ngrok.dev",
340
+ "assistant-1",
341
+ );
342
+ });
343
+
293
344
  test("an active cloud assistant falls back to the sole local entry with a note", async () => {
294
345
  const cloud = makeCloudEntry();
295
346
  const local = makeLocalEntry();
@@ -625,46 +676,40 @@ describe("tunnel edge targeting", () => {
625
676
  expect(logs).toContain("serves webhooks only");
626
677
  });
627
678
 
628
- test("a bare invocation exits listing the providers to choose from", async () => {
629
- writeLockfile(makeLocalEntry());
679
+ test("a bare invocation tunnels the default provider", async () => {
680
+ const entry = makeLocalEntry();
681
+ writeLockfile(entry);
630
682
  process.argv = ["bun", "cadet", "tunnel"];
631
683
 
632
- const { exited, errors } = await runTunnelExpectingExit1();
684
+ const logs = await runTunnelCapturingLogs();
633
685
 
634
- expect(exited).toBe(true);
635
- expect(errors).toContain("--provider is required");
636
- expect(errors).toContain("cadet tunnel --provider ngrok");
637
- expect(errors).toContain("cadet tunnel --provider cloudflare");
638
- expect(errors).toContain("cadet tunnel --provider tailscale");
639
- // Nothing is started and nothing is recorded until the user has chosen.
640
- expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
686
+ expect(logs).toContain("Using the default provider: ngrok");
687
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
688
+ port: EDGE_PORT,
689
+ assistantId: "assistant-1",
690
+ workspaceDir: join(entry.resources!.instanceDir, "workspace"),
691
+ });
692
+ expect(ensureTunnelEdgeMock).toHaveBeenCalled();
641
693
  expect(runTailscaleTunnelMock).not.toHaveBeenCalled();
642
- expect(runNgrokTunnelMock).not.toHaveBeenCalled();
643
694
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
644
695
  });
645
696
 
646
- test("a named assistant without --provider exits before resolving it", async () => {
647
- writeLockfile(makeLocalEntry());
697
+ test("a named assistant without --provider tunnels it with the default provider", async () => {
698
+ const entry = makeLocalEntry();
699
+ entry.name = "Ada";
700
+ writeLockfile(entry);
648
701
  process.argv = ["bun", "cadet", "tunnel", "Ada"];
649
702
 
650
- const { exited, errors } = await runTunnelExpectingExit1();
651
-
652
- expect(exited).toBe(true);
653
- expect(errors).toContain("--provider is required");
654
- // The suggestions keep the assistant that was asked for, so pasting one
655
- // cannot tunnel the active assistant instead.
656
- expect(errors).toContain("cadet tunnel Ada --provider ngrok");
657
- expect(errors).toContain("cadet tunnel Ada --provider tailscale");
658
- expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
659
- });
660
-
661
- test("a shell-sensitive assistant name is quoted in the suggestions", async () => {
662
- writeLockfile(makeLocalEntry());
663
- process.argv = ["bun", "cadet", "tunnel", "Bob&Alice"];
664
-
665
- const { errors } = await runTunnelExpectingExit1();
703
+ const logs = await runTunnelCapturingLogs();
666
704
 
667
- expect(errors).toContain("cadet tunnel 'Bob&Alice' --provider ngrok");
705
+ expect(logs).toContain("Using the default provider: ngrok");
706
+ // The assistant that was asked for is the one tunneled.
707
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
708
+ port: EDGE_PORT,
709
+ assistantId: entry.assistantId,
710
+ workspaceDir: join(entry.resources!.instanceDir, "workspace"),
711
+ });
712
+ expect(ensureTunnelEdgeMock).toHaveBeenCalled();
668
713
  });
669
714
 
670
715
  test("--provider tailscale keeps the webhook callback base and says so", async () => {
@@ -819,7 +864,7 @@ describe("tunnel edge targeting", () => {
819
864
 
820
865
  expect(exited).toBe(true);
821
866
  expect(errors).toContain(
822
- "--domain is only supported with --provider ngrok",
867
+ "--domain is not supported with --provider cloudflare",
823
868
  );
824
869
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
825
870
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
@@ -830,10 +875,7 @@ describe("tunnel edge targeting", () => {
830
875
  writeLockfile(entry);
831
876
  const workspaceDir = join(entry.resources!.instanceDir, "workspace");
832
877
  mkdirSync(workspaceDir, { recursive: true });
833
- writeFileSync(
834
- join(workspaceDir, "config.json"),
835
- JSON.stringify({ ingress: { ngrok: { domain: "dead.ngrok.app" } } }),
836
- );
878
+ realNgrok.saveNgrokDomain("dead.ngrok.app");
837
879
  process.argv = [
838
880
  "bun",
839
881
  "cadet",
@@ -845,11 +887,9 @@ describe("tunnel edge targeting", () => {
845
887
 
846
888
  const logs = await runTunnelCapturingLogs();
847
889
 
848
- expect(logs).toContain("Cleared the saved ngrok domain");
849
- const config = IngressTestConfigSchema.parse(
850
- JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8")),
851
- );
852
- expect(config.ingress?.ngrok).toBeUndefined();
890
+ expect(logs).toContain("Cleared the saved reserved domain");
891
+ // The saved domain lives in the XDG ngrok config, not the workspace config.
892
+ expect(realNgrok.loadNgrokDomain()).toBeNull();
853
893
  // The run proceeds domainless: no `domain` key reaches runNgrokTunnel.
854
894
  expect(runNgrokTunnelMock).toHaveBeenCalledWith({
855
895
  port: EDGE_PORT,
@@ -891,7 +931,7 @@ describe("tunnel edge targeting", () => {
891
931
 
892
932
  expect(exited).toBe(true);
893
933
  expect(errors).toContain(
894
- "--clear-domain is only supported with --provider ngrok",
934
+ "--clear-domain is not supported with --provider cloudflare",
895
935
  );
896
936
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
897
937
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
@@ -975,11 +1015,22 @@ describe("ngrok --domain spawn args", () => {
975
1015
  spawnMock.mockClear();
976
1016
  lastChild = null;
977
1017
  delete process.env.IS_CONTAINERIZED;
1018
+ // The ngrok config file lives under the XDG config dir; a fresh one per
1019
+ // test keeps the saved-domain state hermetic.
1020
+ const configHome = mkdtempSync(join(tmpdir(), "cadet-ngrok-xdg-"));
1021
+ tempDirs.push(configHome);
1022
+ process.env.XDG_CONFIG_HOME = configHome;
978
1023
  mockNgrokApiFetch([{ tunnels: [] }]);
979
1024
  });
980
1025
 
981
1026
  afterEach(() => {
982
1027
  globalThis.fetch = originalFetch;
1028
+ rmSync(realNgrok.ngrokConfigPath(), { force: true });
1029
+ if (originalConfigHome === undefined) {
1030
+ delete process.env.XDG_CONFIG_HOME;
1031
+ } else {
1032
+ process.env.XDG_CONFIG_HOME = originalConfigHome;
1033
+ }
983
1034
  if (originalContainerized === undefined) {
984
1035
  delete process.env.IS_CONTAINERIZED;
985
1036
  } else {
@@ -1089,17 +1140,18 @@ describe("ngrok --domain spawn args", () => {
1089
1140
  const [cmd, args] = spawnMock.mock.calls[0];
1090
1141
  expect(cmd).toBe("ngrok");
1091
1142
  expect(args).toEqual([
1092
- "http",
1093
- "127.0.0.1:7831",
1143
+ "start",
1144
+ "cadet",
1145
+ `--config=${realNgrok.ngrokConfigPath()}`,
1094
1146
  "--log=stdout",
1095
- "--domain=foo.ngrok.app",
1096
1147
  ]);
1148
+ // The reserved domain rides the XDG config the agent loads, not a flag.
1149
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1097
1150
 
1098
1151
  const config = IngressTestConfigSchema.parse(
1099
1152
  JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1100
1153
  );
1101
1154
  expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1102
- expect(config.ingress?.ngrok?.domain).toBe("foo.ngrok.app");
1103
1155
  });
1104
1156
 
1105
1157
  function mockTunnelListFetch(publicUrl: string, addr: string): void {
@@ -1188,14 +1240,12 @@ describe("ngrok --domain spawn args", () => {
1188
1240
  JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1189
1241
  );
1190
1242
  expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1191
- expect(config.ingress?.ngrok?.domain).toBe("foo.ngrok.app");
1243
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1192
1244
  });
1193
1245
 
1194
1246
  test("maybeStartNgrokTunnel rejects a mismatched existing tunnel without adopting or spawning", async () => {
1195
- const ws = makeWorkspace({
1196
- telegram: { botUsername: "example_bot" },
1197
- ingress: { ngrok: { domain: "foo.ngrok.app" } },
1198
- });
1247
+ const ws = makeWorkspace({ telegram: { botUsername: "example_bot" } });
1248
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1199
1249
  mockTunnelListFetch("https://other.ngrok-free.app", "localhost:7830");
1200
1250
 
1201
1251
  const warnings: string[] = [];
@@ -1229,7 +1279,7 @@ describe("ngrok --domain spawn args", () => {
1229
1279
  // The mismatched tunnel is not blessed in config…
1230
1280
  expect(config.ingress?.publicBaseUrl).toBeUndefined();
1231
1281
  // …and the reserved domain stays saved as standing intent.
1232
- expect(config.ingress?.ngrok?.domain).toBe("foo.ngrok.app");
1282
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1233
1283
  });
1234
1284
 
1235
1285
  test("an adopted automatic tunnel records the assistant it fronts", async () => {
@@ -1359,10 +1409,8 @@ describe("ngrok --domain spawn args", () => {
1359
1409
  });
1360
1410
 
1361
1411
  test("maybeStartNgrokTunnel passes the saved domain to the spawn args", async () => {
1362
- const ws = makeWorkspace({
1363
- telegram: { botUsername: "example_bot" },
1364
- ingress: { ngrok: { domain: "foo.ngrok.app" } },
1365
- });
1412
+ const ws = makeWorkspace({ telegram: { botUsername: "example_bot" } });
1413
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1366
1414
  mockNgrokApiFetch([
1367
1415
  { tunnels: [] },
1368
1416
  {
@@ -1383,10 +1431,10 @@ describe("ngrok --domain spawn args", () => {
1383
1431
  const [cmd, args] = spawnMock.mock.calls[0];
1384
1432
  expect(cmd).toBe("ngrok");
1385
1433
  expect(args).toEqual([
1386
- "http",
1387
- "127.0.0.1:7830",
1434
+ "start",
1435
+ "cadet",
1436
+ `--config=${realNgrok.ngrokConfigPath()}`,
1388
1437
  "--log=stdout",
1389
- "--domain=foo.ngrok.app",
1390
1438
  ]);
1391
1439
 
1392
1440
  const config = IngressTestConfigSchema.parse(
@@ -1396,9 +1444,8 @@ describe("ngrok --domain spawn args", () => {
1396
1444
  });
1397
1445
 
1398
1446
  test("runNgrokTunnel without --domain reuses the saved domain and does not delete it", async () => {
1399
- const ws = makeWorkspace({
1400
- ingress: { ngrok: { domain: "foo.ngrok.app" } },
1401
- });
1447
+ const ws = makeWorkspace({});
1448
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1402
1449
  mockNgrokApiFetch([
1403
1450
  { tunnels: [] },
1404
1451
  {
@@ -1422,17 +1469,17 @@ describe("ngrok --domain spawn args", () => {
1422
1469
  expect(spawnMock).toHaveBeenCalledTimes(1);
1423
1470
  const [, args] = spawnMock.mock.calls[0];
1424
1471
  expect(args).toEqual([
1425
- "http",
1426
- "127.0.0.1:7831",
1472
+ "start",
1473
+ "cadet",
1474
+ `--config=${realNgrok.ngrokConfigPath()}`,
1427
1475
  "--log=stdout",
1428
- "--domain=foo.ngrok.app",
1429
1476
  ]);
1430
1477
 
1431
1478
  const config = IngressTestConfigSchema.parse(
1432
1479
  JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1433
1480
  );
1434
1481
  expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1435
- expect(config.ingress?.ngrok?.domain).toBe("foo.ngrok.app");
1482
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1436
1483
  });
1437
1484
  });
1438
1485
 
@@ -20,7 +20,7 @@ import type { GuardianTokenData } from "../lib/guardian-token.js";
20
20
  import * as guardianToken from "../lib/guardian-token.js";
21
21
  import * as local from "../lib/local.js";
22
22
  import * as loopbackFetch from "../lib/loopback-fetch.js";
23
- import * as ngrok from "../lib/ngrok.js";
23
+ import * as ngrok from "../lib/tunnels/ngrok.js";
24
24
  import * as upgradeLifecycle from "../lib/upgrade-lifecycle.js";
25
25
 
26
26
  const realAssistantConfig = { ...assistantConfig };
@@ -157,7 +157,7 @@ const maybeStartNgrokTunnelMock = mock<typeof ngrok.maybeStartNgrokTunnel>(
157
157
  async () => null,
158
158
  );
159
159
 
160
- mock.module("../lib/ngrok.js", () => ({
160
+ mock.module("../lib/tunnels/ngrok.js", () => ({
161
161
  ...realNgrok,
162
162
  maybeStartNgrokTunnel: maybeStartNgrokTunnelMock,
163
163
  }));
@@ -302,7 +302,7 @@ afterAll(() => {
302
302
  mock.module("../lib/guardian-token.js", () => realGuardianToken);
303
303
  mock.module("../lib/local.js", () => realLocal);
304
304
  mock.module("../lib/loopback-fetch.js", () => realLoopbackFetch);
305
- mock.module("../lib/ngrok.js", () => realNgrok);
305
+ mock.module("../lib/tunnels/ngrok.js", () => realNgrok);
306
306
  mock.module("../lib/upgrade-lifecycle.js", () => realUpgradeLifecycle);
307
307
  });
308
308
 
@@ -20,7 +20,7 @@ import * as httpClient from "../lib/http-client.js";
20
20
  import * as ingressConfig from "../lib/ingress-config.js";
21
21
  import * as local from "../lib/local.js";
22
22
  import * as nginxIngress from "../lib/nginx-ingress.js";
23
- import * as ngrok from "../lib/ngrok.js";
23
+ import * as ngrok from "../lib/tunnels/ngrok.js";
24
24
  import * as processLib from "../lib/process.js";
25
25
  import * as portAllocator from "../lib/port-allocator.js";
26
26
  import type { AssistantEntry } from "../lib/assistant-config.js";
@@ -157,7 +157,7 @@ const maybeStartNgrokTunnelMock = mock<typeof ngrok.maybeStartNgrokTunnel>(
157
157
  async () => null,
158
158
  );
159
159
 
160
- mock.module("../lib/ngrok", () => ({
160
+ mock.module("../lib/tunnels/ngrok", () => ({
161
161
  ...realNgrok,
162
162
  maybeStartNgrokTunnel: maybeStartNgrokTunnelMock,
163
163
  }));
@@ -329,7 +329,7 @@ afterAll(() => {
329
329
  mock.module("../lib/process", () => realProcessLib);
330
330
  mock.module("../lib/port-allocator.js", () => realPortAllocator);
331
331
  mock.module("../lib/local", () => realLocal);
332
- mock.module("../lib/ngrok", () => realNgrok);
332
+ mock.module("../lib/tunnels/ngrok", () => realNgrok);
333
333
  mock.module("../lib/ingress-config.js", () => realIngressConfig);
334
334
  mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
335
335
  mock.module("../lib/http-client.js", () => realHttpClient);
@@ -956,9 +956,13 @@ describe("cadet wake — tunnel edge restore", () => {
956
956
  workspaceDir: defaultWorkspace,
957
957
  gatewayPort: 7930,
958
958
  });
959
- // Container wakes never tracked a spawned ngrok PID, so the auto-tunnel
960
- // stays out of this path.
961
- expect(maybeStartNgrokTunnelMock).not.toHaveBeenCalled();
959
+ // Container wakes run the same auto-tunnel as local wakes (docker
960
+ // parity); the spawned agent records itself in the root lockfile.
961
+ expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
962
+ 7840,
963
+ defaultWorkspace,
964
+ "docker-1",
965
+ );
962
966
  expect(logSpy).toHaveBeenCalledWith("Wake complete.");
963
967
  } finally {
964
968
  if (originalWorkspaceDir === undefined) {
@@ -1093,7 +1097,7 @@ describe("cadet wake — tunnel edge restore", () => {
1093
1097
  }
1094
1098
  });
1095
1099
 
1096
- test("a failed docker edge restore does not claim webhooks still work", async () => {
1100
+ test("a failed docker edge restore falls back to the gateway tunnel", async () => {
1097
1101
  const defaultWorkspace = mkdtempSync(join(tmpdir(), "cadet-wake-default-"));
1098
1102
  const originalWorkspaceDir = process.env.CADET_WORKSPACE_DIR;
1099
1103
  process.env.CADET_WORKSPACE_DIR = defaultWorkspace;
@@ -1112,11 +1116,14 @@ describe("cadet wake — tunnel edge restore", () => {
1112
1116
  try {
1113
1117
  await wake();
1114
1118
 
1119
+ // Docker wakes get the same tunnel logic as local ones: when the edge
1120
+ // cannot be rebuilt, the auto-tunnel still fronts the gateway port so
1121
+ // webhook delivery survives.
1115
1122
  expect(warnSpy).toHaveBeenCalledWith(
1116
- expect.stringContaining("webhook delivery are unavailable"),
1123
+ expect.stringContaining("Webhooks still work"),
1117
1124
  );
1118
1125
  expect(warnSpy).not.toHaveBeenCalledWith(
1119
- expect.stringContaining("Webhooks still work"),
1126
+ expect.stringContaining("webhook delivery are unavailable"),
1120
1127
  );
1121
1128
  } finally {
1122
1129
  if (originalWorkspaceDir === undefined) {
@@ -1223,7 +1223,7 @@ const WEB_BACKGROUND_START_TIMEOUT_MS = 30_000;
1223
1223
  * The port is resolved up front (explicit --port must be free; otherwise the
1224
1224
  * first free port at/above 3000) and pinned via `--port` on the child so the
1225
1225
  * URL printed here is the one the child binds. The child's stdout/stderr go to
1226
- * `<xdg-log-dir>/client-web.log` — same detach idiom as the nginx/ngrok
1226
+ * `<xdg-log-dir>/client-web.log` — same detach idiom as the nginx/tunnel
1227
1227
  * spawns. Success is only reported once the child is accepting connections on
1228
1228
  * the port; an early child exit (e.g. missing @mycadet/web assets) or a
1229
1229
  * startup timeout fails with a pointer at the log file.