@mycadet/cli 0.12.7 → 0.12.9

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 (179) 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 +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} +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 +21 -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 +1 -8
  80. package/src/__tests__/{ps-platform-status.test.ts → ps-cloud-status.test.ts} +79 -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 +181 -197
  90. package/src/__tests__/tunnel.test.ts +257 -222
  91. package/src/__tests__/uninstall.test.ts +13 -4
  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 -76
  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 +23 -23
  104. package/src/commands/login.ts +22 -22
  105. package/src/commands/logs.ts +8 -8
  106. package/src/commands/ps.ts +244 -86
  107. package/src/commands/restart.ts +30 -0
  108. package/src/commands/restore.ts +140 -22
  109. package/src/commands/retire.ts +48 -150
  110. package/src/commands/rollback.ts +20 -26
  111. package/src/commands/setup.ts +2 -2
  112. package/src/commands/sleep.ts +2 -2
  113. package/src/commands/teleport.ts +109 -109
  114. package/src/commands/terminal.ts +158 -18
  115. package/src/commands/tunnel.ts +138 -157
  116. package/src/commands/uninstall.ts +57 -9
  117. package/src/commands/unpair.ts +3 -3
  118. package/src/commands/upgrade.ts +30 -54
  119. package/src/commands/use.ts +1 -1
  120. package/src/commands/wake.ts +166 -50
  121. package/src/index.ts +18 -9
  122. package/src/lib/__tests__/{platform-client-signed-url.test.ts → cloud-client-signed-url.test.ts} +16 -16
  123. package/src/lib/__tests__/docker.test.ts +168 -20
  124. package/src/lib/__tests__/job-polling.test.ts +2 -2
  125. package/src/lib/__tests__/local-runtime-client.test.ts +4 -4
  126. package/src/lib/__tests__/runtime-url.test.ts +1 -1
  127. package/src/lib/assistant-api-key-resolution.ts +5 -5
  128. package/src/lib/assistant-client.ts +8 -8
  129. package/src/lib/assistant-config.ts +58 -12
  130. package/src/lib/{platform-client.ts → cloud-client.ts} +107 -98
  131. package/src/lib/cloud-login.ts +24 -24
  132. package/src/lib/{platform-releases.ts → cloud-releases.ts} +11 -9
  133. package/src/lib/cloud-values.ts +3 -0
  134. package/src/lib/config-utils.ts +1 -1
  135. package/src/lib/detached-process.ts +1 -1
  136. package/src/lib/docker.ts +260 -53
  137. package/src/lib/ensure-binary.ts +171 -0
  138. package/src/lib/environments/__tests__/paths.test.ts +2 -2
  139. package/src/lib/environments/__tests__/resolve.test.ts +26 -26
  140. package/src/lib/environments/resolve.ts +8 -8
  141. package/src/lib/flag-args.test.ts +1 -1
  142. package/src/lib/flag-args.ts +1 -1
  143. package/src/lib/guardian-token.ts +37 -5
  144. package/src/lib/hatch-local.ts +19 -23
  145. package/src/lib/health-check.ts +6 -6
  146. package/src/lib/ingress-config.ts +8 -35
  147. package/src/lib/job-polling.ts +2 -2
  148. package/src/lib/live-voice/connection.ts +12 -12
  149. package/src/lib/live-voice/tunnel.test.ts +11 -11
  150. package/src/lib/live-voice/tunnel.ts +15 -15
  151. package/src/lib/local-runtime-client.ts +20 -20
  152. package/src/lib/local.ts +10 -31
  153. package/src/lib/nginx-ingress.ts +42 -15
  154. package/src/lib/pair.ts +1 -1
  155. package/src/lib/process.ts +1 -1
  156. package/src/lib/provider-secrets.ts +3 -3
  157. package/src/lib/retire-apple-container.ts +1 -1
  158. package/src/lib/runtime-url.ts +8 -8
  159. package/src/lib/statefulset.ts +2 -2
  160. package/src/lib/sync-cloud-assistants.ts +19 -19
  161. package/src/lib/teleport-backup.ts +4 -4
  162. package/src/lib/terminal-client.ts +17 -17
  163. package/src/lib/terminal-session.ts +24 -24
  164. package/src/lib/{tunnel-edge.ts → tunnel.ts} +199 -11
  165. package/src/lib/tunnels/cadet.ts +29 -0
  166. package/src/lib/{cloudflare-tunnel.ts → tunnels/cloudflare.ts} +36 -15
  167. package/src/lib/tunnels/gateway-notify.ts +61 -0
  168. package/src/lib/{ngrok.ts → tunnels/ngrok.ts} +350 -89
  169. package/src/lib/{tailscale-tunnel.ts → tunnels/tailscale.ts} +53 -9
  170. package/src/lib/upgrade-lifecycle.ts +8 -21
  171. package/src/lib/upgrade-preflight.ts +2 -2
  172. package/src/lib/workos-pkce.ts +5 -5
  173. package/node_modules/@mycadet/assistant-api/events/platform-disconnected.ts +0 -21
  174. package/node_modules/@mycadet/assistant-api/events/show-platform-login.ts +0 -20
  175. package/src/commands/recover.ts +0 -131
  176. package/src/commands/roadmap.ts +0 -459
  177. package/src/commands/ssh.ts +0 -144
  178. package/src/commands/workflows.ts +0 -305
  179. 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();
@@ -217,15 +247,27 @@ describe("tunnel edge targeting", () => {
217
247
  beforeEach(() => {
218
248
  process.argv = ["bun", "cadet", "tunnel", "--provider", "tailscale"];
219
249
  writeLockfile(makeLocalEntry());
220
- globalThis.fetch = (async () => {
250
+ globalThis.fetch = withPreconnect(async () => {
221
251
  throw new Error("gateway unavailable");
222
- }) as unknown as typeof globalThis.fetch;
252
+ });
253
+ // The ngrok config file lives under the XDG config dir; a fresh one per
254
+ // test keeps the saved-domain state hermetic.
255
+ const configHome = mkdtempSync(join(tmpdir(), "cadet-tunnel-xdg-"));
256
+ tempDirs.push(configHome);
257
+ process.env.XDG_CONFIG_HOME = configHome;
223
258
  runCloudflareTunnelMock.mockReset();
224
- runCloudflareTunnelMock.mockResolvedValue(undefined);
259
+ runCloudflareTunnelMock.mockResolvedValue(
260
+ fakeTunnelResult("https://test.example.cloudflare.dev"),
261
+ );
225
262
  runNgrokTunnelMock.mockReset();
226
- runNgrokTunnelMock.mockResolvedValue(undefined);
263
+ runNgrokTunnelMock.mockResolvedValue(
264
+ fakeTunnelResult("https://test.example.ngrok.dev"),
265
+ );
227
266
  runTailscaleTunnelMock.mockReset();
228
- runTailscaleTunnelMock.mockResolvedValue(undefined);
267
+ runTailscaleTunnelMock.mockResolvedValue(
268
+ fakeTunnelResult("https://test-machine.tailnet.ts.net"),
269
+ );
270
+ notifyGatewayOfPublicUrlMock.mockReset();
229
271
  ensureTunnelEdgeMock.mockReset();
230
272
  ensureTunnelEdgeMock.mockResolvedValue({
231
273
  port: EDGE_PORT,
@@ -237,6 +279,12 @@ describe("tunnel edge targeting", () => {
237
279
  afterEach(() => {
238
280
  process.argv = originalArgv;
239
281
  globalThis.fetch = originalFetch;
282
+ rmSync(realNgrok.ngrokConfigPath(), { force: true });
283
+ if (originalConfigHome === undefined) {
284
+ delete process.env.XDG_CONFIG_HOME;
285
+ } else {
286
+ process.env.XDG_CONFIG_HOME = originalConfigHome;
287
+ }
240
288
  if (originalLockfileDir === undefined) {
241
289
  delete process.env.CADET_LOCKFILE_DIR;
242
290
  } else {
@@ -253,9 +301,9 @@ describe("tunnel edge targeting", () => {
253
301
  });
254
302
 
255
303
  afterAll(() => {
256
- mock.module("../lib/cloudflare-tunnel.js", () => realCloudflareTunnel);
257
- mock.module("../lib/ngrok", () => realNgrok);
258
- mock.module("../lib/tailscale-tunnel.js", () => realTailscaleTunnel);
304
+ mock.module("../lib/tunnels/cloudflare.js", () => realCloudflareTunnel);
305
+ mock.module("../lib/tunnels/ngrok", () => realNgrok);
306
+ mock.module("../lib/tunnels/tailscale.js", () => realTailscaleTunnel);
259
307
  mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
260
308
  });
261
309
 
@@ -267,10 +315,7 @@ describe("tunnel edge targeting", () => {
267
315
 
268
316
  const logs = await runTunnelCapturingLogs();
269
317
 
270
- const workspaceDir = join(
271
- entry.resources!.instanceDir,
272
- "workspace",
273
- );
318
+ const workspaceDir = join(entry.resources!.instanceDir, "workspace");
274
319
  expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
275
320
  assistantId: "assistant-1",
276
321
  workspaceDir,
@@ -283,7 +328,20 @@ describe("tunnel edge targeting", () => {
283
328
  });
284
329
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
285
330
  expect(logs).toContain(`Started the nginx edge on 127.0.0.1:${EDGE_PORT}`);
286
- expect(logs).toContain("serves remote web + webhooks");
331
+ });
332
+
333
+ test("the tunnel level notifies the gateway of the provider's public URL", async () => {
334
+ writeLockfile(makeLocalEntry());
335
+ process.argv = ["bun", "cadet", "tunnel", "--provider", "ngrok"];
336
+
337
+ await runTunnelCapturingLogs();
338
+
339
+ // The provider hands its URL back; the notification is the tunnel
340
+ // level's job, so it holds no matter which provider ran.
341
+ expect(notifyGatewayOfPublicUrlMock).toHaveBeenCalledWith(
342
+ "https://test.example.ngrok.dev",
343
+ "assistant-1",
344
+ );
287
345
  });
288
346
 
289
347
  test("an active cloud assistant falls back to the sole local entry with a note", async () => {
@@ -294,10 +352,7 @@ describe("tunnel edge targeting", () => {
294
352
 
295
353
  const logs = await runTunnelCapturingLogs();
296
354
 
297
- const workspaceDir = join(
298
- local.resources!.instanceDir,
299
- "workspace",
300
- );
355
+ const workspaceDir = join(local.resources!.instanceDir, "workspace");
301
356
  expect(logs).toContain(
302
357
  "Assistant 'cloud-1' runs on Cadet Cloud and needs no tunnel. " +
303
358
  "Tunneling the local assistant 'assistant-1' instead.",
@@ -419,14 +474,7 @@ describe("tunnel edge targeting", () => {
419
474
  test("a positional cloud assistant name errors without auto-fallback", async () => {
420
475
  const local = makeLocalEntry();
421
476
  writeLockfile([makeCloudEntry(), local], local.assistantId);
422
- process.argv = [
423
- "bun",
424
- "cadet",
425
- "tunnel",
426
- "cloud-1",
427
- "--provider",
428
- "ngrok",
429
- ];
477
+ process.argv = ["bun", "cadet", "tunnel", "cloud-1", "--provider", "ngrok"];
430
478
 
431
479
  const { exited, errors } = await runTunnelExpectingExit1();
432
480
 
@@ -449,10 +497,7 @@ describe("tunnel edge targeting", () => {
449
497
 
450
498
  await runTunnelCapturingLogs();
451
499
 
452
- const workspaceDir = join(
453
- local.resources!.instanceDir,
454
- "workspace",
455
- );
500
+ const workspaceDir = join(local.resources!.instanceDir, "workspace");
456
501
  expect(runNgrokTunnelMock).toHaveBeenCalledWith({
457
502
  port: EDGE_PORT,
458
503
  assistantId: "assistant-1",
@@ -476,10 +521,7 @@ describe("tunnel edge targeting", () => {
476
521
 
477
522
  await runTunnelCapturingLogs();
478
523
 
479
- const workspaceDir = join(
480
- local.resources!.instanceDir,
481
- "workspace",
482
- );
524
+ const workspaceDir = join(local.resources!.instanceDir, "workspace");
483
525
  expect(runNgrokTunnelMock).toHaveBeenCalledWith({
484
526
  port: EDGE_PORT,
485
527
  assistantId: "assistant-1",
@@ -491,14 +533,7 @@ describe("tunnel edge targeting", () => {
491
533
  const workspaceDir = useTempDefaultWorkspaceDir();
492
534
  const local = makeLocalEntry();
493
535
  writeLockfile([local, makeAppleContainerEntry()], local.assistantId);
494
- process.argv = [
495
- "bun",
496
- "cadet",
497
- "tunnel",
498
- "apple-1",
499
- "--provider",
500
- "ngrok",
501
- ];
536
+ process.argv = ["bun", "cadet", "tunnel", "apple-1", "--provider", "ngrok"];
502
537
 
503
538
  await runTunnelCapturingLogs();
504
539
 
@@ -641,49 +676,42 @@ describe("tunnel edge targeting", () => {
641
676
  workspaceDir: join(entry.resources!.instanceDir, "workspace"),
642
677
  });
643
678
  expect(logs).toContain(`Reusing the nginx edge on 127.0.0.1:${EDGE_PORT}`);
644
- expect(logs).toContain("serves webhooks only");
645
679
  });
646
680
 
647
- test("a bare invocation exits listing the providers to choose from", async () => {
648
- writeLockfile(makeLocalEntry());
681
+ test("a bare invocation tunnels the default provider", async () => {
682
+ const entry = makeLocalEntry();
683
+ writeLockfile(entry);
649
684
  process.argv = ["bun", "cadet", "tunnel"];
650
685
 
651
- const { exited, errors } = await runTunnelExpectingExit1();
686
+ const logs = await runTunnelCapturingLogs();
652
687
 
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();
688
+ expect(logs).toContain("Using the default provider: ngrok");
689
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
690
+ port: EDGE_PORT,
691
+ assistantId: "assistant-1",
692
+ workspaceDir: join(entry.resources!.instanceDir, "workspace"),
693
+ });
694
+ expect(ensureTunnelEdgeMock).toHaveBeenCalled();
660
695
  expect(runTailscaleTunnelMock).not.toHaveBeenCalled();
661
- expect(runNgrokTunnelMock).not.toHaveBeenCalled();
662
696
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
663
697
  });
664
698
 
665
- test("a named assistant without --provider exits before resolving it", async () => {
666
- writeLockfile(makeLocalEntry());
699
+ test("a named assistant without --provider tunnels it with the default provider", async () => {
700
+ const entry = makeLocalEntry();
701
+ entry.name = "Ada";
702
+ writeLockfile(entry);
667
703
  process.argv = ["bun", "cadet", "tunnel", "Ada"];
668
704
 
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();
705
+ const logs = await runTunnelCapturingLogs();
685
706
 
686
- expect(errors).toContain("cadet tunnel 'Bob&Alice' --provider ngrok");
707
+ expect(logs).toContain("Using the default provider: ngrok");
708
+ // The assistant that was asked for is the one tunneled.
709
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
710
+ port: EDGE_PORT,
711
+ assistantId: entry.assistantId,
712
+ workspaceDir: join(entry.resources!.instanceDir, "workspace"),
713
+ });
714
+ expect(ensureTunnelEdgeMock).toHaveBeenCalled();
687
715
  });
688
716
 
689
717
  test("--provider tailscale keeps the webhook callback base and says so", async () => {
@@ -756,7 +784,7 @@ describe("tunnel edge targeting", () => {
756
784
  ensureTunnelEdgeMock.mockRejectedValue(
757
785
  new Error(
758
786
  "nginx is not installed, so the tunnel edge cannot start. " +
759
- "Install it (macOS: `brew install nginx`, Linux: `sudo apt install nginx`) " +
787
+ "See https://nginx.org/en/docs/install.html for how to install, " +
760
788
  "or point NGINX_BIN at an existing binary.",
761
789
  ),
762
790
  );
@@ -764,7 +792,9 @@ describe("tunnel edge targeting", () => {
764
792
  const { exited, errors } = await runTunnelExpectingExit1();
765
793
 
766
794
  expect(exited).toBe(true);
767
- expect(errors).toContain("brew install nginx");
795
+ expect(errors).toContain(
796
+ "See https://nginx.org/en/docs/install.html for how to install",
797
+ );
768
798
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
769
799
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
770
800
  expect(runTailscaleTunnelMock).not.toHaveBeenCalled();
@@ -838,7 +868,7 @@ describe("tunnel edge targeting", () => {
838
868
 
839
869
  expect(exited).toBe(true);
840
870
  expect(errors).toContain(
841
- "--domain is only supported with --provider ngrok",
871
+ "--domain is not supported with --provider cloudflare",
842
872
  );
843
873
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
844
874
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
@@ -847,15 +877,9 @@ describe("tunnel edge targeting", () => {
847
877
  test("--clear-domain drops the saved domain and runs domainless", async () => {
848
878
  const entry = makeLocalEntry();
849
879
  writeLockfile(entry);
850
- const workspaceDir = join(
851
- entry.resources!.instanceDir,
852
- "workspace",
853
- );
880
+ const workspaceDir = join(entry.resources!.instanceDir, "workspace");
854
881
  mkdirSync(workspaceDir, { recursive: true });
855
- writeFileSync(
856
- join(workspaceDir, "config.json"),
857
- JSON.stringify({ ingress: { ngrok: { domain: "dead.ngrok.app" } } }),
858
- );
882
+ realNgrok.saveNgrokDomain("dead.ngrok.app");
859
883
  process.argv = [
860
884
  "bun",
861
885
  "cadet",
@@ -867,11 +891,9 @@ describe("tunnel edge targeting", () => {
867
891
 
868
892
  const logs = await runTunnelCapturingLogs();
869
893
 
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();
894
+ expect(logs).toContain("Cleared the saved reserved domain");
895
+ // The saved domain lives in the XDG ngrok config, not the workspace config.
896
+ expect(realNgrok.loadNgrokDomain()).toBeNull();
875
897
  // The run proceeds domainless: no `domain` key reaches runNgrokTunnel.
876
898
  expect(runNgrokTunnelMock).toHaveBeenCalledWith({
877
899
  port: EDGE_PORT,
@@ -913,7 +935,7 @@ describe("tunnel edge targeting", () => {
913
935
 
914
936
  expect(exited).toBe(true);
915
937
  expect(errors).toContain(
916
- "--clear-domain is only supported with --provider ngrok",
938
+ "--clear-domain is not supported with --provider cloudflare",
917
939
  );
918
940
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
919
941
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
@@ -945,7 +967,7 @@ describe("tunnel edge targeting", () => {
945
967
  try {
946
968
  await tunnel();
947
969
  } catch (e) {
948
- err = e as Error;
970
+ err = e instanceof Error ? e : undefined;
949
971
  }
950
972
 
951
973
  expect(err?.message).toContain("is not yet implemented");
@@ -959,20 +981,26 @@ describe("tunnel edge targeting", () => {
959
981
 
960
982
  describe("ngrok --domain spawn args", () => {
961
983
  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
- });
984
+ let lastChild: ChildProcess | null = null;
985
+
986
+ const spawnMock = mock(
987
+ (
988
+ _command: string,
989
+ _args: readonly string[],
990
+ _options?: SpawnOptions,
991
+ ): ChildProcess => {
992
+ const child = new realChildProcess.ChildProcess();
993
+ // kill() stays stubbed: the fake pid must never be signaled for real.
994
+ // stdout/stderr: the real class resolves them through a getter over the
995
+ // internal spawn handle, so null-shadow them via defineProperty — an
996
+ // Object.assign [[Set]] through the getter-only accessor throws.
997
+ Object.defineProperty(child, "stdout", { value: null });
998
+ Object.defineProperty(child, "stderr", { value: null });
999
+ const fake = Object.assign(child, { pid: 4242, kill: () => true });
1000
+ lastChild = fake;
1001
+ return fake;
1002
+ },
1003
+ );
976
1004
  const execFileSyncMock = mock(() => "ngrok version 3.9.0");
977
1005
 
978
1006
  beforeAll(() => {
@@ -991,11 +1019,22 @@ describe("ngrok --domain spawn args", () => {
991
1019
  spawnMock.mockClear();
992
1020
  lastChild = null;
993
1021
  delete process.env.IS_CONTAINERIZED;
1022
+ // The ngrok config file lives under the XDG config dir; a fresh one per
1023
+ // test keeps the saved-domain state hermetic.
1024
+ const configHome = mkdtempSync(join(tmpdir(), "cadet-ngrok-xdg-"));
1025
+ tempDirs.push(configHome);
1026
+ process.env.XDG_CONFIG_HOME = configHome;
994
1027
  mockNgrokApiFetch([{ tunnels: [] }]);
995
1028
  });
996
1029
 
997
1030
  afterEach(() => {
998
1031
  globalThis.fetch = originalFetch;
1032
+ rmSync(realNgrok.ngrokConfigPath(), { force: true });
1033
+ if (originalConfigHome === undefined) {
1034
+ delete process.env.XDG_CONFIG_HOME;
1035
+ } else {
1036
+ process.env.XDG_CONFIG_HOME = originalConfigHome;
1037
+ }
999
1038
  if (originalContainerized === undefined) {
1000
1039
  delete process.env.IS_CONTAINERIZED;
1001
1040
  } else {
@@ -1021,22 +1060,20 @@ describe("ngrok --domain spawn args", () => {
1021
1060
  /** ngrok local API stub replaying one response per call, last one repeated. */
1022
1061
  function mockNgrokApiFetch(responses: { tunnels: StubTunnel[] }[]): void {
1023
1062
  let call = 0;
1024
- globalThis.fetch = (async () => {
1063
+ globalThis.fetch = withPreconnect(async () => {
1025
1064
  const body = responses[Math.min(call, responses.length - 1)];
1026
1065
  call++;
1027
1066
  return new Response(JSON.stringify(body), {
1028
1067
  status: 200,
1029
1068
  headers: { "Content-Type": "application/json" },
1030
1069
  });
1031
- }) as unknown as typeof globalThis.fetch;
1070
+ });
1032
1071
  }
1033
1072
 
1034
1073
  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
- };
1074
+ const config = IngressTestConfigSchema.parse(
1075
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1076
+ );
1040
1077
  return config.ingress ?? {};
1041
1078
  }
1042
1079
 
@@ -1048,7 +1085,7 @@ describe("ngrok --domain spawn args", () => {
1048
1085
  const lockfileDir = mkdtempSync(join(tmpdir(), "cadet-ngrok-lockfile-"));
1049
1086
  tempDirs.push(lockfileDir);
1050
1087
  writeFileSync(
1051
- join(lockfileDir, ".cadet.lock.json"),
1088
+ join(lockfileDir, "lockfile.json"),
1052
1089
  JSON.stringify({
1053
1090
  activeAssistant: assistantId,
1054
1091
  assistants: [
@@ -1104,33 +1141,34 @@ describe("ngrok --domain spawn args", () => {
1104
1141
  }
1105
1142
 
1106
1143
  expect(spawnMock).toHaveBeenCalledTimes(1);
1107
- const [cmd, args] = spawnMock.mock.calls[0] as unknown as [
1108
- string,
1109
- string[],
1110
- ];
1144
+ const [cmd, args] = spawnMock.mock.calls[0];
1111
1145
  expect(cmd).toBe("ngrok");
1146
+ // Plain `ngrok http` so the user's default config — where their
1147
+ // authtoken lives — is what the agent loads.
1112
1148
  expect(args).toEqual([
1113
1149
  "http",
1114
1150
  "127.0.0.1:7831",
1115
1151
  "--log=stdout",
1116
1152
  "--domain=foo.ngrok.app",
1117
1153
  ]);
1154
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1118
1155
 
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");
1156
+ const config = IngressTestConfigSchema.parse(
1157
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1158
+ );
1159
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1124
1160
  });
1125
1161
 
1126
1162
  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;
1163
+ globalThis.fetch = withPreconnect(
1164
+ async () =>
1165
+ new Response(
1166
+ JSON.stringify({
1167
+ tunnels: [{ public_url: publicUrl, config: { addr } }],
1168
+ }),
1169
+ { status: 200, headers: { "Content-Type": "application/json" } },
1170
+ ),
1171
+ );
1134
1172
  }
1135
1173
 
1136
1174
  test("runNgrokTunnel rejects an existing tunnel that mismatches the requested domain", async () => {
@@ -1143,11 +1181,11 @@ describe("ngrok --domain spawn args", () => {
1143
1181
  errors.push(a.join(" "));
1144
1182
  },
1145
1183
  );
1146
- const exitSpy = spyOn(process, "exit").mockImplementation(((
1147
- code?: number,
1148
- ) => {
1149
- throw new Error(`exit:${code}`);
1150
- }) as never);
1184
+ const exitSpy = spyOn(process, "exit").mockImplementation(
1185
+ (code?: number | string | undefined): never => {
1186
+ throw new Error(`exit:${code}`);
1187
+ },
1188
+ );
1151
1189
 
1152
1190
  try {
1153
1191
  await expect(
@@ -1170,9 +1208,9 @@ describe("ngrok --domain spawn args", () => {
1170
1208
  expect(combined).toContain("Stop the existing ngrok agent");
1171
1209
  expect(spawnMock).not.toHaveBeenCalled();
1172
1210
 
1173
- const config = JSON.parse(
1174
- readFileSync(join(ws, "config.json"), "utf-8"),
1175
- ) as { ingress?: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1211
+ const config = IngressTestConfigSchema.parse(
1212
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1213
+ );
1176
1214
  expect(config.ingress?.publicBaseUrl).toBeUndefined();
1177
1215
  expect(config.ingress?.ngrok).toBeUndefined();
1178
1216
  });
@@ -1189,6 +1227,8 @@ describe("ngrok --domain spawn args", () => {
1189
1227
  // The adopt path blocks until SIGINT/SIGTERM; pump SIGINT until the
1190
1228
  // listener is registered and the promise settles. Earlier tests leak
1191
1229
  // SIGINT handlers that call process.exit, so no-op it while pumping.
1230
+ // LOAD-BEARING cast: a no-op exit can't honestly be typed `: never` — it
1231
+ // must return so leaked SIGINT handlers can't crash the pump below.
1192
1232
  const exitSpy = spyOn(process, "exit").mockImplementation(
1193
1233
  (() => undefined) as never,
1194
1234
  );
@@ -1201,18 +1241,16 @@ describe("ngrok --domain spawn args", () => {
1201
1241
  }
1202
1242
 
1203
1243
  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");
1244
+ const config = IngressTestConfigSchema.parse(
1245
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1246
+ );
1247
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1248
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1209
1249
  });
1210
1250
 
1211
1251
  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
- });
1252
+ const ws = makeWorkspace({ telegram: { botUsername: "example_bot" } });
1253
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1216
1254
  mockTunnelListFetch("https://other.ngrok-free.app", "localhost:7830");
1217
1255
 
1218
1256
  const warnings: string[] = [];
@@ -1240,13 +1278,13 @@ describe("ngrok --domain spawn args", () => {
1240
1278
  "cadet tunnel --provider ngrok --domain foo.ngrok.app",
1241
1279
  );
1242
1280
 
1243
- const config = JSON.parse(
1244
- readFileSync(join(ws, "config.json"), "utf-8"),
1245
- ) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1281
+ const config = IngressTestConfigSchema.parse(
1282
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1283
+ );
1246
1284
  // The mismatched tunnel is not blessed in config…
1247
- expect(config.ingress.publicBaseUrl).toBeUndefined();
1285
+ expect(config.ingress?.publicBaseUrl).toBeUndefined();
1248
1286
  // …and the reserved domain stays saved as standing intent.
1249
- expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
1287
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1250
1288
  });
1251
1289
 
1252
1290
  test("an adopted automatic tunnel records the assistant it fronts", async () => {
@@ -1329,9 +1367,9 @@ describe("ngrok --domain spawn args", () => {
1329
1367
  expect(combined).toContain("not 18080");
1330
1368
  expect(combined).toContain("cadet tunnel --provider ngrok");
1331
1369
 
1332
- const config = JSON.parse(
1333
- readFileSync(join(ws, "config.json"), "utf-8"),
1334
- ) as { ingress?: { publicBaseUrl?: string } };
1370
+ const config = IngressTestConfigSchema.parse(
1371
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1372
+ );
1335
1373
  expect(config.ingress?.publicBaseUrl).toBeUndefined();
1336
1374
  });
1337
1375
 
@@ -1345,11 +1383,11 @@ describe("ngrok --domain spawn args", () => {
1345
1383
  errors.push(a.join(" "));
1346
1384
  },
1347
1385
  );
1348
- const exitSpy = spyOn(process, "exit").mockImplementation(((
1349
- code?: number,
1350
- ) => {
1351
- throw new Error(`exit:${code}`);
1352
- }) as never);
1386
+ const exitSpy = spyOn(process, "exit").mockImplementation(
1387
+ (code?: number | string | undefined): never => {
1388
+ throw new Error(`exit:${code}`);
1389
+ },
1390
+ );
1353
1391
 
1354
1392
  try {
1355
1393
  await expect(
@@ -1369,17 +1407,15 @@ describe("ngrok --domain spawn args", () => {
1369
1407
  expect(combined).toContain("Stop the existing ngrok agent");
1370
1408
  expect(spawnMock).not.toHaveBeenCalled();
1371
1409
 
1372
- const config = JSON.parse(
1373
- readFileSync(join(ws, "config.json"), "utf-8"),
1374
- ) as { ingress?: { publicBaseUrl?: string } };
1410
+ const config = IngressTestConfigSchema.parse(
1411
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1412
+ );
1375
1413
  expect(config.ingress?.publicBaseUrl).toBeUndefined();
1376
1414
  });
1377
1415
 
1378
1416
  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
- });
1417
+ const ws = makeWorkspace({ telegram: { botUsername: "example_bot" } });
1418
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1383
1419
  mockNgrokApiFetch([
1384
1420
  { tunnels: [] },
1385
1421
  {
@@ -1397,11 +1433,10 @@ describe("ngrok --domain spawn args", () => {
1397
1433
 
1398
1434
  expect(child).not.toBeNull();
1399
1435
  expect(spawnMock).toHaveBeenCalledTimes(1);
1400
- const [cmd, args] = spawnMock.mock.calls[0] as unknown as [
1401
- string,
1402
- string[],
1403
- ];
1436
+ const [cmd, args] = spawnMock.mock.calls[0];
1404
1437
  expect(cmd).toBe("ngrok");
1438
+ // Plain `ngrok http` so the user's default config — where their
1439
+ // authtoken lives — is what the agent loads.
1405
1440
  expect(args).toEqual([
1406
1441
  "http",
1407
1442
  "127.0.0.1:7830",
@@ -1409,16 +1444,15 @@ describe("ngrok --domain spawn args", () => {
1409
1444
  "--domain=foo.ngrok.app",
1410
1445
  ]);
1411
1446
 
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");
1447
+ const config = IngressTestConfigSchema.parse(
1448
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1449
+ );
1450
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1416
1451
  });
1417
1452
 
1418
1453
  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
- });
1454
+ const ws = makeWorkspace({});
1455
+ realNgrok.saveNgrokDomain("foo.ngrok.app");
1422
1456
  mockNgrokApiFetch([
1423
1457
  { tunnels: [] },
1424
1458
  {
@@ -1440,7 +1474,7 @@ describe("ngrok --domain spawn args", () => {
1440
1474
  }
1441
1475
 
1442
1476
  expect(spawnMock).toHaveBeenCalledTimes(1);
1443
- const [, args] = spawnMock.mock.calls[0] as unknown as [string, string[]];
1477
+ const [, args] = spawnMock.mock.calls[0];
1444
1478
  expect(args).toEqual([
1445
1479
  "http",
1446
1480
  "127.0.0.1:7831",
@@ -1448,11 +1482,11 @@ describe("ngrok --domain spawn args", () => {
1448
1482
  "--domain=foo.ngrok.app",
1449
1483
  ]);
1450
1484
 
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");
1485
+ const config = IngressTestConfigSchema.parse(
1486
+ JSON.parse(readFileSync(join(ws, "config.json"), "utf-8")),
1487
+ );
1488
+ expect(config.ingress?.publicBaseUrl).toBe("https://foo.ngrok.app");
1489
+ expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
1456
1490
  });
1457
1491
  });
1458
1492
 
@@ -1460,18 +1494,23 @@ describe("tunnel --detach", () => {
1460
1494
  const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
1461
1495
 
1462
1496
  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;
1497
+ const child = new realChildProcess.ChildProcess();
1498
+ // kill() stays stubbed: the fake pid must never be signaled for real.
1499
+ // stdout/stderr: the real class resolves them through a getter over the
1500
+ // internal spawn handle, so null-shadow them via defineProperty — an
1501
+ // Object.assign [[Set]] through the getter-only accessor throws.
1502
+ Object.defineProperty(child, "stdout", { value: null });
1503
+ Object.defineProperty(child, "stderr", { value: null });
1504
+ return Object.assign(child, { pid, kill: () => true });
1472
1505
  }
1473
1506
 
1474
- const spawnMock = mock((..._args: unknown[]) => makeFakeChild(9999));
1507
+ const spawnMock = mock(
1508
+ (
1509
+ _command: string,
1510
+ _args: readonly string[],
1511
+ _options?: SpawnOptions,
1512
+ ): ChildProcess => makeFakeChild(9999),
1513
+ );
1475
1514
 
1476
1515
  // spawnDetachedTunnel() scopes the log filename to its own (parent) pid,
1477
1516
  // which in-process is this test runner's pid.
@@ -1532,17 +1571,13 @@ describe("tunnel --detach", () => {
1532
1571
  const logs = await runTunnelCapturingLogs();
1533
1572
 
1534
1573
  expect(spawnMock).toHaveBeenCalledTimes(1);
1535
- const [, args, opts] = spawnMock.mock.calls[0] as unknown as [
1536
- string,
1537
- string[],
1538
- Record<string, unknown>,
1539
- ];
1574
+ const [, args, opts] = spawnMock.mock.calls[0];
1540
1575
  expect(args).toContain("tunnel");
1541
1576
  expect(args).toContain("--provider");
1542
1577
  expect(args).toContain("ngrok");
1543
1578
  expect(args).not.toContain("--detach");
1544
1579
  expect(args).not.toContain("-d");
1545
- expect(opts.detached).toBe(true);
1580
+ expect(opts?.detached).toBe(true);
1546
1581
 
1547
1582
  expect(logs).toContain("Tunnel established: https://foo.ngrok.app");
1548
1583
  expect(logs).toContain("Running in background (pid 9999)");
@@ -1566,7 +1601,7 @@ describe("tunnel --detach", () => {
1566
1601
 
1567
1602
  await runTunnelCapturingLogs();
1568
1603
 
1569
- const [, args] = spawnMock.mock.calls[0] as unknown as [string, string[]];
1604
+ const [, args] = spawnMock.mock.calls[0];
1570
1605
  expect(args).not.toContain("-d");
1571
1606
  expect(args).not.toContain("--detach");
1572
1607
  });
@@ -1584,7 +1619,7 @@ describe("tunnel --detach", () => {
1584
1619
  const child = makeFakeChild(4242);
1585
1620
  // Listeners attach synchronously right after spawn() returns; defer the
1586
1621
  // exit so they're in place before it fires.
1587
- setTimeout(() => (child as unknown as EventEmitter).emit("exit", 1), 0);
1622
+ setTimeout(() => child.emit("exit", 1), 0);
1588
1623
  return child;
1589
1624
  });
1590
1625