@mycadet/cli 0.12.7 → 0.12.9-dev.202610131452.9acab64

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 (185) hide show
  1. package/README.md +2 -2
  2. package/knip.json +1 -1
  3. package/node_modules/@mycadet/assistant-api/events/cloud-disconnected.ts +21 -0
  4. package/node_modules/@mycadet/assistant-api/events/host-bash.ts +1 -1
  5. package/node_modules/@mycadet/assistant-api/events/host-cu.ts +1 -1
  6. package/node_modules/@mycadet/assistant-api/events/host-file.ts +1 -1
  7. package/node_modules/@mycadet/assistant-api/events/notification-intent.ts +1 -1
  8. package/node_modules/@mycadet/assistant-api/events/show-cloud-login.ts +18 -0
  9. package/node_modules/@mycadet/assistant-api/index.ts +11 -11
  10. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/package.json +2 -2
  11. package/node_modules/@mycadet/{service-contracts/src/platform-credential.ts → assistant-api/node_modules/@mycadet/service-contracts/src/cloud-credential.ts} +9 -9
  12. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
  13. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
  14. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
  15. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
  16. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
  17. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
  18. package/node_modules/@mycadet/assistant-api/responses/home.ts +1 -1
  19. package/node_modules/@mycadet/assistant-api/surfaces.ts +4 -4
  20. package/node_modules/@mycadet/avatar-manifest/src/__tests__/notification-avatar.test.ts +1 -1
  21. package/node_modules/@mycadet/avatar-manifest/src/notification-avatar.ts +2 -2
  22. package/node_modules/@mycadet/environments/src/seeds.ts +6 -6
  23. package/node_modules/@mycadet/environments/src/shell.test.ts +1 -1
  24. package/node_modules/@mycadet/environments/src/types.ts +6 -6
  25. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/avatar-manifest/src/__tests__/notification-avatar.test.ts +1 -1
  26. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/avatar-manifest/src/notification-avatar.ts +2 -2
  27. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/seeds.ts +6 -6
  28. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/shell.test.ts +1 -1
  29. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/types.ts +6 -6
  30. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/package.json +2 -2
  31. package/node_modules/@mycadet/{assistant-api/node_modules/@mycadet/service-contracts/src/platform-credential.ts → local-mode/node_modules/@mycadet/service-contracts/src/cloud-credential.ts} +9 -9
  32. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
  33. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
  34. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
  35. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
  36. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
  37. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
  38. package/node_modules/@mycadet/local-mode/src/__tests__/pair.test.ts +5 -5
  39. package/node_modules/@mycadet/local-mode/src/__tests__/retire.test.ts +1 -1
  40. package/node_modules/@mycadet/local-mode/src/config.ts +2 -2
  41. package/node_modules/@mycadet/local-mode/src/index.ts +1 -1
  42. package/node_modules/@mycadet/local-mode/src/lockfile-contract.test.ts +3 -3
  43. package/node_modules/@mycadet/local-mode/src/lockfile-contract.ts +10 -5
  44. package/node_modules/@mycadet/local-mode/src/lockfile.test.ts +15 -15
  45. package/node_modules/@mycadet/local-mode/src/lockfile.ts +9 -9
  46. package/node_modules/@mycadet/local-mode/src/pair.ts +2 -2
  47. package/node_modules/@mycadet/local-mode/src/retire.ts +3 -3
  48. package/node_modules/@mycadet/local-mode/src/teleport-backup-policy.ts +2 -2
  49. package/node_modules/@mycadet/local-mode/src/unpair.ts +2 -2
  50. package/node_modules/@mycadet/service-contracts/package.json +2 -2
  51. package/node_modules/@mycadet/{local-mode/node_modules/@mycadet/service-contracts/src/platform-credential.ts → service-contracts/src/cloud-credential.ts} +9 -9
  52. package/node_modules/@mycadet/service-contracts/src/handles.ts +3 -3
  53. package/node_modules/@mycadet/service-contracts/src/index.ts +1 -1
  54. package/node_modules/@mycadet/service-contracts/src/ingress.ts +11 -5
  55. package/node_modules/@mycadet/service-contracts/src/remote-web-pairing.ts +1 -1
  56. package/node_modules/@mycadet/service-contracts/src/rpc.ts +14 -14
  57. package/node_modules/@mycadet/service-contracts/src/trust-rules.ts +5 -5
  58. package/package.json +6 -4
  59. package/src/__tests__/assistant-api-key-resolution.test.ts +6 -6
  60. package/src/__tests__/{assistant-client-platform-auth.test.ts → assistant-client-cloud-auth.test.ts} +10 -10
  61. package/src/__tests__/assistant-client-refresh.test.ts +2 -2
  62. package/src/__tests__/backup.test.ts +86 -91
  63. package/src/__tests__/client-token.test.ts +1 -1
  64. package/src/__tests__/client-tui-refresh.test.ts +1 -1
  65. package/src/__tests__/client-web-config.test.ts +1 -2
  66. package/src/__tests__/{platform-client.test.ts → cloud-client.test.ts} +59 -59
  67. package/src/__tests__/{platform-releases.test.ts → cloud-releases.test.ts} +28 -18
  68. package/src/__tests__/{cloudflare-tunnel.test.ts → cloudflare.test.ts} +1 -1
  69. package/src/__tests__/connect-import.test.ts +55 -39
  70. package/src/__tests__/debug.test.ts +38 -38
  71. package/src/__tests__/ensure-binary.test.ts +182 -0
  72. package/src/__tests__/guardian-token.test.ts +149 -56
  73. package/src/__tests__/hatch-provider-secrets.test.ts +2 -2
  74. package/src/__tests__/helpers/guardian-token.ts +22 -0
  75. package/src/__tests__/helpers/http.ts +10 -0
  76. package/src/__tests__/nginx-ingress.test.ts +15 -20
  77. package/src/__tests__/ngrok-spawn.test.ts +49 -0
  78. package/src/__tests__/orphan-detection.test.ts +1 -1
  79. package/src/__tests__/pair.test.ts +149 -85
  80. package/src/__tests__/{ps-platform-status.test.ts → ps-cloud-status.test.ts} +80 -26
  81. package/src/__tests__/ps-table.test.ts +79 -0
  82. package/src/__tests__/{recover.test.ts → restore.test.ts} +29 -28
  83. package/src/__tests__/retire.test.ts +58 -199
  84. package/src/__tests__/root-process-lockfile.test.ts +99 -0
  85. package/src/__tests__/sleep.test.ts +19 -19
  86. package/src/__tests__/statefulset.test.ts +8 -8
  87. package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +2 -2
  88. package/src/__tests__/teleport-backup.test.ts +3 -3
  89. package/src/__tests__/teleport.test.ts +180 -211
  90. package/src/__tests__/tunnel.test.ts +490 -270
  91. package/src/__tests__/uninstall.test.ts +150 -9
  92. package/src/__tests__/upgrade-local.test.ts +3 -3
  93. package/src/__tests__/upgrade-replay-env.test.ts +12 -12
  94. package/src/__tests__/wake.test.ts +260 -43
  95. package/src/__tests__/workos-pkce.test.ts +2 -2
  96. package/src/commands/backup.ts +24 -24
  97. package/src/commands/client.ts +76 -78
  98. package/src/commands/completions.test.ts +104 -0
  99. package/src/commands/completions.ts +87 -0
  100. package/src/commands/debug.ts +20 -20
  101. package/src/commands/exec.ts +12 -12
  102. package/src/commands/flags.ts +2 -2
  103. package/src/commands/hatch.ts +27 -24
  104. package/src/commands/login.ts +22 -22
  105. package/src/commands/logs.ts +8 -8
  106. package/src/commands/pair.ts +140 -37
  107. package/src/commands/ps.ts +232 -86
  108. package/src/commands/restart.ts +30 -0
  109. package/src/commands/restore.ts +140 -22
  110. package/src/commands/retire.ts +48 -150
  111. package/src/commands/rollback.ts +20 -26
  112. package/src/commands/setup.ts +2 -2
  113. package/src/commands/sleep.ts +2 -2
  114. package/src/commands/teleport.ts +110 -129
  115. package/src/commands/terminal.ts +158 -18
  116. package/src/commands/tunnel.ts +156 -175
  117. package/src/commands/uninstall.ts +137 -54
  118. package/src/commands/unpair.ts +3 -3
  119. package/src/commands/upgrade.ts +30 -54
  120. package/src/commands/use.ts +1 -1
  121. package/src/commands/wake.ts +166 -50
  122. package/src/index.ts +18 -9
  123. package/src/lib/__tests__/{platform-client-signed-url.test.ts → cloud-client-signed-url.test.ts} +16 -16
  124. package/src/lib/__tests__/docker.test.ts +168 -20
  125. package/src/lib/__tests__/job-polling.test.ts +2 -2
  126. package/src/lib/__tests__/local-gateway-cli.test.ts +42 -0
  127. package/src/lib/__tests__/local-runtime-client.test.ts +4 -4
  128. package/src/lib/__tests__/runtime-url.test.ts +1 -1
  129. package/src/lib/assistant-api-key-resolution.ts +5 -5
  130. package/src/lib/assistant-client.ts +8 -8
  131. package/src/lib/assistant-config.ts +58 -12
  132. package/src/lib/{platform-client.ts → cloud-client.ts} +105 -99
  133. package/src/lib/cloud-login.ts +23 -24
  134. package/src/lib/{platform-releases.ts → cloud-releases.ts} +11 -9
  135. package/src/lib/cloud-values.ts +3 -0
  136. package/src/lib/color.ts +4 -0
  137. package/src/lib/config-utils.ts +1 -1
  138. package/src/lib/detached-process.ts +1 -1
  139. package/src/lib/docker.ts +300 -111
  140. package/src/lib/ensure-binary.ts +171 -0
  141. package/src/lib/environments/__tests__/paths.test.ts +29 -3
  142. package/src/lib/environments/__tests__/resolve.test.ts +26 -26
  143. package/src/lib/environments/paths.ts +16 -8
  144. package/src/lib/environments/resolve.ts +10 -12
  145. package/src/lib/flag-args.test.ts +1 -1
  146. package/src/lib/flag-args.ts +1 -1
  147. package/src/lib/guardian-token.ts +37 -5
  148. package/src/lib/hatch-local.ts +32 -29
  149. package/src/lib/hatch-next-steps.ts +5 -9
  150. package/src/lib/health-check.ts +6 -6
  151. package/src/lib/ingress-config.ts +8 -35
  152. package/src/lib/job-polling.ts +2 -2
  153. package/src/lib/live-voice/connection.ts +12 -12
  154. package/src/lib/live-voice/tunnel.test.ts +11 -11
  155. package/src/lib/live-voice/tunnel.ts +15 -15
  156. package/src/lib/local-runtime-client.ts +20 -20
  157. package/src/lib/local.ts +46 -34
  158. package/src/lib/nginx-ingress.ts +40 -15
  159. package/src/lib/pair.ts +1 -1
  160. package/src/lib/process.ts +1 -1
  161. package/src/lib/provider-secrets.ts +3 -3
  162. package/src/lib/retire-apple-container.ts +1 -1
  163. package/src/lib/runtime-url.ts +8 -8
  164. package/src/lib/statefulset.ts +2 -2
  165. package/src/lib/step-runner.ts +6 -1
  166. package/src/lib/sync-cloud-assistants.ts +19 -19
  167. package/src/lib/teleport-backup.ts +4 -4
  168. package/src/lib/terminal-client.ts +17 -17
  169. package/src/lib/terminal-session.ts +24 -24
  170. package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
  171. package/src/lib/tunnels/cadet.ts +29 -0
  172. package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +36 -15
  173. package/src/lib/tunnels/gateway-notify.ts +61 -0
  174. package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +350 -89
  175. package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +53 -9
  176. package/src/lib/upgrade-lifecycle.ts +8 -21
  177. package/src/lib/upgrade-preflight.ts +2 -2
  178. package/src/lib/workos-pkce.ts +5 -5
  179. package/node_modules/@mycadet/assistant-api/events/platform-disconnected.ts +0 -21
  180. package/node_modules/@mycadet/assistant-api/events/show-platform-login.ts +0 -20
  181. package/src/commands/recover.ts +0 -131
  182. package/src/commands/roadmap.ts +0 -459
  183. package/src/commands/ssh.ts +0 -144
  184. package/src/commands/workflows.ts +0 -305
  185. package/src/lib/managed-cloud.ts +0 -3
@@ -10,8 +10,7 @@ import {
10
10
  test,
11
11
  } from "bun:test";
12
12
  import * as childProcess from "node:child_process";
13
- import type { ChildProcess } from "node:child_process";
14
- import { EventEmitter } from "node:events";
13
+ import type { ChildProcess, SpawnOptions } from "node:child_process";
15
14
  import {
16
15
  appendFileSync,
17
16
  mkdirSync,
@@ -24,56 +23,81 @@ import { tmpdir } from "node:os";
24
23
  import { join } from "node:path";
25
24
 
26
25
  import { TUNNEL_PROVIDERS } from "@mycadet/service-contracts/ingress";
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";
35
+ import { withPreconnect } from "./helpers/http.js";
34
36
 
35
37
  const realCloudflareTunnel = { ...cloudflareTunnel };
38
+ const realGatewayNotify = { ...gatewayNotify };
36
39
  const realNgrok = { ...ngrok };
37
40
  const realNginxIngress = { ...nginxIngress };
38
41
  const realTailscaleTunnel = { ...tailscaleTunnel };
39
42
  const realChildProcess = { ...childProcess };
40
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
+
41
50
  const runCloudflareTunnelMock = mock<
42
51
  typeof cloudflareTunnel.runCloudflareTunnel
43
- >(async () => {});
44
- mock.module("../lib/cloudflare-tunnel.js", () => ({
52
+ >(async () => fakeTunnelResult("https://test.example.cloudflare.dev"));
53
+ mock.module("../lib/tunnels/cloudflare.js", () => ({
45
54
  ...realCloudflareTunnel,
46
55
  runCloudflareTunnel: runCloudflareTunnelMock,
47
56
  }));
48
57
 
49
- const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () => {});
50
- 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", () => ({
51
62
  ...realNgrok,
52
63
  runNgrokTunnel: runNgrokTunnelMock,
53
64
  }));
54
65
 
55
66
  const runTailscaleTunnelMock = mock<typeof tailscaleTunnel.runTailscaleTunnel>(
56
- async () => {},
67
+ async () => fakeTunnelResult("https://test-machine.tailnet.ts.net"),
57
68
  );
58
- mock.module("../lib/tailscale-tunnel.js", () => ({
69
+ mock.module("../lib/tunnels/tailscale.js", () => ({
59
70
  ...realTailscaleTunnel,
60
71
  runTailscaleTunnel: runTailscaleTunnelMock,
61
72
  }));
62
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
+
63
82
  const EDGE_PORT = 18080;
64
83
 
65
84
  const ensureTunnelEdgeMock = mock<typeof nginxIngress.ensureTunnelEdge>(
66
85
  async () => ({ port: EDGE_PORT, started: true, includesWebApp: true }),
67
86
  );
87
+ const getNginxVersionMock = mock<typeof nginxIngress.getNginxVersion>(
88
+ () => "nginx version (test)",
89
+ );
68
90
  mock.module("../lib/nginx-ingress.js", () => ({
69
91
  ...realNginxIngress,
70
92
  ensureTunnelEdge: ensureTunnelEdgeMock,
93
+ getNginxVersion: getNginxVersionMock,
71
94
  }));
72
95
 
73
96
  const { tunnel } = await import("../commands/tunnel.js");
74
97
 
75
98
  const originalArgv = [...process.argv];
76
99
  const originalFetch = globalThis.fetch;
100
+ const originalConfigHome = process.env.XDG_CONFIG_HOME;
77
101
  const originalLockfileDir = process.env.CADET_LOCKFILE_DIR;
78
102
  const originalWorkspaceDir = process.env.CADET_WORKSPACE_DIR;
79
103
  const tempDirs: string[] = [];
@@ -126,10 +150,7 @@ function writeEntryWorkspaceConfig(
126
150
  entry: AssistantEntry,
127
151
  config: Record<string, unknown>,
128
152
  ): string {
129
- const workspaceDir = join(
130
- entry.resources!.instanceDir,
131
- "workspace",
132
- );
153
+ const workspaceDir = join(entry.resources!.instanceDir, "workspace");
133
154
  mkdirSync(workspaceDir, { recursive: true });
134
155
  writeFileSync(
135
156
  join(workspaceDir, "config.json"),
@@ -158,7 +179,7 @@ function writeLockfile(
158
179
  process.env.CADET_LOCKFILE_DIR = lockfileDir;
159
180
  mkdirSync(lockfileDir, { recursive: true });
160
181
  writeFileSync(
161
- join(lockfileDir, ".cadet.lock.json"),
182
+ join(lockfileDir, "lockfile.json"),
162
183
  JSON.stringify(
163
184
  {
164
185
  activeAssistant: activeAssistant ?? entries[0].assistantId,
@@ -170,6 +191,15 @@ function writeLockfile(
170
191
  );
171
192
  }
172
193
 
194
+ const IngressTestConfigSchema = z.object({
195
+ ingress: z
196
+ .looseObject({
197
+ publicBaseUrl: z.string().optional(),
198
+ ngrok: z.object({ domain: z.string().optional() }).optional(),
199
+ })
200
+ .optional(),
201
+ });
202
+
173
203
  /** Run tunnel() expecting exit(1); returns the joined console.error output. */
174
204
  async function runTunnelExpectingExit1(): Promise<{
175
205
  exited: boolean;
@@ -181,17 +211,17 @@ async function runTunnelExpectingExit1(): Promise<{
181
211
  errors.push(a.join(" "));
182
212
  },
183
213
  );
184
- const exitSpy = spyOn(process, "exit").mockImplementation(((
185
- code?: number,
186
- ) => {
187
- throw new Error(`exit:${code}`);
188
- }) as never);
214
+ const exitSpy = spyOn(process, "exit").mockImplementation(
215
+ (code?: number | string | undefined): never => {
216
+ throw new Error(`exit:${code}`);
217
+ },
218
+ );
189
219
 
190
220
  let exited = false;
191
221
  try {
192
222
  await tunnel();
193
223
  } catch (e) {
194
- exited = (e as Error).message === "exit:1";
224
+ exited = e instanceof Error && e.message === "exit:1";
195
225
  } finally {
196
226
  errSpy.mockRestore();
197
227
  exitSpy.mockRestore();
@@ -215,17 +245,36 @@ async function runTunnelCapturingLogs(): Promise<string> {
215
245
 
216
246
  describe("tunnel edge targeting", () => {
217
247
  beforeEach(() => {
218
- process.argv = ["bun", "cadet", "tunnel", "--provider", "tailscale"];
248
+ process.argv = [
249
+ "bun",
250
+ "cadet",
251
+ "tunnel",
252
+ "--provider",
253
+ "tailscale",
254
+ "--foreground",
255
+ ];
219
256
  writeLockfile(makeLocalEntry());
220
- globalThis.fetch = (async () => {
257
+ globalThis.fetch = withPreconnect(async () => {
221
258
  throw new Error("gateway unavailable");
222
- }) as unknown as typeof globalThis.fetch;
259
+ });
260
+ // The ngrok config file lives under the XDG config dir; a fresh one per
261
+ // test keeps the saved-domain state hermetic.
262
+ const configHome = mkdtempSync(join(tmpdir(), "cadet-tunnel-xdg-"));
263
+ tempDirs.push(configHome);
264
+ process.env.XDG_CONFIG_HOME = configHome;
223
265
  runCloudflareTunnelMock.mockReset();
224
- runCloudflareTunnelMock.mockResolvedValue(undefined);
266
+ runCloudflareTunnelMock.mockResolvedValue(
267
+ fakeTunnelResult("https://test.example.cloudflare.dev"),
268
+ );
225
269
  runNgrokTunnelMock.mockReset();
226
- runNgrokTunnelMock.mockResolvedValue(undefined);
270
+ runNgrokTunnelMock.mockResolvedValue(
271
+ fakeTunnelResult("https://test.example.ngrok.dev"),
272
+ );
227
273
  runTailscaleTunnelMock.mockReset();
228
- runTailscaleTunnelMock.mockResolvedValue(undefined);
274
+ runTailscaleTunnelMock.mockResolvedValue(
275
+ fakeTunnelResult("https://test-machine.tailnet.ts.net"),
276
+ );
277
+ notifyGatewayOfPublicUrlMock.mockReset();
229
278
  ensureTunnelEdgeMock.mockReset();
230
279
  ensureTunnelEdgeMock.mockResolvedValue({
231
280
  port: EDGE_PORT,
@@ -237,6 +286,12 @@ describe("tunnel edge targeting", () => {
237
286
  afterEach(() => {
238
287
  process.argv = originalArgv;
239
288
  globalThis.fetch = originalFetch;
289
+ rmSync(realNgrok.ngrokConfigPath(), { force: true });
290
+ if (originalConfigHome === undefined) {
291
+ delete process.env.XDG_CONFIG_HOME;
292
+ } else {
293
+ process.env.XDG_CONFIG_HOME = originalConfigHome;
294
+ }
240
295
  if (originalLockfileDir === undefined) {
241
296
  delete process.env.CADET_LOCKFILE_DIR;
242
297
  } else {
@@ -253,9 +308,9 @@ describe("tunnel edge targeting", () => {
253
308
  });
254
309
 
255
310
  afterAll(() => {
256
- mock.module("../lib/cloudflare-tunnel.js", () => realCloudflareTunnel);
257
- mock.module("../lib/ngrok", () => realNgrok);
258
- mock.module("../lib/tailscale-tunnel.js", () => realTailscaleTunnel);
311
+ mock.module("../lib/tunnels/cloudflare.js", () => realCloudflareTunnel);
312
+ mock.module("../lib/tunnels/ngrok", () => realNgrok);
313
+ mock.module("../lib/tunnels/tailscale.js", () => realTailscaleTunnel);
259
314
  mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
260
315
  });
261
316
 
@@ -263,14 +318,18 @@ describe("tunnel edge targeting", () => {
263
318
  const entry = makeLocalEntry();
264
319
  entry.runtimeUrl = "https://stale-tunnel.ngrok-free.dev";
265
320
  writeLockfile(entry);
266
- process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
321
+ process.argv = [
322
+ "bun",
323
+ "cadet",
324
+ "tunnel",
325
+ "--provider",
326
+ "ngrok",
327
+ "--foreground",
328
+ ];
267
329
 
268
330
  const logs = await runTunnelCapturingLogs();
269
331
 
270
- const workspaceDir = join(
271
- entry.resources!.instanceDir,
272
- "workspace",
273
- );
332
+ const workspaceDir = join(entry.resources!.instanceDir, "workspace");
274
333
  expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
275
334
  assistantId: "assistant-1",
276
335
  workspaceDir,
@@ -283,21 +342,45 @@ describe("tunnel edge targeting", () => {
283
342
  });
284
343
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
285
344
  expect(logs).toContain(`Started the nginx edge on 127.0.0.1:${EDGE_PORT}`);
286
- expect(logs).toContain("serves remote web + webhooks");
345
+ });
346
+
347
+ test("the tunnel level notifies the gateway of the provider's public URL", async () => {
348
+ writeLockfile(makeLocalEntry());
349
+ process.argv = [
350
+ "bun",
351
+ "cadet",
352
+ "tunnel",
353
+ "--provider",
354
+ "ngrok",
355
+ "--foreground",
356
+ ];
357
+
358
+ await runTunnelCapturingLogs();
359
+
360
+ // The provider hands its URL back; the notification is the tunnel
361
+ // level's job, so it holds no matter which provider ran.
362
+ expect(notifyGatewayOfPublicUrlMock).toHaveBeenCalledWith(
363
+ "https://test.example.ngrok.dev",
364
+ "assistant-1",
365
+ );
287
366
  });
288
367
 
289
368
  test("an active cloud assistant falls back to the sole local entry with a note", async () => {
290
369
  const cloud = makeCloudEntry();
291
370
  const local = makeLocalEntry();
292
371
  writeLockfile([cloud, local], cloud.assistantId);
293
- process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
372
+ process.argv = [
373
+ "bun",
374
+ "cadet",
375
+ "tunnel",
376
+ "--provider",
377
+ "ngrok",
378
+ "--foreground",
379
+ ];
294
380
 
295
381
  const logs = await runTunnelCapturingLogs();
296
382
 
297
- const workspaceDir = join(
298
- local.resources!.instanceDir,
299
- "workspace",
300
- );
383
+ const workspaceDir = join(local.resources!.instanceDir, "workspace");
301
384
  expect(logs).toContain(
302
385
  "Assistant 'cloud-1' runs on Cadet Cloud and needs no tunnel. " +
303
386
  "Tunneling the local assistant 'assistant-1' instead.",
@@ -325,6 +408,7 @@ describe("tunnel edge targeting", () => {
325
408
  "docker-1",
326
409
  "--provider",
327
410
  "ngrok",
411
+ "--foreground",
328
412
  ];
329
413
 
330
414
  await runTunnelCapturingLogs();
@@ -345,7 +429,14 @@ describe("tunnel edge targeting", () => {
345
429
  const workspaceDir = useTempDefaultWorkspaceDir();
346
430
  const docker = makeDockerEntry();
347
431
  writeLockfile(docker, docker.assistantId);
348
- process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
432
+ process.argv = [
433
+ "bun",
434
+ "cadet",
435
+ "tunnel",
436
+ "--provider",
437
+ "ngrok",
438
+ "--foreground",
439
+ ];
349
440
 
350
441
  await runTunnelCapturingLogs();
351
442
 
@@ -365,7 +456,14 @@ describe("tunnel edge targeting", () => {
365
456
  const workspaceDir = useTempDefaultWorkspaceDir();
366
457
  const cloud = makeCloudEntry();
367
458
  writeLockfile([cloud, makeDockerEntry()], cloud.assistantId);
368
- process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
459
+ process.argv = [
460
+ "bun",
461
+ "cadet",
462
+ "tunnel",
463
+ "--provider",
464
+ "ngrok",
465
+ "--foreground",
466
+ ];
369
467
 
370
468
  const logs = await runTunnelCapturingLogs();
371
469
 
@@ -382,7 +480,14 @@ describe("tunnel edge targeting", () => {
382
480
 
383
481
  test("an active cloud assistant with no local entries exits with an error", async () => {
384
482
  writeLockfile(makeCloudEntry());
385
- process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
483
+ process.argv = [
484
+ "bun",
485
+ "cadet",
486
+ "tunnel",
487
+ "--provider",
488
+ "ngrok",
489
+ "--foreground",
490
+ ];
386
491
 
387
492
  const { exited, errors } = await runTunnelExpectingExit1();
388
493
 
@@ -401,7 +506,14 @@ describe("tunnel edge targeting", () => {
401
506
  [cloud, makeLocalEntry("assistant-a"), makeLocalEntry("assistant-b")],
402
507
  cloud.assistantId,
403
508
  );
404
- process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
509
+ process.argv = [
510
+ "bun",
511
+ "cadet",
512
+ "tunnel",
513
+ "--provider",
514
+ "ngrok",
515
+ "--foreground",
516
+ ];
405
517
 
406
518
  const { exited, errors } = await runTunnelExpectingExit1();
407
519
 
@@ -426,6 +538,7 @@ describe("tunnel edge targeting", () => {
426
538
  "cloud-1",
427
539
  "--provider",
428
540
  "ngrok",
541
+ "--foreground",
429
542
  ];
430
543
 
431
544
  const { exited, errors } = await runTunnelExpectingExit1();
@@ -445,14 +558,19 @@ describe("tunnel edge targeting", () => {
445
558
  const local = makeLocalEntry();
446
559
  local.name = "Ada";
447
560
  writeLockfile([makeCloudEntry(), local], "cloud-1");
448
- process.argv = ["bun", "cadet", "tunnel", "Ada", "--provider", "ngrok"];
561
+ process.argv = [
562
+ "bun",
563
+ "cadet",
564
+ "tunnel",
565
+ "Ada",
566
+ "--provider",
567
+ "ngrok",
568
+ "--foreground",
569
+ ];
449
570
 
450
571
  await runTunnelCapturingLogs();
451
572
 
452
- const workspaceDir = join(
453
- local.resources!.instanceDir,
454
- "workspace",
455
- );
573
+ const workspaceDir = join(local.resources!.instanceDir, "workspace");
456
574
  expect(runNgrokTunnelMock).toHaveBeenCalledWith({
457
575
  port: EDGE_PORT,
458
576
  assistantId: "assistant-1",
@@ -472,14 +590,12 @@ describe("tunnel edge targeting", () => {
472
590
  "Lovelace",
473
591
  "--provider",
474
592
  "ngrok",
593
+ "--foreground",
475
594
  ];
476
595
 
477
596
  await runTunnelCapturingLogs();
478
597
 
479
- const workspaceDir = join(
480
- local.resources!.instanceDir,
481
- "workspace",
482
- );
598
+ const workspaceDir = join(local.resources!.instanceDir, "workspace");
483
599
  expect(runNgrokTunnelMock).toHaveBeenCalledWith({
484
600
  port: EDGE_PORT,
485
601
  assistantId: "assistant-1",
@@ -498,6 +614,7 @@ describe("tunnel edge targeting", () => {
498
614
  "apple-1",
499
615
  "--provider",
500
616
  "ngrok",
617
+ "--foreground",
501
618
  ];
502
619
 
503
620
  await runTunnelCapturingLogs();
@@ -518,7 +635,14 @@ describe("tunnel edge targeting", () => {
518
635
  const workspaceDir = useTempDefaultWorkspaceDir();
519
636
  const cloud = makeCloudEntry();
520
637
  writeLockfile([cloud, makeAppleContainerEntry()], cloud.assistantId);
521
- process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
638
+ process.argv = [
639
+ "bun",
640
+ "cadet",
641
+ "tunnel",
642
+ "--provider",
643
+ "ngrok",
644
+ "--foreground",
645
+ ];
522
646
 
523
647
  const logs = await runTunnelCapturingLogs();
524
648
 
@@ -550,6 +674,7 @@ describe("tunnel edge targeting", () => {
550
674
  "assistant-b",
551
675
  "--provider",
552
676
  "ngrok",
677
+ "--foreground",
553
678
  ];
554
679
 
555
680
  await runTunnelCapturingLogs();
@@ -564,7 +689,15 @@ describe("tunnel edge targeting", () => {
564
689
  cloud.name = "Cloudy";
565
690
  const local = makeLocalEntry();
566
691
  writeLockfile([cloud, local], local.assistantId);
567
- process.argv = ["bun", "cadet", "tunnel", "Cloudy", "--provider", "ngrok"];
692
+ process.argv = [
693
+ "bun",
694
+ "cadet",
695
+ "tunnel",
696
+ "Cloudy",
697
+ "--provider",
698
+ "ngrok",
699
+ "--foreground",
700
+ ];
568
701
 
569
702
  const { exited, errors } = await runTunnelExpectingExit1();
570
703
 
@@ -585,7 +718,15 @@ describe("tunnel edge targeting", () => {
585
718
  const second = makeLocalEntry("assistant-b");
586
719
  second.name = "Ada";
587
720
  writeLockfile([first, second], "assistant-a");
588
- process.argv = ["bun", "cadet", "tunnel", "Ada", "--provider", "ngrok"];
721
+ process.argv = [
722
+ "bun",
723
+ "cadet",
724
+ "tunnel",
725
+ "Ada",
726
+ "--provider",
727
+ "ngrok",
728
+ "--foreground",
729
+ ];
589
730
 
590
731
  const { exited, errors } = await runTunnelExpectingExit1();
591
732
 
@@ -598,7 +739,15 @@ describe("tunnel edge targeting", () => {
598
739
  });
599
740
 
600
741
  test("an unknown positional name errors", async () => {
601
- process.argv = ["bun", "cadet", "tunnel", "nope", "--provider", "ngrok"];
742
+ process.argv = [
743
+ "bun",
744
+ "cadet",
745
+ "tunnel",
746
+ "nope",
747
+ "--provider",
748
+ "ngrok",
749
+ "--foreground",
750
+ ];
602
751
 
603
752
  const { exited, errors } = await runTunnelExpectingExit1();
604
753
 
@@ -611,7 +760,14 @@ describe("tunnel edge targeting", () => {
611
760
  test("targets the edge port for cloudflare", async () => {
612
761
  const entry = makeLocalEntry();
613
762
  writeLockfile(entry);
614
- process.argv = ["bun", "cadet", "tunnel", "--provider", "cloudflare"];
763
+ process.argv = [
764
+ "bun",
765
+ "cadet",
766
+ "tunnel",
767
+ "--provider",
768
+ "cloudflare",
769
+ "--foreground",
770
+ ];
615
771
 
616
772
  await runTunnelCapturingLogs();
617
773
 
@@ -626,7 +782,14 @@ describe("tunnel edge targeting", () => {
626
782
  test("targets the edge port for tailscale and notes a reused webhooks-only edge", async () => {
627
783
  const entry = makeLocalEntry();
628
784
  writeLockfile(entry);
629
- process.argv = ["bun", "cadet", "tunnel", "--provider", "tailscale"];
785
+ process.argv = [
786
+ "bun",
787
+ "cadet",
788
+ "tunnel",
789
+ "--provider",
790
+ "tailscale",
791
+ "--foreground",
792
+ ];
630
793
  ensureTunnelEdgeMock.mockResolvedValue({
631
794
  port: EDGE_PORT,
632
795
  started: false,
@@ -641,49 +804,42 @@ describe("tunnel edge targeting", () => {
641
804
  workspaceDir: join(entry.resources!.instanceDir, "workspace"),
642
805
  });
643
806
  expect(logs).toContain(`Reusing the nginx edge on 127.0.0.1:${EDGE_PORT}`);
644
- expect(logs).toContain("serves webhooks only");
645
807
  });
646
808
 
647
- test("a bare invocation exits listing the providers to choose from", async () => {
648
- writeLockfile(makeLocalEntry());
649
- process.argv = ["bun", "cadet", "tunnel"];
809
+ test("a bare invocation tunnels the default provider", async () => {
810
+ const entry = makeLocalEntry();
811
+ writeLockfile(entry);
812
+ process.argv = ["bun", "cadet", "tunnel", "--foreground"];
650
813
 
651
- const { exited, errors } = await runTunnelExpectingExit1();
814
+ const logs = await runTunnelCapturingLogs();
652
815
 
653
- expect(exited).toBe(true);
654
- expect(errors).toContain("--provider is required");
655
- expect(errors).toContain("cadet tunnel --provider ngrok");
656
- expect(errors).toContain("cadet tunnel --provider cloudflare");
657
- expect(errors).toContain("cadet tunnel --provider tailscale");
658
- // Nothing is started and nothing is recorded until the user has chosen.
659
- expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
816
+ expect(logs).toContain("Using the default provider: ngrok");
817
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
818
+ port: EDGE_PORT,
819
+ assistantId: "assistant-1",
820
+ workspaceDir: join(entry.resources!.instanceDir, "workspace"),
821
+ });
822
+ expect(ensureTunnelEdgeMock).toHaveBeenCalled();
660
823
  expect(runTailscaleTunnelMock).not.toHaveBeenCalled();
661
- expect(runNgrokTunnelMock).not.toHaveBeenCalled();
662
824
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
663
825
  });
664
826
 
665
- test("a named assistant without --provider exits before resolving it", async () => {
666
- writeLockfile(makeLocalEntry());
667
- process.argv = ["bun", "cadet", "tunnel", "Ada"];
668
-
669
- const { exited, errors } = await runTunnelExpectingExit1();
670
-
671
- expect(exited).toBe(true);
672
- expect(errors).toContain("--provider is required");
673
- // The suggestions keep the assistant that was asked for, so pasting one
674
- // cannot tunnel the active assistant instead.
675
- expect(errors).toContain("cadet tunnel Ada --provider ngrok");
676
- expect(errors).toContain("cadet tunnel Ada --provider tailscale");
677
- expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
678
- });
679
-
680
- test("a shell-sensitive assistant name is quoted in the suggestions", async () => {
681
- writeLockfile(makeLocalEntry());
682
- process.argv = ["bun", "cadet", "tunnel", "Bob&Alice"];
827
+ test("a named assistant without --provider tunnels it with the default provider", async () => {
828
+ const entry = makeLocalEntry();
829
+ entry.name = "Ada";
830
+ writeLockfile(entry);
831
+ process.argv = ["bun", "cadet", "tunnel", "Ada", "--foreground"];
683
832
 
684
- const { errors } = await runTunnelExpectingExit1();
833
+ const logs = await runTunnelCapturingLogs();
685
834
 
686
- expect(errors).toContain("cadet tunnel 'Bob&Alice' --provider ngrok");
835
+ expect(logs).toContain("Using the default provider: ngrok");
836
+ // The assistant that was asked for is the one tunneled.
837
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
838
+ port: EDGE_PORT,
839
+ assistantId: entry.assistantId,
840
+ workspaceDir: join(entry.resources!.instanceDir, "workspace"),
841
+ });
842
+ expect(ensureTunnelEdgeMock).toHaveBeenCalled();
687
843
  });
688
844
 
689
845
  test("--provider tailscale keeps the webhook callback base and says so", async () => {
@@ -692,7 +848,14 @@ describe("tunnel edge targeting", () => {
692
848
  const workspaceDir = writeEntryWorkspaceConfig(entry, {
693
849
  telegram: { botUsername: "example_bot" },
694
850
  });
695
- process.argv = ["bun", "cadet", "tunnel", "--provider", "tailscale"];
851
+ process.argv = [
852
+ "bun",
853
+ "cadet",
854
+ "tunnel",
855
+ "--provider",
856
+ "tailscale",
857
+ "--foreground",
858
+ ];
696
859
 
697
860
  const warnings: string[] = [];
698
861
  const warnSpy = spyOn(console, "warn").mockImplementation(
@@ -723,7 +886,14 @@ describe("tunnel edge targeting", () => {
723
886
  const entry = makeLocalEntry();
724
887
  writeLockfile(entry);
725
888
  const workspaceDir = writeEntryWorkspaceConfig(entry, {});
726
- process.argv = ["bun", "cadet", "tunnel", "--provider", "tailscale"];
889
+ process.argv = [
890
+ "bun",
891
+ "cadet",
892
+ "tunnel",
893
+ "--provider",
894
+ "tailscale",
895
+ "--foreground",
896
+ ];
727
897
 
728
898
  await runTunnelCapturingLogs();
729
899
 
@@ -740,7 +910,14 @@ describe("tunnel edge targeting", () => {
740
910
  const workspaceDir = writeEntryWorkspaceConfig(entry, {
741
911
  telegram: { botUsername: "example_bot" },
742
912
  });
743
- process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
913
+ process.argv = [
914
+ "bun",
915
+ "cadet",
916
+ "tunnel",
917
+ "--provider",
918
+ "ngrok",
919
+ "--foreground",
920
+ ];
744
921
 
745
922
  await runTunnelCapturingLogs();
746
923
 
@@ -752,11 +929,18 @@ describe("tunnel edge targeting", () => {
752
929
  });
753
930
 
754
931
  test("missing nginx aborts before any provider spawn", async () => {
755
- process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
932
+ process.argv = [
933
+ "bun",
934
+ "cadet",
935
+ "tunnel",
936
+ "--provider",
937
+ "ngrok",
938
+ "--foreground",
939
+ ];
756
940
  ensureTunnelEdgeMock.mockRejectedValue(
757
941
  new Error(
758
942
  "nginx is not installed, so the tunnel edge cannot start. " +
759
- "Install it (macOS: `brew install nginx`, Linux: `sudo apt install nginx`) " +
943
+ "See https://nginx.org/en/docs/install.html for how to install, " +
760
944
  "or point NGINX_BIN at an existing binary.",
761
945
  ),
762
946
  );
@@ -764,14 +948,23 @@ describe("tunnel edge targeting", () => {
764
948
  const { exited, errors } = await runTunnelExpectingExit1();
765
949
 
766
950
  expect(exited).toBe(true);
767
- expect(errors).toContain("brew install nginx");
951
+ expect(errors).toContain(
952
+ "See https://nginx.org/en/docs/install.html for how to install",
953
+ );
768
954
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
769
955
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
770
956
  expect(runTailscaleTunnelMock).not.toHaveBeenCalled();
771
957
  });
772
958
 
773
959
  test("rejects an unknown --provider with a stale-CLI hint", async () => {
774
- process.argv = ["bun", "cadet", "tunnel", "--provider", "bogus"];
960
+ process.argv = [
961
+ "bun",
962
+ "cadet",
963
+ "tunnel",
964
+ "--provider",
965
+ "bogus",
966
+ "--foreground",
967
+ ];
775
968
 
776
969
  const { exited, errors } = await runTunnelExpectingExit1();
777
970
 
@@ -788,7 +981,14 @@ describe("tunnel edge targeting", () => {
788
981
  try {
789
982
  for (const provider of TUNNEL_PROVIDERS) {
790
983
  writeLockfile(makeLocalEntry());
791
- process.argv = ["bun", "cadet", "tunnel", "--provider", provider];
984
+ process.argv = [
985
+ "bun",
986
+ "cadet",
987
+ "tunnel",
988
+ "--provider",
989
+ provider,
990
+ "--foreground",
991
+ ];
792
992
 
793
993
  const { exited, errors } = await runTunnelExpectingExit1();
794
994
 
@@ -811,6 +1011,7 @@ describe("tunnel edge targeting", () => {
811
1011
  "ngrok",
812
1012
  "--domain",
813
1013
  "foo.ngrok.app",
1014
+ "--foreground",
814
1015
  ];
815
1016
 
816
1017
  await runTunnelCapturingLogs();
@@ -832,13 +1033,14 @@ describe("tunnel edge targeting", () => {
832
1033
  "cloudflare",
833
1034
  "--domain",
834
1035
  "foo.ngrok.app",
1036
+ "--foreground",
835
1037
  ];
836
1038
 
837
1039
  const { exited, errors } = await runTunnelExpectingExit1();
838
1040
 
839
1041
  expect(exited).toBe(true);
840
1042
  expect(errors).toContain(
841
- "--domain is only supported with --provider ngrok",
1043
+ "--domain is not supported with --provider cloudflare",
842
1044
  );
843
1045
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
844
1046
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
@@ -847,15 +1049,9 @@ describe("tunnel edge targeting", () => {
847
1049
  test("--clear-domain drops the saved domain and runs domainless", async () => {
848
1050
  const entry = makeLocalEntry();
849
1051
  writeLockfile(entry);
850
- const workspaceDir = join(
851
- entry.resources!.instanceDir,
852
- "workspace",
853
- );
1052
+ const workspaceDir = join(entry.resources!.instanceDir, "workspace");
854
1053
  mkdirSync(workspaceDir, { recursive: true });
855
- writeFileSync(
856
- join(workspaceDir, "config.json"),
857
- JSON.stringify({ ingress: { ngrok: { domain: "dead.ngrok.app" } } }),
858
- );
1054
+ realNgrok.saveNgrokDomain("dead.ngrok.app");
859
1055
  process.argv = [
860
1056
  "bun",
861
1057
  "cadet",
@@ -863,15 +1059,14 @@ describe("tunnel edge targeting", () => {
863
1059
  "--provider",
864
1060
  "ngrok",
865
1061
  "--clear-domain",
1062
+ "--foreground",
866
1063
  ];
867
1064
 
868
1065
  const logs = await runTunnelCapturingLogs();
869
1066
 
870
- expect(logs).toContain("Cleared the saved ngrok domain");
871
- const config = JSON.parse(
872
- readFileSync(join(workspaceDir, "config.json"), "utf-8"),
873
- ) as { ingress?: { ngrok?: { domain?: string } } };
874
- expect(config.ingress?.ngrok).toBeUndefined();
1067
+ expect(logs).toContain("Cleared the saved reserved domain");
1068
+ // The saved domain lives in the XDG ngrok config, not the workspace config.
1069
+ expect(realNgrok.loadNgrokDomain()).toBeNull();
875
1070
  // The run proceeds domainless: no `domain` key reaches runNgrokTunnel.
876
1071
  expect(runNgrokTunnelMock).toHaveBeenCalledWith({
877
1072
  port: EDGE_PORT,
@@ -890,6 +1085,7 @@ describe("tunnel edge targeting", () => {
890
1085
  "--domain",
891
1086
  "foo.ngrok.app",
892
1087
  "--clear-domain",
1088
+ "--foreground",
893
1089
  ];
894
1090
 
895
1091
  const { exited, errors } = await runTunnelExpectingExit1();
@@ -907,13 +1103,14 @@ describe("tunnel edge targeting", () => {
907
1103
  "--provider",
908
1104
  "cloudflare",
909
1105
  "--clear-domain",
1106
+ "--foreground",
910
1107
  ];
911
1108
 
912
1109
  const { exited, errors } = await runTunnelExpectingExit1();
913
1110
 
914
1111
  expect(exited).toBe(true);
915
1112
  expect(errors).toContain(
916
- "--clear-domain is only supported with --provider ngrok",
1113
+ "--clear-domain is not supported with --provider cloudflare",
917
1114
  );
918
1115
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
919
1116
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
@@ -927,6 +1124,7 @@ describe("tunnel edge targeting", () => {
927
1124
  "--provider",
928
1125
  "ngrok",
929
1126
  "--domain",
1127
+ "--foreground",
930
1128
  ];
931
1129
 
932
1130
  const { exited, errors } = await runTunnelExpectingExit1();
@@ -939,13 +1137,20 @@ describe("tunnel edge targeting", () => {
939
1137
  test("a not-yet-implemented provider error carries the stale-CLI hint", async () => {
940
1138
  // `cadet` stays valid-but-unimplemented, so it hits this path rather
941
1139
  // than the unknown-provider one.
942
- process.argv = ["bun", "cadet", "tunnel", "--provider", "cadet"];
1140
+ process.argv = [
1141
+ "bun",
1142
+ "cadet",
1143
+ "tunnel",
1144
+ "--provider",
1145
+ "cadet",
1146
+ "--foreground",
1147
+ ];
943
1148
 
944
1149
  let err: Error | undefined;
945
1150
  try {
946
1151
  await tunnel();
947
1152
  } catch (e) {
948
- err = e as Error;
1153
+ err = e instanceof Error ? e : undefined;
949
1154
  }
950
1155
 
951
1156
  expect(err?.message).toContain("is not yet implemented");
@@ -959,20 +1164,26 @@ describe("tunnel edge targeting", () => {
959
1164
 
960
1165
  describe("ngrok --domain spawn args", () => {
961
1166
  const originalContainerized = process.env.IS_CONTAINERIZED;
962
- let lastChild: EventEmitter | null = null;
963
-
964
- const spawnMock = mock((..._args: unknown[]) => {
965
- const emitter = new EventEmitter();
966
- lastChild = Object.assign(emitter, {
967
- stdout: null,
968
- stderr: null,
969
- killed: false,
970
- kill: () => true,
971
- unref: () => {},
972
- pid: 4242,
973
- });
974
- return lastChild as unknown as ChildProcess;
975
- });
1167
+ let lastChild: ChildProcess | null = null;
1168
+
1169
+ const spawnMock = mock(
1170
+ (
1171
+ _command: string,
1172
+ _args: readonly string[],
1173
+ _options?: SpawnOptions,
1174
+ ): ChildProcess => {
1175
+ const child = new realChildProcess.ChildProcess();
1176
+ // kill() stays stubbed: the fake pid must never be signaled for real.
1177
+ // stdout/stderr: the real class resolves them through a getter over the
1178
+ // internal spawn handle, so null-shadow them via defineProperty — an
1179
+ // Object.assign [[Set]] through the getter-only accessor throws.
1180
+ Object.defineProperty(child, "stdout", { value: null });
1181
+ Object.defineProperty(child, "stderr", { value: null });
1182
+ const fake = Object.assign(child, { pid: 4242, kill: () => true });
1183
+ lastChild = fake;
1184
+ return fake;
1185
+ },
1186
+ );
976
1187
  const execFileSyncMock = mock(() => "ngrok version 3.9.0");
977
1188
 
978
1189
  beforeAll(() => {
@@ -991,11 +1202,22 @@ describe("ngrok --domain spawn args", () => {
991
1202
  spawnMock.mockClear();
992
1203
  lastChild = null;
993
1204
  delete process.env.IS_CONTAINERIZED;
1205
+ // The ngrok config file lives under the XDG config dir; a fresh one per
1206
+ // test keeps the saved-domain state hermetic.
1207
+ const configHome = mkdtempSync(join(tmpdir(), "cadet-ngrok-xdg-"));
1208
+ tempDirs.push(configHome);
1209
+ process.env.XDG_CONFIG_HOME = configHome;
994
1210
  mockNgrokApiFetch([{ tunnels: [] }]);
995
1211
  });
996
1212
 
997
1213
  afterEach(() => {
998
1214
  globalThis.fetch = originalFetch;
1215
+ rmSync(realNgrok.ngrokConfigPath(), { force: true });
1216
+ if (originalConfigHome === undefined) {
1217
+ delete process.env.XDG_CONFIG_HOME;
1218
+ } else {
1219
+ process.env.XDG_CONFIG_HOME = originalConfigHome;
1220
+ }
999
1221
  if (originalContainerized === undefined) {
1000
1222
  delete process.env.IS_CONTAINERIZED;
1001
1223
  } else {
@@ -1021,22 +1243,20 @@ describe("ngrok --domain spawn args", () => {
1021
1243
  /** ngrok local API stub replaying one response per call, last one repeated. */
1022
1244
  function mockNgrokApiFetch(responses: { tunnels: StubTunnel[] }[]): void {
1023
1245
  let call = 0;
1024
- globalThis.fetch = (async () => {
1246
+ globalThis.fetch = withPreconnect(async () => {
1025
1247
  const body = responses[Math.min(call, responses.length - 1)];
1026
1248
  call++;
1027
1249
  return new Response(JSON.stringify(body), {
1028
1250
  status: 200,
1029
1251
  headers: { "Content-Type": "application/json" },
1030
1252
  });
1031
- }) as unknown as typeof globalThis.fetch;
1253
+ });
1032
1254
  }
1033
1255
 
1034
1256
  function readIngress(ws: string): Record<string, unknown> {
1035
- const config = JSON.parse(
1036
- readFileSync(join(ws, "config.json"), "utf-8"),
1037
- ) as {
1038
- ingress?: Record<string, unknown>;
1039
- };
1257
+ const config = IngressTestConfigSchema.parse(
1258
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1259
+ );
1040
1260
  return config.ingress ?? {};
1041
1261
  }
1042
1262
 
@@ -1048,7 +1268,7 @@ describe("ngrok --domain spawn args", () => {
1048
1268
  const lockfileDir = mkdtempSync(join(tmpdir(), "cadet-ngrok-lockfile-"));
1049
1269
  tempDirs.push(lockfileDir);
1050
1270
  writeFileSync(
1051
- join(lockfileDir, ".cadet.lock.json"),
1271
+ join(lockfileDir, "lockfile.json"),
1052
1272
  JSON.stringify({
1053
1273
  activeAssistant: assistantId,
1054
1274
  assistants: [
@@ -1104,33 +1324,34 @@ describe("ngrok --domain spawn args", () => {
1104
1324
  }
1105
1325
 
1106
1326
  expect(spawnMock).toHaveBeenCalledTimes(1);
1107
- const [cmd, args] = spawnMock.mock.calls[0] as unknown as [
1108
- string,
1109
- string[],
1110
- ];
1327
+ const [cmd, args] = spawnMock.mock.calls[0];
1111
1328
  expect(cmd).toBe("ngrok");
1329
+ // Plain `ngrok http` so the user's default config — where their
1330
+ // authtoken lives — is what the agent loads.
1112
1331
  expect(args).toEqual([
1113
1332
  "http",
1114
1333
  "127.0.0.1:7831",
1115
1334
  "--log=stdout",
1116
1335
  "--domain=foo.ngrok.app",
1117
1336
  ]);
1337
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1118
1338
 
1119
- const config = JSON.parse(
1120
- readFileSync(join(ws, "config.json"), "utf-8"),
1121
- ) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1122
- expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
1123
- expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
1339
+ const config = IngressTestConfigSchema.parse(
1340
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1341
+ );
1342
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1124
1343
  });
1125
1344
 
1126
1345
  function mockTunnelListFetch(publicUrl: string, addr: string): void {
1127
- globalThis.fetch = (async () =>
1128
- new Response(
1129
- JSON.stringify({
1130
- tunnels: [{ public_url: publicUrl, config: { addr } }],
1131
- }),
1132
- { status: 200, headers: { "Content-Type": "application/json" } },
1133
- )) as unknown as typeof globalThis.fetch;
1346
+ globalThis.fetch = withPreconnect(
1347
+ async () =>
1348
+ new Response(
1349
+ JSON.stringify({
1350
+ tunnels: [{ public_url: publicUrl, config: { addr } }],
1351
+ }),
1352
+ { status: 200, headers: { "Content-Type": "application/json" } },
1353
+ ),
1354
+ );
1134
1355
  }
1135
1356
 
1136
1357
  test("runNgrokTunnel rejects an existing tunnel that mismatches the requested domain", async () => {
@@ -1143,11 +1364,11 @@ describe("ngrok --domain spawn args", () => {
1143
1364
  errors.push(a.join(" "));
1144
1365
  },
1145
1366
  );
1146
- const exitSpy = spyOn(process, "exit").mockImplementation(((
1147
- code?: number,
1148
- ) => {
1149
- throw new Error(`exit:${code}`);
1150
- }) as never);
1367
+ const exitSpy = spyOn(process, "exit").mockImplementation(
1368
+ (code?: number | string | undefined): never => {
1369
+ throw new Error(`exit:${code}`);
1370
+ },
1371
+ );
1151
1372
 
1152
1373
  try {
1153
1374
  await expect(
@@ -1170,9 +1391,9 @@ describe("ngrok --domain spawn args", () => {
1170
1391
  expect(combined).toContain("Stop the existing ngrok agent");
1171
1392
  expect(spawnMock).not.toHaveBeenCalled();
1172
1393
 
1173
- const config = JSON.parse(
1174
- readFileSync(join(ws, "config.json"), "utf-8"),
1175
- ) as { ingress?: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1394
+ const config = IngressTestConfigSchema.parse(
1395
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1396
+ );
1176
1397
  expect(config.ingress?.publicBaseUrl).toBeUndefined();
1177
1398
  expect(config.ingress?.ngrok).toBeUndefined();
1178
1399
  });
@@ -1189,6 +1410,8 @@ describe("ngrok --domain spawn args", () => {
1189
1410
  // The adopt path blocks until SIGINT/SIGTERM; pump SIGINT until the
1190
1411
  // listener is registered and the promise settles. Earlier tests leak
1191
1412
  // SIGINT handlers that call process.exit, so no-op it while pumping.
1413
+ // LOAD-BEARING cast: a no-op exit can't honestly be typed `: never` — it
1414
+ // must return so leaked SIGINT handlers can't crash the pump below.
1192
1415
  const exitSpy = spyOn(process, "exit").mockImplementation(
1193
1416
  (() => undefined) as never,
1194
1417
  );
@@ -1201,18 +1424,16 @@ describe("ngrok --domain spawn args", () => {
1201
1424
  }
1202
1425
 
1203
1426
  expect(spawnMock).not.toHaveBeenCalled();
1204
- const config = JSON.parse(
1205
- readFileSync(join(ws, "config.json"), "utf-8"),
1206
- ) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1207
- expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
1208
- expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
1427
+ const config = IngressTestConfigSchema.parse(
1428
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1429
+ );
1430
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1431
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1209
1432
  });
1210
1433
 
1211
1434
  test("maybeStartNgrokTunnel rejects a mismatched existing tunnel without adopting or spawning", async () => {
1212
- const ws = makeWorkspace({
1213
- telegram: { botUsername: "example_bot" },
1214
- ingress: { ngrok: { domain: "foo.ngrok.app" } },
1215
- });
1435
+ const ws = makeWorkspace({ telegram: { botUsername: "example_bot" } });
1436
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1216
1437
  mockTunnelListFetch("https://other.ngrok-free.app", "localhost:7830");
1217
1438
 
1218
1439
  const warnings: string[] = [];
@@ -1240,13 +1461,13 @@ describe("ngrok --domain spawn args", () => {
1240
1461
  "cadet tunnel --provider ngrok --domain foo.ngrok.app",
1241
1462
  );
1242
1463
 
1243
- const config = JSON.parse(
1244
- readFileSync(join(ws, "config.json"), "utf-8"),
1245
- ) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1464
+ const config = IngressTestConfigSchema.parse(
1465
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1466
+ );
1246
1467
  // The mismatched tunnel is not blessed in config…
1247
- expect(config.ingress.publicBaseUrl).toBeUndefined();
1468
+ expect(config.ingress?.publicBaseUrl).toBeUndefined();
1248
1469
  // …and the reserved domain stays saved as standing intent.
1249
- expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
1470
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1250
1471
  });
1251
1472
 
1252
1473
  test("an adopted automatic tunnel records the assistant it fronts", async () => {
@@ -1329,9 +1550,9 @@ describe("ngrok --domain spawn args", () => {
1329
1550
  expect(combined).toContain("not 18080");
1330
1551
  expect(combined).toContain("cadet tunnel --provider ngrok");
1331
1552
 
1332
- const config = JSON.parse(
1333
- readFileSync(join(ws, "config.json"), "utf-8"),
1334
- ) as { ingress?: { publicBaseUrl?: string } };
1553
+ const config = IngressTestConfigSchema.parse(
1554
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1555
+ );
1335
1556
  expect(config.ingress?.publicBaseUrl).toBeUndefined();
1336
1557
  });
1337
1558
 
@@ -1345,11 +1566,11 @@ describe("ngrok --domain spawn args", () => {
1345
1566
  errors.push(a.join(" "));
1346
1567
  },
1347
1568
  );
1348
- const exitSpy = spyOn(process, "exit").mockImplementation(((
1349
- code?: number,
1350
- ) => {
1351
- throw new Error(`exit:${code}`);
1352
- }) as never);
1569
+ const exitSpy = spyOn(process, "exit").mockImplementation(
1570
+ (code?: number | string | undefined): never => {
1571
+ throw new Error(`exit:${code}`);
1572
+ },
1573
+ );
1353
1574
 
1354
1575
  try {
1355
1576
  await expect(
@@ -1369,17 +1590,15 @@ describe("ngrok --domain spawn args", () => {
1369
1590
  expect(combined).toContain("Stop the existing ngrok agent");
1370
1591
  expect(spawnMock).not.toHaveBeenCalled();
1371
1592
 
1372
- const config = JSON.parse(
1373
- readFileSync(join(ws, "config.json"), "utf-8"),
1374
- ) as { ingress?: { publicBaseUrl?: string } };
1593
+ const config = IngressTestConfigSchema.parse(
1594
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1595
+ );
1375
1596
  expect(config.ingress?.publicBaseUrl).toBeUndefined();
1376
1597
  });
1377
1598
 
1378
1599
  test("maybeStartNgrokTunnel passes the saved domain to the spawn args", async () => {
1379
- const ws = makeWorkspace({
1380
- telegram: { botUsername: "example_bot" },
1381
- ingress: { ngrok: { domain: "foo.ngrok.app" } },
1382
- });
1600
+ const ws = makeWorkspace({ telegram: { botUsername: "example_bot" } });
1601
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1383
1602
  mockNgrokApiFetch([
1384
1603
  { tunnels: [] },
1385
1604
  {
@@ -1397,11 +1616,10 @@ describe("ngrok --domain spawn args", () => {
1397
1616
 
1398
1617
  expect(child).not.toBeNull();
1399
1618
  expect(spawnMock).toHaveBeenCalledTimes(1);
1400
- const [cmd, args] = spawnMock.mock.calls[0] as unknown as [
1401
- string,
1402
- string[],
1403
- ];
1619
+ const [cmd, args] = spawnMock.mock.calls[0];
1404
1620
  expect(cmd).toBe("ngrok");
1621
+ // Plain `ngrok http` so the user's default config — where their
1622
+ // authtoken lives — is what the agent loads.
1405
1623
  expect(args).toEqual([
1406
1624
  "http",
1407
1625
  "127.0.0.1:7830",
@@ -1409,16 +1627,15 @@ describe("ngrok --domain spawn args", () => {
1409
1627
  "--domain=foo.ngrok.app",
1410
1628
  ]);
1411
1629
 
1412
- const config = JSON.parse(
1413
- readFileSync(join(ws, "config.json"), "utf-8"),
1414
- ) as { ingress: { publicBaseUrl?: string } };
1415
- expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
1630
+ const config = IngressTestConfigSchema.parse(
1631
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1632
+ );
1633
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1416
1634
  });
1417
1635
 
1418
1636
  test("runNgrokTunnel without --domain reuses the saved domain and does not delete it", async () => {
1419
- const ws = makeWorkspace({
1420
- ingress: { ngrok: { domain: "foo.ngrok.app" } },
1421
- });
1637
+ const ws = makeWorkspace({});
1638
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1422
1639
  mockNgrokApiFetch([
1423
1640
  { tunnels: [] },
1424
1641
  {
@@ -1440,7 +1657,7 @@ describe("ngrok --domain spawn args", () => {
1440
1657
  }
1441
1658
 
1442
1659
  expect(spawnMock).toHaveBeenCalledTimes(1);
1443
- const [, args] = spawnMock.mock.calls[0] as unknown as [string, string[]];
1660
+ const [, args] = spawnMock.mock.calls[0];
1444
1661
  expect(args).toEqual([
1445
1662
  "http",
1446
1663
  "127.0.0.1:7831",
@@ -1448,38 +1665,52 @@ describe("ngrok --domain spawn args", () => {
1448
1665
  "--domain=foo.ngrok.app",
1449
1666
  ]);
1450
1667
 
1451
- const config = JSON.parse(
1452
- readFileSync(join(ws, "config.json"), "utf-8"),
1453
- ) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1454
- expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
1455
- expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
1668
+ const config = IngressTestConfigSchema.parse(
1669
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1670
+ );
1671
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1672
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1456
1673
  });
1457
1674
  });
1458
1675
 
1459
- describe("tunnel --detach", () => {
1676
+ describe("tunnel background mode", () => {
1460
1677
  const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
1461
1678
 
1462
1679
  function makeFakeChild(pid: number): ChildProcess {
1463
- const emitter = new EventEmitter();
1464
- return Object.assign(emitter, {
1465
- stdout: null,
1466
- stderr: null,
1467
- killed: false,
1468
- kill: () => true,
1469
- unref: () => {},
1470
- pid,
1471
- }) as unknown as ChildProcess;
1680
+ const child = new realChildProcess.ChildProcess();
1681
+ // kill() stays stubbed: the fake pid must never be signaled for real.
1682
+ // stdout/stderr: the real class resolves them through a getter over the
1683
+ // internal spawn handle, so null-shadow them via defineProperty — an
1684
+ // Object.assign [[Set]] through the getter-only accessor throws.
1685
+ Object.defineProperty(child, "stdout", { value: null });
1686
+ Object.defineProperty(child, "stderr", { value: null });
1687
+ return Object.assign(child, { pid, kill: () => true });
1472
1688
  }
1473
1689
 
1474
- const spawnMock = mock((..._args: unknown[]) => makeFakeChild(9999));
1690
+ const spawnMock = mock(
1691
+ (
1692
+ _command: string,
1693
+ _args: readonly string[],
1694
+ _options?: SpawnOptions,
1695
+ ): ChildProcess => makeFakeChild(9999),
1696
+ );
1475
1697
 
1476
- // spawnDetachedTunnel() scopes the log filename to its own (parent) pid,
1477
- // which in-process is this test runner's pid.
1478
1698
  function detachedLogPath(): string {
1479
- return join(getLogDir(), `tunnel-${process.pid}.log`);
1699
+ return join(getLogDir(), "tunnel.log");
1480
1700
  }
1481
1701
 
1482
1702
  beforeAll(() => {
1703
+ // The edge-targeting describe's afterAll restores the REAL provider
1704
+ // modules; re-establish the fakes the foreground test here needs.
1705
+ mock.module("../lib/tunnels/ngrok", () => ({
1706
+ ...realNgrok,
1707
+ runNgrokTunnel: runNgrokTunnelMock,
1708
+ }));
1709
+ mock.module("../lib/nginx-ingress.js", () => ({
1710
+ ...realNginxIngress,
1711
+ ensureTunnelEdge: ensureTunnelEdgeMock,
1712
+ getNginxVersion: getNginxVersionMock,
1713
+ }));
1483
1714
  mock.module("node:child_process", () => ({
1484
1715
  ...realChildProcess,
1485
1716
  spawn: spawnMock,
@@ -1488,10 +1719,22 @@ describe("tunnel --detach", () => {
1488
1719
 
1489
1720
  afterAll(() => {
1490
1721
  mock.module("node:child_process", () => realChildProcess);
1722
+ mock.module("../lib/tunnels/ngrok", () => realNgrok);
1723
+ mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
1491
1724
  });
1492
1725
 
1493
1726
  beforeEach(() => {
1494
1727
  spawnMock.mockClear();
1728
+ runNgrokTunnelMock.mockReset();
1729
+ runNgrokTunnelMock.mockResolvedValue(
1730
+ fakeTunnelResult("https://test.example.ngrok.dev"),
1731
+ );
1732
+ ensureTunnelEdgeMock.mockReset();
1733
+ ensureTunnelEdgeMock.mockResolvedValue({
1734
+ port: EDGE_PORT,
1735
+ started: true,
1736
+ includesWebApp: true,
1737
+ });
1495
1738
  const configHome = mkdtempSync(join(tmpdir(), "cadet-tunnel-xdg-"));
1496
1739
  tempDirs.push(configHome);
1497
1740
  process.env.XDG_CONFIG_HOME = configHome;
@@ -1509,15 +1752,8 @@ describe("tunnel --detach", () => {
1509
1752
  }
1510
1753
  });
1511
1754
 
1512
- test("--detach re-invokes without the flag as a detached child and reports readiness", async () => {
1513
- process.argv = [
1514
- "bun",
1515
- "cadet",
1516
- "tunnel",
1517
- "--provider",
1518
- "ngrok",
1519
- "--detach",
1520
- ];
1755
+ test("runs detached by default: re-invokes itself as a detached child and reports readiness", async () => {
1756
+ process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
1521
1757
 
1522
1758
  // Simulate the detached child writing its readiness line to the log file
1523
1759
  // the moment it is spawned.
@@ -1532,17 +1768,15 @@ describe("tunnel --detach", () => {
1532
1768
  const logs = await runTunnelCapturingLogs();
1533
1769
 
1534
1770
  expect(spawnMock).toHaveBeenCalledTimes(1);
1535
- const [, args, opts] = spawnMock.mock.calls[0] as unknown as [
1536
- string,
1537
- string[],
1538
- Record<string, unknown>,
1539
- ];
1771
+ const [, args, opts] = spawnMock.mock.calls[0];
1540
1772
  expect(args).toContain("tunnel");
1541
1773
  expect(args).toContain("--provider");
1542
1774
  expect(args).toContain("ngrok");
1543
- expect(args).not.toContain("--detach");
1544
- expect(args).not.toContain("-d");
1545
- expect(opts.detached).toBe(true);
1775
+ // The child runs the foreground path (it IS the background daemon); the
1776
+ // parent must not hand the default through or the child would daemonize
1777
+ // again.
1778
+ expect(args).toContain("--foreground");
1779
+ expect(opts?.detached).toBe(true);
1546
1780
 
1547
1781
  expect(logs).toContain("Tunnel established: https://foo.ngrok.app");
1548
1782
  expect(logs).toContain("Running in background (pid 9999)");
@@ -1554,37 +1788,30 @@ describe("tunnel --detach", () => {
1554
1788
  expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
1555
1789
  });
1556
1790
 
1557
- test("-d is equivalent to --detach", async () => {
1558
- process.argv = ["bun", "cadet", "tunnel", "--provider", "tailscale", "-d"];
1559
- spawnMock.mockImplementationOnce(() => {
1560
- appendFileSync(
1561
- detachedLogPath(),
1562
- "Tunnel established: https://tailnet.example.ts.net\n",
1563
- );
1564
- return makeFakeChild(1234);
1565
- });
1566
-
1567
- await runTunnelCapturingLogs();
1568
-
1569
- const [, args] = spawnMock.mock.calls[0] as unknown as [string, string[]];
1570
- expect(args).not.toContain("-d");
1571
- expect(args).not.toContain("--detach");
1572
- });
1573
-
1574
- test("reports an early child exit instead of hanging", async () => {
1791
+ test("--foreground runs the tunnel in this process instead of a detached child", async () => {
1575
1792
  process.argv = [
1576
1793
  "bun",
1577
1794
  "cadet",
1578
1795
  "tunnel",
1579
1796
  "--provider",
1580
1797
  "ngrok",
1581
- "--detach",
1798
+ "--foreground",
1582
1799
  ];
1800
+
1801
+ await runTunnelCapturingLogs();
1802
+
1803
+ expect(spawnMock).not.toHaveBeenCalled();
1804
+ expect(ensureTunnelEdgeMock).toHaveBeenCalled();
1805
+ expect(runNgrokTunnelMock).toHaveBeenCalled();
1806
+ });
1807
+
1808
+ test("reports an early child exit instead of hanging", async () => {
1809
+ process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
1583
1810
  spawnMock.mockImplementationOnce(() => {
1584
1811
  const child = makeFakeChild(4242);
1585
1812
  // Listeners attach synchronously right after spawn() returns; defer the
1586
1813
  // exit so they're in place before it fires.
1587
- setTimeout(() => (child as unknown as EventEmitter).emit("exit", 1), 0);
1814
+ setTimeout(() => child.emit("exit", 1), 0);
1588
1815
  return child;
1589
1816
  });
1590
1817
 
@@ -1596,14 +1823,7 @@ describe("tunnel --detach", () => {
1596
1823
  });
1597
1824
 
1598
1825
  test("an adopted ngrok tunnel gets an accurate stop message, not a false kill claim", async () => {
1599
- process.argv = [
1600
- "bun",
1601
- "cadet",
1602
- "tunnel",
1603
- "--provider",
1604
- "ngrok",
1605
- "--detach",
1606
- ];
1826
+ process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
1607
1827
  spawnMock.mockImplementationOnce(() => {
1608
1828
  appendFileSync(
1609
1829
  detachedLogPath(),