@mycadet/cli 0.12.7 → 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 (165) 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-platform-login.ts → show-cloud-login.ts} +3 -5
  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 +1 -1
  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 +1 -1
  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 +1 -1
  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 +3 -2
  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 +2 -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} +5 -5
  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__/guardian-token.test.ts +149 -56
  72. package/src/__tests__/hatch-provider-secrets.test.ts +2 -2
  73. package/src/__tests__/helpers/guardian-token.ts +22 -0
  74. package/src/__tests__/helpers/http.ts +10 -0
  75. package/src/__tests__/nginx-ingress.test.ts +18 -18
  76. package/src/__tests__/orphan-detection.test.ts +1 -1
  77. package/src/__tests__/pair.test.ts +1 -8
  78. package/src/__tests__/{ps-platform-status.test.ts → ps-cloud-status.test.ts} +79 -26
  79. package/src/__tests__/{recover.test.ts → restore.test.ts} +29 -28
  80. package/src/__tests__/retire.test.ts +58 -199
  81. package/src/__tests__/root-process-lockfile.test.ts +99 -0
  82. package/src/__tests__/sleep.test.ts +19 -19
  83. package/src/__tests__/statefulset.test.ts +8 -8
  84. package/src/__tests__/{tailscale-tunnel.test.ts → tailscale.test.ts} +1 -1
  85. package/src/__tests__/teleport-backup.test.ts +3 -3
  86. package/src/__tests__/teleport.test.ts +180 -196
  87. package/src/__tests__/tunnel.test.ts +255 -227
  88. package/src/__tests__/uninstall.test.ts +13 -4
  89. package/src/__tests__/upgrade-local.test.ts +3 -3
  90. package/src/__tests__/upgrade-replay-env.test.ts +12 -12
  91. package/src/__tests__/wake.test.ts +258 -41
  92. package/src/__tests__/workos-pkce.test.ts +2 -2
  93. package/src/commands/backup.ts +22 -22
  94. package/src/commands/client.ts +76 -76
  95. package/src/commands/debug.ts +20 -20
  96. package/src/commands/exec.ts +5 -5
  97. package/src/commands/hatch.ts +22 -22
  98. package/src/commands/login.ts +22 -22
  99. package/src/commands/logs.ts +1 -1
  100. package/src/commands/ps.ts +188 -79
  101. package/src/commands/restart.ts +30 -0
  102. package/src/commands/restore.ts +138 -20
  103. package/src/commands/retire.ts +46 -148
  104. package/src/commands/rollback.ts +18 -24
  105. package/src/commands/setup.ts +2 -2
  106. package/src/commands/teleport.ts +93 -93
  107. package/src/commands/terminal.ts +157 -17
  108. package/src/commands/tunnel.ts +80 -122
  109. package/src/commands/uninstall.ts +55 -7
  110. package/src/commands/unpair.ts +3 -3
  111. package/src/commands/upgrade.ts +22 -46
  112. package/src/commands/wake.ts +162 -46
  113. package/src/index.ts +3 -9
  114. package/src/lib/__tests__/{platform-client-signed-url.test.ts → cloud-client-signed-url.test.ts} +16 -16
  115. package/src/lib/__tests__/job-polling.test.ts +1 -1
  116. package/src/lib/__tests__/local-runtime-client.test.ts +4 -4
  117. package/src/lib/__tests__/runtime-url.test.ts +1 -1
  118. package/src/lib/assistant-api-key-resolution.ts +5 -5
  119. package/src/lib/assistant-client.ts +8 -8
  120. package/src/lib/assistant-config.ts +58 -12
  121. package/src/lib/{platform-client.ts → cloud-client.ts} +107 -98
  122. package/src/lib/cloud-login.ts +24 -24
  123. package/src/lib/{platform-releases.ts → cloud-releases.ts} +5 -5
  124. package/src/lib/cloud-values.ts +3 -0
  125. package/src/lib/config-utils.ts +1 -1
  126. package/src/lib/detached-process.ts +1 -1
  127. package/src/lib/docker.ts +111 -10
  128. package/src/lib/environments/__tests__/paths.test.ts +2 -2
  129. package/src/lib/environments/__tests__/resolve.test.ts +26 -26
  130. package/src/lib/environments/resolve.ts +8 -8
  131. package/src/lib/guardian-token.ts +36 -4
  132. package/src/lib/hatch-local.ts +19 -23
  133. package/src/lib/health-check.ts +6 -6
  134. package/src/lib/ingress-config.ts +8 -35
  135. package/src/lib/job-polling.ts +1 -1
  136. package/src/lib/live-voice/connection.ts +12 -12
  137. package/src/lib/live-voice/tunnel.test.ts +11 -11
  138. package/src/lib/live-voice/tunnel.ts +15 -15
  139. package/src/lib/local-runtime-client.ts +20 -20
  140. package/src/lib/local.ts +9 -30
  141. package/src/lib/nginx-ingress.ts +41 -14
  142. package/src/lib/process.ts +1 -1
  143. package/src/lib/provider-secrets.ts +3 -3
  144. package/src/lib/retire-apple-container.ts +1 -1
  145. package/src/lib/runtime-url.ts +8 -8
  146. package/src/lib/statefulset.ts +2 -2
  147. package/src/lib/sync-cloud-assistants.ts +18 -18
  148. package/src/lib/teleport-backup.ts +4 -4
  149. package/src/lib/terminal-client.ts +17 -17
  150. package/src/lib/terminal-session.ts +24 -24
  151. package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
  152. package/src/lib/tunnels/cadet.ts +29 -0
  153. package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +15 -4
  154. package/src/lib/tunnels/gateway-notify.ts +61 -0
  155. package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +201 -30
  156. package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +19 -5
  157. package/src/lib/upgrade-lifecycle.ts +3 -17
  158. package/src/lib/upgrade-preflight.ts +2 -2
  159. package/src/lib/workos-pkce.ts +5 -5
  160. package/node_modules/@mycadet/assistant-api/events/platform-disconnected.ts +0 -21
  161. package/src/commands/recover.ts +0 -131
  162. package/src/commands/roadmap.ts +0 -459
  163. package/src/commands/ssh.ts +0 -144
  164. package/src/commands/workflows.ts +0 -305
  165. 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,42 +23,62 @@ 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>(
@@ -74,6 +93,7 @@ const { tunnel } = await import("../commands/tunnel.js");
74
93
 
75
94
  const originalArgv = [...process.argv];
76
95
  const originalFetch = globalThis.fetch;
96
+ const originalConfigHome = process.env.XDG_CONFIG_HOME;
77
97
  const originalLockfileDir = process.env.CADET_LOCKFILE_DIR;
78
98
  const originalWorkspaceDir = process.env.CADET_WORKSPACE_DIR;
79
99
  const tempDirs: string[] = [];
@@ -126,10 +146,7 @@ function writeEntryWorkspaceConfig(
126
146
  entry: AssistantEntry,
127
147
  config: Record<string, unknown>,
128
148
  ): string {
129
- const workspaceDir = join(
130
- entry.resources!.instanceDir,
131
- "workspace",
132
- );
149
+ const workspaceDir = join(entry.resources!.instanceDir, "workspace");
133
150
  mkdirSync(workspaceDir, { recursive: true });
134
151
  writeFileSync(
135
152
  join(workspaceDir, "config.json"),
@@ -158,7 +175,7 @@ function writeLockfile(
158
175
  process.env.CADET_LOCKFILE_DIR = lockfileDir;
159
176
  mkdirSync(lockfileDir, { recursive: true });
160
177
  writeFileSync(
161
- join(lockfileDir, ".cadet.lock.json"),
178
+ join(lockfileDir, "lockfile.json"),
162
179
  JSON.stringify(
163
180
  {
164
181
  activeAssistant: activeAssistant ?? entries[0].assistantId,
@@ -170,6 +187,15 @@ function writeLockfile(
170
187
  );
171
188
  }
172
189
 
190
+ const IngressTestConfigSchema = z.object({
191
+ ingress: z
192
+ .looseObject({
193
+ publicBaseUrl: z.string().optional(),
194
+ ngrok: z.object({ domain: z.string().optional() }).optional(),
195
+ })
196
+ .optional(),
197
+ });
198
+
173
199
  /** Run tunnel() expecting exit(1); returns the joined console.error output. */
174
200
  async function runTunnelExpectingExit1(): Promise<{
175
201
  exited: boolean;
@@ -181,17 +207,17 @@ async function runTunnelExpectingExit1(): Promise<{
181
207
  errors.push(a.join(" "));
182
208
  },
183
209
  );
184
- const exitSpy = spyOn(process, "exit").mockImplementation(((
185
- code?: number,
186
- ) => {
187
- throw new Error(`exit:${code}`);
188
- }) as never);
210
+ const exitSpy = spyOn(process, "exit").mockImplementation(
211
+ (code?: number | string | undefined): never => {
212
+ throw new Error(`exit:${code}`);
213
+ },
214
+ );
189
215
 
190
216
  let exited = false;
191
217
  try {
192
218
  await tunnel();
193
219
  } catch (e) {
194
- exited = (e as Error).message === "exit:1";
220
+ exited = e instanceof Error && e.message === "exit:1";
195
221
  } finally {
196
222
  errSpy.mockRestore();
197
223
  exitSpy.mockRestore();
@@ -217,15 +243,27 @@ describe("tunnel edge targeting", () => {
217
243
  beforeEach(() => {
218
244
  process.argv = ["bun", "cadet", "tunnel", "--provider", "tailscale"];
219
245
  writeLockfile(makeLocalEntry());
220
- globalThis.fetch = (async () => {
246
+ globalThis.fetch = withPreconnect(async () => {
221
247
  throw new Error("gateway unavailable");
222
- }) as unknown as typeof globalThis.fetch;
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;
223
254
  runCloudflareTunnelMock.mockReset();
224
- runCloudflareTunnelMock.mockResolvedValue(undefined);
255
+ runCloudflareTunnelMock.mockResolvedValue(
256
+ fakeTunnelResult("https://test.example.cloudflare.dev"),
257
+ );
225
258
  runNgrokTunnelMock.mockReset();
226
- runNgrokTunnelMock.mockResolvedValue(undefined);
259
+ runNgrokTunnelMock.mockResolvedValue(
260
+ fakeTunnelResult("https://test.example.ngrok.dev"),
261
+ );
227
262
  runTailscaleTunnelMock.mockReset();
228
- runTailscaleTunnelMock.mockResolvedValue(undefined);
263
+ runTailscaleTunnelMock.mockResolvedValue(
264
+ fakeTunnelResult("https://test-machine.tailnet.ts.net"),
265
+ );
266
+ notifyGatewayOfPublicUrlMock.mockReset();
229
267
  ensureTunnelEdgeMock.mockReset();
230
268
  ensureTunnelEdgeMock.mockResolvedValue({
231
269
  port: EDGE_PORT,
@@ -237,6 +275,12 @@ describe("tunnel edge targeting", () => {
237
275
  afterEach(() => {
238
276
  process.argv = originalArgv;
239
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
+ }
240
284
  if (originalLockfileDir === undefined) {
241
285
  delete process.env.CADET_LOCKFILE_DIR;
242
286
  } else {
@@ -253,9 +297,9 @@ describe("tunnel edge targeting", () => {
253
297
  });
254
298
 
255
299
  afterAll(() => {
256
- mock.module("../lib/cloudflare-tunnel.js", () => realCloudflareTunnel);
257
- mock.module("../lib/ngrok", () => realNgrok);
258
- 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);
259
303
  mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
260
304
  });
261
305
 
@@ -267,10 +311,7 @@ describe("tunnel edge targeting", () => {
267
311
 
268
312
  const logs = await runTunnelCapturingLogs();
269
313
 
270
- const workspaceDir = join(
271
- entry.resources!.instanceDir,
272
- "workspace",
273
- );
314
+ const workspaceDir = join(entry.resources!.instanceDir, "workspace");
274
315
  expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
275
316
  assistantId: "assistant-1",
276
317
  workspaceDir,
@@ -286,6 +327,20 @@ describe("tunnel edge targeting", () => {
286
327
  expect(logs).toContain("serves remote web + webhooks");
287
328
  });
288
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
+
289
344
  test("an active cloud assistant falls back to the sole local entry with a note", async () => {
290
345
  const cloud = makeCloudEntry();
291
346
  const local = makeLocalEntry();
@@ -294,10 +349,7 @@ describe("tunnel edge targeting", () => {
294
349
 
295
350
  const logs = await runTunnelCapturingLogs();
296
351
 
297
- const workspaceDir = join(
298
- local.resources!.instanceDir,
299
- "workspace",
300
- );
352
+ const workspaceDir = join(local.resources!.instanceDir, "workspace");
301
353
  expect(logs).toContain(
302
354
  "Assistant 'cloud-1' runs on Cadet Cloud and needs no tunnel. " +
303
355
  "Tunneling the local assistant 'assistant-1' instead.",
@@ -419,14 +471,7 @@ describe("tunnel edge targeting", () => {
419
471
  test("a positional cloud assistant name errors without auto-fallback", async () => {
420
472
  const local = makeLocalEntry();
421
473
  writeLockfile([makeCloudEntry(), local], local.assistantId);
422
- process.argv = [
423
- "bun",
424
- "cadet",
425
- "tunnel",
426
- "cloud-1",
427
- "--provider",
428
- "ngrok",
429
- ];
474
+ process.argv = ["bun", "cadet", "tunnel", "cloud-1", "--provider", "ngrok"];
430
475
 
431
476
  const { exited, errors } = await runTunnelExpectingExit1();
432
477
 
@@ -449,10 +494,7 @@ describe("tunnel edge targeting", () => {
449
494
 
450
495
  await runTunnelCapturingLogs();
451
496
 
452
- const workspaceDir = join(
453
- local.resources!.instanceDir,
454
- "workspace",
455
- );
497
+ const workspaceDir = join(local.resources!.instanceDir, "workspace");
456
498
  expect(runNgrokTunnelMock).toHaveBeenCalledWith({
457
499
  port: EDGE_PORT,
458
500
  assistantId: "assistant-1",
@@ -476,10 +518,7 @@ describe("tunnel edge targeting", () => {
476
518
 
477
519
  await runTunnelCapturingLogs();
478
520
 
479
- const workspaceDir = join(
480
- local.resources!.instanceDir,
481
- "workspace",
482
- );
521
+ const workspaceDir = join(local.resources!.instanceDir, "workspace");
483
522
  expect(runNgrokTunnelMock).toHaveBeenCalledWith({
484
523
  port: EDGE_PORT,
485
524
  assistantId: "assistant-1",
@@ -491,14 +530,7 @@ describe("tunnel edge targeting", () => {
491
530
  const workspaceDir = useTempDefaultWorkspaceDir();
492
531
  const local = makeLocalEntry();
493
532
  writeLockfile([local, makeAppleContainerEntry()], local.assistantId);
494
- process.argv = [
495
- "bun",
496
- "cadet",
497
- "tunnel",
498
- "apple-1",
499
- "--provider",
500
- "ngrok",
501
- ];
533
+ process.argv = ["bun", "cadet", "tunnel", "apple-1", "--provider", "ngrok"];
502
534
 
503
535
  await runTunnelCapturingLogs();
504
536
 
@@ -644,46 +676,40 @@ describe("tunnel edge targeting", () => {
644
676
  expect(logs).toContain("serves webhooks only");
645
677
  });
646
678
 
647
- test("a bare invocation exits listing the providers to choose from", async () => {
648
- writeLockfile(makeLocalEntry());
679
+ test("a bare invocation tunnels the default provider", async () => {
680
+ const entry = makeLocalEntry();
681
+ writeLockfile(entry);
649
682
  process.argv = ["bun", "cadet", "tunnel"];
650
683
 
651
- const { exited, errors } = await runTunnelExpectingExit1();
684
+ const logs = await runTunnelCapturingLogs();
652
685
 
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();
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();
660
693
  expect(runTailscaleTunnelMock).not.toHaveBeenCalled();
661
- expect(runNgrokTunnelMock).not.toHaveBeenCalled();
662
694
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
663
695
  });
664
696
 
665
- test("a named assistant without --provider exits before resolving it", async () => {
666
- 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);
667
701
  process.argv = ["bun", "cadet", "tunnel", "Ada"];
668
702
 
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"];
683
-
684
- const { errors } = await runTunnelExpectingExit1();
703
+ const logs = await runTunnelCapturingLogs();
685
704
 
686
- 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();
687
713
  });
688
714
 
689
715
  test("--provider tailscale keeps the webhook callback base and says so", async () => {
@@ -838,7 +864,7 @@ describe("tunnel edge targeting", () => {
838
864
 
839
865
  expect(exited).toBe(true);
840
866
  expect(errors).toContain(
841
- "--domain is only supported with --provider ngrok",
867
+ "--domain is not supported with --provider cloudflare",
842
868
  );
843
869
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
844
870
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
@@ -847,15 +873,9 @@ describe("tunnel edge targeting", () => {
847
873
  test("--clear-domain drops the saved domain and runs domainless", async () => {
848
874
  const entry = makeLocalEntry();
849
875
  writeLockfile(entry);
850
- const workspaceDir = join(
851
- entry.resources!.instanceDir,
852
- "workspace",
853
- );
876
+ const workspaceDir = join(entry.resources!.instanceDir, "workspace");
854
877
  mkdirSync(workspaceDir, { recursive: true });
855
- writeFileSync(
856
- join(workspaceDir, "config.json"),
857
- JSON.stringify({ ingress: { ngrok: { domain: "dead.ngrok.app" } } }),
858
- );
878
+ realNgrok.saveNgrokDomain("dead.ngrok.app");
859
879
  process.argv = [
860
880
  "bun",
861
881
  "cadet",
@@ -867,11 +887,9 @@ describe("tunnel edge targeting", () => {
867
887
 
868
888
  const logs = await runTunnelCapturingLogs();
869
889
 
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();
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();
875
893
  // The run proceeds domainless: no `domain` key reaches runNgrokTunnel.
876
894
  expect(runNgrokTunnelMock).toHaveBeenCalledWith({
877
895
  port: EDGE_PORT,
@@ -913,7 +931,7 @@ describe("tunnel edge targeting", () => {
913
931
 
914
932
  expect(exited).toBe(true);
915
933
  expect(errors).toContain(
916
- "--clear-domain is only supported with --provider ngrok",
934
+ "--clear-domain is not supported with --provider cloudflare",
917
935
  );
918
936
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
919
937
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
@@ -945,7 +963,7 @@ describe("tunnel edge targeting", () => {
945
963
  try {
946
964
  await tunnel();
947
965
  } catch (e) {
948
- err = e as Error;
966
+ err = e instanceof Error ? e : undefined;
949
967
  }
950
968
 
951
969
  expect(err?.message).toContain("is not yet implemented");
@@ -959,20 +977,26 @@ describe("tunnel edge targeting", () => {
959
977
 
960
978
  describe("ngrok --domain spawn args", () => {
961
979
  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
- });
980
+ let lastChild: ChildProcess | null = null;
981
+
982
+ const spawnMock = mock(
983
+ (
984
+ _command: string,
985
+ _args: readonly string[],
986
+ _options?: SpawnOptions,
987
+ ): ChildProcess => {
988
+ const child = new realChildProcess.ChildProcess();
989
+ // kill() stays stubbed: the fake pid must never be signaled for real.
990
+ // stdout/stderr: the real class resolves them through a getter over the
991
+ // internal spawn handle, so null-shadow them via defineProperty — an
992
+ // Object.assign [[Set]] through the getter-only accessor throws.
993
+ Object.defineProperty(child, "stdout", { value: null });
994
+ Object.defineProperty(child, "stderr", { value: null });
995
+ const fake = Object.assign(child, { pid: 4242, kill: () => true });
996
+ lastChild = fake;
997
+ return fake;
998
+ },
999
+ );
976
1000
  const execFileSyncMock = mock(() => "ngrok version 3.9.0");
977
1001
 
978
1002
  beforeAll(() => {
@@ -991,11 +1015,22 @@ describe("ngrok --domain spawn args", () => {
991
1015
  spawnMock.mockClear();
992
1016
  lastChild = null;
993
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;
994
1023
  mockNgrokApiFetch([{ tunnels: [] }]);
995
1024
  });
996
1025
 
997
1026
  afterEach(() => {
998
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
+ }
999
1034
  if (originalContainerized === undefined) {
1000
1035
  delete process.env.IS_CONTAINERIZED;
1001
1036
  } else {
@@ -1021,22 +1056,20 @@ describe("ngrok --domain spawn args", () => {
1021
1056
  /** ngrok local API stub replaying one response per call, last one repeated. */
1022
1057
  function mockNgrokApiFetch(responses: { tunnels: StubTunnel[] }[]): void {
1023
1058
  let call = 0;
1024
- globalThis.fetch = (async () => {
1059
+ globalThis.fetch = withPreconnect(async () => {
1025
1060
  const body = responses[Math.min(call, responses.length - 1)];
1026
1061
  call++;
1027
1062
  return new Response(JSON.stringify(body), {
1028
1063
  status: 200,
1029
1064
  headers: { "Content-Type": "application/json" },
1030
1065
  });
1031
- }) as unknown as typeof globalThis.fetch;
1066
+ });
1032
1067
  }
1033
1068
 
1034
1069
  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
- };
1070
+ const config = IngressTestConfigSchema.parse(
1071
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1072
+ );
1040
1073
  return config.ingress ?? {};
1041
1074
  }
1042
1075
 
@@ -1048,7 +1081,7 @@ describe("ngrok --domain spawn args", () => {
1048
1081
  const lockfileDir = mkdtempSync(join(tmpdir(), "cadet-ngrok-lockfile-"));
1049
1082
  tempDirs.push(lockfileDir);
1050
1083
  writeFileSync(
1051
- join(lockfileDir, ".cadet.lock.json"),
1084
+ join(lockfileDir, "lockfile.json"),
1052
1085
  JSON.stringify({
1053
1086
  activeAssistant: assistantId,
1054
1087
  assistants: [
@@ -1104,33 +1137,33 @@ describe("ngrok --domain spawn args", () => {
1104
1137
  }
1105
1138
 
1106
1139
  expect(spawnMock).toHaveBeenCalledTimes(1);
1107
- const [cmd, args] = spawnMock.mock.calls[0] as unknown as [
1108
- string,
1109
- string[],
1110
- ];
1140
+ const [cmd, args] = spawnMock.mock.calls[0];
1111
1141
  expect(cmd).toBe("ngrok");
1112
1142
  expect(args).toEqual([
1113
- "http",
1114
- "127.0.0.1:7831",
1143
+ "start",
1144
+ "cadet",
1145
+ `--config=${realNgrok.ngrokConfigPath()}`,
1115
1146
  "--log=stdout",
1116
- "--domain=foo.ngrok.app",
1117
1147
  ]);
1148
+ // The reserved domain rides the XDG config the agent loads, not a flag.
1149
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1118
1150
 
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");
1151
+ const config = IngressTestConfigSchema.parse(
1152
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1153
+ );
1154
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1124
1155
  });
1125
1156
 
1126
1157
  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;
1158
+ globalThis.fetch = withPreconnect(
1159
+ async () =>
1160
+ new Response(
1161
+ JSON.stringify({
1162
+ tunnels: [{ public_url: publicUrl, config: { addr } }],
1163
+ }),
1164
+ { status: 200, headers: { "Content-Type": "application/json" } },
1165
+ ),
1166
+ );
1134
1167
  }
1135
1168
 
1136
1169
  test("runNgrokTunnel rejects an existing tunnel that mismatches the requested domain", async () => {
@@ -1143,11 +1176,11 @@ describe("ngrok --domain spawn args", () => {
1143
1176
  errors.push(a.join(" "));
1144
1177
  },
1145
1178
  );
1146
- const exitSpy = spyOn(process, "exit").mockImplementation(((
1147
- code?: number,
1148
- ) => {
1149
- throw new Error(`exit:${code}`);
1150
- }) as never);
1179
+ const exitSpy = spyOn(process, "exit").mockImplementation(
1180
+ (code?: number | string | undefined): never => {
1181
+ throw new Error(`exit:${code}`);
1182
+ },
1183
+ );
1151
1184
 
1152
1185
  try {
1153
1186
  await expect(
@@ -1170,9 +1203,9 @@ describe("ngrok --domain spawn args", () => {
1170
1203
  expect(combined).toContain("Stop the existing ngrok agent");
1171
1204
  expect(spawnMock).not.toHaveBeenCalled();
1172
1205
 
1173
- const config = JSON.parse(
1174
- readFileSync(join(ws, "config.json"), "utf-8"),
1175
- ) as { ingress?: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1206
+ const config = IngressTestConfigSchema.parse(
1207
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1208
+ );
1176
1209
  expect(config.ingress?.publicBaseUrl).toBeUndefined();
1177
1210
  expect(config.ingress?.ngrok).toBeUndefined();
1178
1211
  });
@@ -1189,6 +1222,8 @@ describe("ngrok --domain spawn args", () => {
1189
1222
  // The adopt path blocks until SIGINT/SIGTERM; pump SIGINT until the
1190
1223
  // listener is registered and the promise settles. Earlier tests leak
1191
1224
  // SIGINT handlers that call process.exit, so no-op it while pumping.
1225
+ // LOAD-BEARING cast: a no-op exit can't honestly be typed `: never` — it
1226
+ // must return so leaked SIGINT handlers can't crash the pump below.
1192
1227
  const exitSpy = spyOn(process, "exit").mockImplementation(
1193
1228
  (() => undefined) as never,
1194
1229
  );
@@ -1201,18 +1236,16 @@ describe("ngrok --domain spawn args", () => {
1201
1236
  }
1202
1237
 
1203
1238
  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");
1239
+ const config = IngressTestConfigSchema.parse(
1240
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1241
+ );
1242
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1243
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1209
1244
  });
1210
1245
 
1211
1246
  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
- });
1247
+ const ws = makeWorkspace({ telegram: { botUsername: "example_bot" } });
1248
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1216
1249
  mockTunnelListFetch("https://other.ngrok-free.app", "localhost:7830");
1217
1250
 
1218
1251
  const warnings: string[] = [];
@@ -1240,13 +1273,13 @@ describe("ngrok --domain spawn args", () => {
1240
1273
  "cadet tunnel --provider ngrok --domain foo.ngrok.app",
1241
1274
  );
1242
1275
 
1243
- const config = JSON.parse(
1244
- readFileSync(join(ws, "config.json"), "utf-8"),
1245
- ) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1276
+ const config = IngressTestConfigSchema.parse(
1277
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1278
+ );
1246
1279
  // The mismatched tunnel is not blessed in config…
1247
- expect(config.ingress.publicBaseUrl).toBeUndefined();
1280
+ expect(config.ingress?.publicBaseUrl).toBeUndefined();
1248
1281
  // …and the reserved domain stays saved as standing intent.
1249
- expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
1282
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1250
1283
  });
1251
1284
 
1252
1285
  test("an adopted automatic tunnel records the assistant it fronts", async () => {
@@ -1329,9 +1362,9 @@ describe("ngrok --domain spawn args", () => {
1329
1362
  expect(combined).toContain("not 18080");
1330
1363
  expect(combined).toContain("cadet tunnel --provider ngrok");
1331
1364
 
1332
- const config = JSON.parse(
1333
- readFileSync(join(ws, "config.json"), "utf-8"),
1334
- ) as { ingress?: { publicBaseUrl?: string } };
1365
+ const config = IngressTestConfigSchema.parse(
1366
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1367
+ );
1335
1368
  expect(config.ingress?.publicBaseUrl).toBeUndefined();
1336
1369
  });
1337
1370
 
@@ -1345,11 +1378,11 @@ describe("ngrok --domain spawn args", () => {
1345
1378
  errors.push(a.join(" "));
1346
1379
  },
1347
1380
  );
1348
- const exitSpy = spyOn(process, "exit").mockImplementation(((
1349
- code?: number,
1350
- ) => {
1351
- throw new Error(`exit:${code}`);
1352
- }) as never);
1381
+ const exitSpy = spyOn(process, "exit").mockImplementation(
1382
+ (code?: number | string | undefined): never => {
1383
+ throw new Error(`exit:${code}`);
1384
+ },
1385
+ );
1353
1386
 
1354
1387
  try {
1355
1388
  await expect(
@@ -1369,17 +1402,15 @@ describe("ngrok --domain spawn args", () => {
1369
1402
  expect(combined).toContain("Stop the existing ngrok agent");
1370
1403
  expect(spawnMock).not.toHaveBeenCalled();
1371
1404
 
1372
- const config = JSON.parse(
1373
- readFileSync(join(ws, "config.json"), "utf-8"),
1374
- ) as { ingress?: { publicBaseUrl?: string } };
1405
+ const config = IngressTestConfigSchema.parse(
1406
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1407
+ );
1375
1408
  expect(config.ingress?.publicBaseUrl).toBeUndefined();
1376
1409
  });
1377
1410
 
1378
1411
  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
- });
1412
+ const ws = makeWorkspace({ telegram: { botUsername: "example_bot" } });
1413
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1383
1414
  mockNgrokApiFetch([
1384
1415
  { tunnels: [] },
1385
1416
  {
@@ -1397,28 +1428,24 @@ describe("ngrok --domain spawn args", () => {
1397
1428
 
1398
1429
  expect(child).not.toBeNull();
1399
1430
  expect(spawnMock).toHaveBeenCalledTimes(1);
1400
- const [cmd, args] = spawnMock.mock.calls[0] as unknown as [
1401
- string,
1402
- string[],
1403
- ];
1431
+ const [cmd, args] = spawnMock.mock.calls[0];
1404
1432
  expect(cmd).toBe("ngrok");
1405
1433
  expect(args).toEqual([
1406
- "http",
1407
- "127.0.0.1:7830",
1434
+ "start",
1435
+ "cadet",
1436
+ `--config=${realNgrok.ngrokConfigPath()}`,
1408
1437
  "--log=stdout",
1409
- "--domain=foo.ngrok.app",
1410
1438
  ]);
1411
1439
 
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");
1440
+ const config = IngressTestConfigSchema.parse(
1441
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1442
+ );
1443
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1416
1444
  });
1417
1445
 
1418
1446
  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
- });
1447
+ const ws = makeWorkspace({});
1448
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1422
1449
  mockNgrokApiFetch([
1423
1450
  { tunnels: [] },
1424
1451
  {
@@ -1440,19 +1467,19 @@ describe("ngrok --domain spawn args", () => {
1440
1467
  }
1441
1468
 
1442
1469
  expect(spawnMock).toHaveBeenCalledTimes(1);
1443
- const [, args] = spawnMock.mock.calls[0] as unknown as [string, string[]];
1470
+ const [, args] = spawnMock.mock.calls[0];
1444
1471
  expect(args).toEqual([
1445
- "http",
1446
- "127.0.0.1:7831",
1472
+ "start",
1473
+ "cadet",
1474
+ `--config=${realNgrok.ngrokConfigPath()}`,
1447
1475
  "--log=stdout",
1448
- "--domain=foo.ngrok.app",
1449
1476
  ]);
1450
1477
 
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");
1478
+ const config = IngressTestConfigSchema.parse(
1479
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1480
+ );
1481
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1482
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1456
1483
  });
1457
1484
  });
1458
1485
 
@@ -1460,18 +1487,23 @@ describe("tunnel --detach", () => {
1460
1487
  const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
1461
1488
 
1462
1489
  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;
1490
+ const child = new realChildProcess.ChildProcess();
1491
+ // kill() stays stubbed: the fake pid must never be signaled for real.
1492
+ // stdout/stderr: the real class resolves them through a getter over the
1493
+ // internal spawn handle, so null-shadow them via defineProperty — an
1494
+ // Object.assign [[Set]] through the getter-only accessor throws.
1495
+ Object.defineProperty(child, "stdout", { value: null });
1496
+ Object.defineProperty(child, "stderr", { value: null });
1497
+ return Object.assign(child, { pid, kill: () => true });
1472
1498
  }
1473
1499
 
1474
- const spawnMock = mock((..._args: unknown[]) => makeFakeChild(9999));
1500
+ const spawnMock = mock(
1501
+ (
1502
+ _command: string,
1503
+ _args: readonly string[],
1504
+ _options?: SpawnOptions,
1505
+ ): ChildProcess => makeFakeChild(9999),
1506
+ );
1475
1507
 
1476
1508
  // spawnDetachedTunnel() scopes the log filename to its own (parent) pid,
1477
1509
  // which in-process is this test runner's pid.
@@ -1532,17 +1564,13 @@ describe("tunnel --detach", () => {
1532
1564
  const logs = await runTunnelCapturingLogs();
1533
1565
 
1534
1566
  expect(spawnMock).toHaveBeenCalledTimes(1);
1535
- const [, args, opts] = spawnMock.mock.calls[0] as unknown as [
1536
- string,
1537
- string[],
1538
- Record<string, unknown>,
1539
- ];
1567
+ const [, args, opts] = spawnMock.mock.calls[0];
1540
1568
  expect(args).toContain("tunnel");
1541
1569
  expect(args).toContain("--provider");
1542
1570
  expect(args).toContain("ngrok");
1543
1571
  expect(args).not.toContain("--detach");
1544
1572
  expect(args).not.toContain("-d");
1545
- expect(opts.detached).toBe(true);
1573
+ expect(opts?.detached).toBe(true);
1546
1574
 
1547
1575
  expect(logs).toContain("Tunnel established: https://foo.ngrok.app");
1548
1576
  expect(logs).toContain("Running in background (pid 9999)");
@@ -1566,7 +1594,7 @@ describe("tunnel --detach", () => {
1566
1594
 
1567
1595
  await runTunnelCapturingLogs();
1568
1596
 
1569
- const [, args] = spawnMock.mock.calls[0] as unknown as [string, string[]];
1597
+ const [, args] = spawnMock.mock.calls[0];
1570
1598
  expect(args).not.toContain("-d");
1571
1599
  expect(args).not.toContain("--detach");
1572
1600
  });
@@ -1584,7 +1612,7 @@ describe("tunnel --detach", () => {
1584
1612
  const child = makeFakeChild(4242);
1585
1613
  // Listeners attach synchronously right after spawn() returns; defer the
1586
1614
  // exit so they're in place before it fires.
1587
- setTimeout(() => (child as unknown as EventEmitter).emit("exit", 1), 0);
1615
+ setTimeout(() => child.emit("exit", 1), 0);
1588
1616
  return child;
1589
1617
  });
1590
1618