@vellumai/cli 0.11.2 → 0.11.3-staging.2

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 (97) hide show
  1. package/knip.json +1 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  4. package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  6. package/node_modules/@vellumai/environments/src/index.ts +17 -6
  7. package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  8. package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  9. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
  11. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
  12. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
  13. package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
  14. package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
  15. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
  16. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
  17. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  18. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  19. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  20. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  21. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  22. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
  23. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  24. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  25. package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
  26. package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
  27. package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
  28. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
  29. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
  30. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
  31. package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
  32. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
  33. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
  34. package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
  35. package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
  36. package/node_modules/@vellumai/local-mode/package.json +1 -0
  37. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
  38. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
  39. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
  40. package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
  41. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
  42. package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
  43. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
  44. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
  45. package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
  46. package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
  47. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
  48. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
  49. package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
  50. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
  51. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
  52. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
  53. package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
  54. package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
  55. package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
  56. package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
  57. package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
  58. package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
  59. package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
  60. package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  61. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
  62. package/package.json +3 -1
  63. package/src/__tests__/client-token.test.ts +26 -1
  64. package/src/__tests__/guardian-token.test.ts +4 -0
  65. package/src/__tests__/nginx-ingress-command.test.ts +176 -16
  66. package/src/__tests__/nginx-ingress.test.ts +1821 -101
  67. package/src/__tests__/pair.test.ts +104 -2
  68. package/src/__tests__/sleep.test.ts +7 -3
  69. package/src/__tests__/tailscale-tunnel.test.ts +0 -1
  70. package/src/__tests__/tunnel.test.ts +1099 -55
  71. package/src/__tests__/wake.test.ts +166 -99
  72. package/src/commands/client.ts +182 -27
  73. package/src/commands/connect/import.ts +35 -156
  74. package/src/commands/nginx-ingress.ts +87 -110
  75. package/src/commands/pair.ts +84 -21
  76. package/src/commands/tunnel.ts +196 -85
  77. package/src/commands/upgrade.ts +3 -2
  78. package/src/commands/wake.ts +5 -163
  79. package/src/index.ts +1 -1
  80. package/src/lib/__tests__/local-ces.test.ts +10 -1
  81. package/src/lib/cloudflare-tunnel.ts +3 -16
  82. package/src/lib/environments/__tests__/paths.test.ts +20 -1
  83. package/src/lib/environments/paths.ts +29 -41
  84. package/src/lib/environments/resolve.ts +7 -5
  85. package/src/lib/guardian-token.ts +22 -62
  86. package/src/lib/ingress-config.ts +25 -0
  87. package/src/lib/local.ts +77 -20
  88. package/src/lib/nginx-ingress.ts +523 -76
  89. package/src/lib/ngrok.ts +154 -61
  90. package/src/lib/orphan-detection.test.ts +167 -0
  91. package/src/lib/orphan-detection.ts +40 -0
  92. package/src/lib/pair.test.ts +87 -0
  93. package/src/lib/pair.ts +55 -0
  94. package/src/lib/platform-client.ts +8 -5
  95. package/src/lib/tailscale-tunnel.ts +7 -18
  96. package/src/lib/tunnel-edge.ts +114 -0
  97. package/src/lib/xdg-log.ts +2 -2
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  afterAll,
3
3
  afterEach,
4
+ beforeAll,
4
5
  beforeEach,
5
6
  describe,
6
7
  expect,
@@ -8,16 +9,30 @@ import {
8
9
  spyOn,
9
10
  test,
10
11
  } from "bun:test";
11
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import * as childProcess from "node:child_process";
13
+ import type { ChildProcess } from "node:child_process";
14
+ import { EventEmitter } from "node:events";
15
+ import {
16
+ mkdirSync,
17
+ mkdtempSync,
18
+ readFileSync,
19
+ rmSync,
20
+ writeFileSync,
21
+ } from "node:fs";
12
22
  import { tmpdir } from "node:os";
13
23
  import { join } from "node:path";
14
24
 
15
25
  import * as cloudflareTunnel from "../lib/cloudflare-tunnel.js";
16
26
  import * as ngrok from "../lib/ngrok.js";
27
+ import * as nginxIngress from "../lib/nginx-ingress.js";
28
+ import * as tailscaleTunnel from "../lib/tailscale-tunnel.js";
17
29
  import type { AssistantEntry } from "../lib/assistant-config.js";
18
30
 
19
31
  const realCloudflareTunnel = { ...cloudflareTunnel };
20
32
  const realNgrok = { ...ngrok };
33
+ const realNginxIngress = { ...nginxIngress };
34
+ const realTailscaleTunnel = { ...tailscaleTunnel };
35
+ const realChildProcess = { ...childProcess };
21
36
 
22
37
  const runCloudflareTunnelMock = mock<
23
38
  typeof cloudflareTunnel.runCloudflareTunnel
@@ -33,18 +48,37 @@ mock.module("../lib/ngrok", () => ({
33
48
  runNgrokTunnel: runNgrokTunnelMock,
34
49
  }));
35
50
 
51
+ const runTailscaleTunnelMock = mock<typeof tailscaleTunnel.runTailscaleTunnel>(
52
+ async () => {},
53
+ );
54
+ mock.module("../lib/tailscale-tunnel.js", () => ({
55
+ ...realTailscaleTunnel,
56
+ runTailscaleTunnel: runTailscaleTunnelMock,
57
+ }));
58
+
59
+ const EDGE_PORT = 18080;
60
+
61
+ const ensureTunnelEdgeMock = mock<typeof nginxIngress.ensureTunnelEdge>(
62
+ async () => ({ port: EDGE_PORT, started: true, includesWebApp: true }),
63
+ );
64
+ mock.module("../lib/nginx-ingress.js", () => ({
65
+ ...realNginxIngress,
66
+ ensureTunnelEdge: ensureTunnelEdgeMock,
67
+ }));
68
+
36
69
  const { tunnel } = await import("../commands/tunnel.js");
37
70
 
38
71
  const originalArgv = [...process.argv];
39
72
  const originalFetch = globalThis.fetch;
40
73
  const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
74
+ const originalWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
41
75
  const tempDirs: string[] = [];
42
76
 
43
- function makeLocalEntry(): AssistantEntry {
77
+ function makeLocalEntry(assistantId = "assistant-1"): AssistantEntry {
44
78
  const instanceDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-test-"));
45
79
  tempDirs.push(instanceDir);
46
80
  return {
47
- assistantId: "assistant-1",
81
+ assistantId,
48
82
  runtimeUrl: "http://127.0.0.1:7830",
49
83
  cloud: "local",
50
84
  resources: {
@@ -57,7 +91,47 @@ function makeLocalEntry(): AssistantEntry {
57
91
  };
58
92
  }
59
93
 
60
- function writeLockfile(entry: AssistantEntry): void {
94
+ function makeCloudEntry(assistantId = "cloud-1"): AssistantEntry {
95
+ return {
96
+ assistantId,
97
+ runtimeUrl: `https://runtime.example.com/${assistantId}`,
98
+ cloud: "vellum",
99
+ };
100
+ }
101
+
102
+ /** A `hatch --remote docker` entry: local container gateway, no `resources`. */
103
+ function makeDockerEntry(assistantId = "docker-1"): AssistantEntry {
104
+ return {
105
+ assistantId,
106
+ runtimeUrl: "http://localhost:7930",
107
+ cloud: "docker",
108
+ };
109
+ }
110
+
111
+ /** A macOS-app-managed entry: local container gateway, no `resources`. */
112
+ function makeAppleContainerEntry(assistantId = "apple-1"): AssistantEntry {
113
+ return {
114
+ assistantId,
115
+ runtimeUrl: "http://localhost:8030",
116
+ cloud: "apple-container",
117
+ };
118
+ }
119
+
120
+ /** Point the default workspace dir at a temp dir; returns that dir. */
121
+ function useTempDefaultWorkspaceDir(): string {
122
+ const workspaceDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-ws-"));
123
+ tempDirs.push(workspaceDir);
124
+ process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
125
+ return workspaceDir;
126
+ }
127
+
128
+ function writeLockfile(
129
+ entryOrEntries: AssistantEntry | AssistantEntry[],
130
+ activeAssistant?: string,
131
+ ): void {
132
+ const entries = Array.isArray(entryOrEntries)
133
+ ? entryOrEntries
134
+ : [entryOrEntries];
61
135
  const lockfileDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-lockfile-"));
62
136
  tempDirs.push(lockfileDir);
63
137
  process.env.VELLUM_LOCKFILE_DIR = lockfileDir;
@@ -66,8 +140,8 @@ function writeLockfile(entry: AssistantEntry): void {
66
140
  join(lockfileDir, ".vellum.lock.json"),
67
141
  JSON.stringify(
68
142
  {
69
- activeAssistant: entry.assistantId,
70
- assistants: [entry],
143
+ activeAssistant: activeAssistant ?? entries[0].assistantId,
144
+ assistants: entries,
71
145
  },
72
146
  null,
73
147
  2,
@@ -75,20 +149,50 @@ function writeLockfile(entry: AssistantEntry): void {
75
149
  );
76
150
  }
77
151
 
78
- function mockEnabledFlagFetch() {
79
- const fetchMock = mock(async (_input: string, _init?: RequestInit) => {
80
- return new Response(
81
- JSON.stringify({
82
- flags: [{ key: "web-remote-ingress", enabled: true }],
83
- }),
84
- { status: 200, headers: { "Content-Type": "application/json" } },
85
- );
152
+ /** Run tunnel() expecting exit(1); returns the joined console.error output. */
153
+ async function runTunnelExpectingExit1(): Promise<{
154
+ exited: boolean;
155
+ errors: string;
156
+ }> {
157
+ const errors: string[] = [];
158
+ const errSpy = spyOn(console, "error").mockImplementation(
159
+ (...a: unknown[]) => {
160
+ errors.push(a.join(" "));
161
+ },
162
+ );
163
+ const exitSpy = spyOn(process, "exit").mockImplementation(((
164
+ code?: number,
165
+ ) => {
166
+ throw new Error(`exit:${code}`);
167
+ }) as never);
168
+
169
+ let exited = false;
170
+ try {
171
+ await tunnel();
172
+ } catch (e) {
173
+ exited = (e as Error).message === "exit:1";
174
+ } finally {
175
+ errSpy.mockRestore();
176
+ exitSpy.mockRestore();
177
+ }
178
+ return { exited, errors: errors.join("\n") };
179
+ }
180
+
181
+ /** Run tunnel() capturing console.log output; returns the joined lines. */
182
+ async function runTunnelCapturingLogs(): Promise<string> {
183
+ const logs: string[] = [];
184
+ const logSpy = spyOn(console, "log").mockImplementation((...a: unknown[]) => {
185
+ logs.push(a.join(" "));
86
186
  });
87
- globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
88
- return fetchMock;
187
+ try {
188
+ await tunnel();
189
+ } finally {
190
+ logSpy.mockRestore();
191
+ }
192
+ return logs.join("\n");
89
193
  }
90
194
 
91
- describe("tunnel nginx ingress feature flag", () => {
195
+ describe("tunnel edge targeting", () => {
92
196
  beforeEach(() => {
93
197
  process.argv = ["bun", "vellum", "tunnel"];
94
198
  writeLockfile(makeLocalEntry());
@@ -99,6 +203,14 @@ describe("tunnel nginx ingress feature flag", () => {
99
203
  runCloudflareTunnelMock.mockResolvedValue(undefined);
100
204
  runNgrokTunnelMock.mockReset();
101
205
  runNgrokTunnelMock.mockResolvedValue(undefined);
206
+ runTailscaleTunnelMock.mockReset();
207
+ runTailscaleTunnelMock.mockResolvedValue(undefined);
208
+ ensureTunnelEdgeMock.mockReset();
209
+ ensureTunnelEdgeMock.mockResolvedValue({
210
+ port: EDGE_PORT,
211
+ started: true,
212
+ includesWebApp: true,
213
+ });
102
214
  });
103
215
 
104
216
  afterEach(() => {
@@ -109,6 +221,11 @@ describe("tunnel nginx ingress feature flag", () => {
109
221
  } else {
110
222
  process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
111
223
  }
224
+ if (originalWorkspaceDir === undefined) {
225
+ delete process.env.VELLUM_WORKSPACE_DIR;
226
+ } else {
227
+ process.env.VELLUM_WORKSPACE_DIR = originalWorkspaceDir;
228
+ }
112
229
  for (const dir of tempDirs.splice(0)) {
113
230
  rmSync(dir, { recursive: true, force: true });
114
231
  }
@@ -117,86 +234,596 @@ describe("tunnel nginx ingress feature flag", () => {
117
234
  afterAll(() => {
118
235
  mock.module("../lib/cloudflare-tunnel.js", () => realCloudflareTunnel);
119
236
  mock.module("../lib/ngrok", () => realNgrok);
237
+ mock.module("../lib/tailscale-tunnel.js", () => realTailscaleTunnel);
238
+ mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
120
239
  });
121
240
 
122
- test("does not start ngrok when the flag lookup fails", async () => {
241
+ test("does not start ngrok when the edge flag lookup fails", async () => {
123
242
  process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
243
+ ensureTunnelEdgeMock.mockImplementation(realNginxIngress.ensureTunnelEdge);
244
+
245
+ const { exited, errors } = await runTunnelExpectingExit1();
124
246
 
125
- await expect(tunnel()).rejects.toThrow(
247
+ expect(exited).toBe(true);
248
+ expect(errors).toContain(
126
249
  "Could not verify the `web-remote-ingress` feature flag",
127
250
  );
128
-
129
251
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
130
252
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
131
253
  });
132
254
 
133
- test("checks the nginx flag through the local gateway for ngrok", async () => {
255
+ test("targets the edge port returned by ensureTunnelEdge for ngrok", async () => {
134
256
  const entry = makeLocalEntry();
135
257
  entry.runtimeUrl = "https://stale-tunnel.ngrok-free.dev";
136
258
  writeLockfile(entry);
137
259
  process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
138
- const fetchMock = mockEnabledFlagFetch();
139
260
 
140
- await tunnel();
261
+ const logs = await runTunnelCapturingLogs();
262
+
263
+ const workspaceDir = join(
264
+ entry.resources!.instanceDir,
265
+ ".vellum",
266
+ "workspace",
267
+ );
268
+ expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
269
+ assistantId: "assistant-1",
270
+ workspaceDir,
271
+ gatewayPort: 7830,
272
+ });
273
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
274
+ port: EDGE_PORT,
275
+ assistantId: "assistant-1",
276
+ workspaceDir,
277
+ });
278
+ expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
279
+ expect(logs).toContain(`Started the nginx edge on 127.0.0.1:${EDGE_PORT}`);
280
+ expect(logs).toContain("serves remote web + webhooks");
281
+ });
282
+
283
+ test("an active cloud assistant falls back to the sole local entry with a note", async () => {
284
+ const cloud = makeCloudEntry();
285
+ const local = makeLocalEntry();
286
+ writeLockfile([cloud, local], cloud.assistantId);
287
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
288
+
289
+ const logs = await runTunnelCapturingLogs();
290
+
291
+ const workspaceDir = join(
292
+ local.resources!.instanceDir,
293
+ ".vellum",
294
+ "workspace",
295
+ );
296
+ expect(logs).toContain(
297
+ "Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel. " +
298
+ "Tunneling the local assistant 'assistant-1' instead.",
299
+ );
300
+ expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
301
+ assistantId: "assistant-1",
302
+ workspaceDir,
303
+ gatewayPort: 7830,
304
+ });
305
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
306
+ port: EDGE_PORT,
307
+ assistantId: "assistant-1",
308
+ workspaceDir,
309
+ });
310
+ });
311
+
312
+ test("a positional docker assistant name tunnels via its runtimeUrl gateway port", async () => {
313
+ const workspaceDir = useTempDefaultWorkspaceDir();
314
+ const local = makeLocalEntry();
315
+ writeLockfile([local, makeDockerEntry()], local.assistantId);
316
+ process.argv = [
317
+ "bun",
318
+ "vellum",
319
+ "tunnel",
320
+ "docker-1",
321
+ "--provider",
322
+ "ngrok",
323
+ ];
324
+
325
+ await runTunnelCapturingLogs();
326
+
327
+ expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
328
+ assistantId: "docker-1",
329
+ workspaceDir,
330
+ gatewayPort: 7930,
331
+ });
332
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
333
+ port: EDGE_PORT,
334
+ assistantId: "docker-1",
335
+ workspaceDir,
336
+ });
337
+ });
338
+
339
+ test("an active docker assistant tunnels on a bare invocation", async () => {
340
+ const workspaceDir = useTempDefaultWorkspaceDir();
341
+ const docker = makeDockerEntry();
342
+ writeLockfile(docker, docker.assistantId);
343
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
344
+
345
+ await runTunnelCapturingLogs();
346
+
347
+ expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
348
+ assistantId: "docker-1",
349
+ workspaceDir,
350
+ gatewayPort: 7930,
351
+ });
352
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
353
+ port: EDGE_PORT,
354
+ assistantId: "docker-1",
355
+ workspaceDir,
356
+ });
357
+ });
358
+
359
+ test("an active cloud assistant falls back to a sole docker entry with a note", async () => {
360
+ const workspaceDir = useTempDefaultWorkspaceDir();
361
+ const cloud = makeCloudEntry();
362
+ writeLockfile([cloud, makeDockerEntry()], cloud.assistantId);
363
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
364
+
365
+ const logs = await runTunnelCapturingLogs();
366
+
367
+ expect(logs).toContain(
368
+ "Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel. " +
369
+ "Tunneling the local assistant 'docker-1' instead.",
370
+ );
371
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
372
+ port: EDGE_PORT,
373
+ assistantId: "docker-1",
374
+ workspaceDir,
375
+ });
376
+ });
377
+
378
+ test("an active cloud assistant with no local entries exits with an error", async () => {
379
+ writeLockfile(makeCloudEntry());
380
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
381
+
382
+ const { exited, errors } = await runTunnelExpectingExit1();
383
+
384
+ expect(exited).toBe(true);
385
+ expect(errors).toContain(
386
+ "Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel.",
387
+ );
388
+ expect(errors).toContain("No local assistant found to tunnel");
389
+ expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
390
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
391
+ });
392
+
393
+ test("an active cloud assistant with multiple local entries exits listing them", async () => {
394
+ const cloud = makeCloudEntry();
395
+ writeLockfile(
396
+ [cloud, makeLocalEntry("assistant-a"), makeLocalEntry("assistant-b")],
397
+ cloud.assistantId,
398
+ );
399
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
400
+
401
+ const { exited, errors } = await runTunnelExpectingExit1();
402
+
403
+ expect(exited).toBe(true);
404
+ expect(errors).toContain(
405
+ "Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel.",
406
+ );
407
+ expect(errors).toContain(
408
+ "Pass a local assistant as the name argument: assistant-a, assistant-b.",
409
+ );
410
+ expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
411
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
412
+ });
413
+
414
+ test("a positional cloud assistant name errors without auto-fallback", async () => {
415
+ const local = makeLocalEntry();
416
+ writeLockfile([makeCloudEntry(), local], local.assistantId);
417
+ process.argv = [
418
+ "bun",
419
+ "vellum",
420
+ "tunnel",
421
+ "cloud-1",
422
+ "--provider",
423
+ "ngrok",
424
+ ];
141
425
 
142
- const [url, init] = fetchMock.mock.calls[0];
143
- expect(url).toBe(
144
- "http://127.0.0.1:7830/v1/assistants/assistant-1/feature-flags",
426
+ const { exited, errors } = await runTunnelExpectingExit1();
427
+
428
+ expect(exited).toBe(true);
429
+ expect(errors).toContain(
430
+ "Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel.",
431
+ );
432
+ expect(errors).toContain(
433
+ "Pass a local assistant as the name argument: assistant-1.",
434
+ );
435
+ expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
436
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
437
+ });
438
+
439
+ test("a positional display name resolves the local assistant", async () => {
440
+ const local = makeLocalEntry();
441
+ local.name = "Ada";
442
+ writeLockfile([makeCloudEntry(), local], "cloud-1");
443
+ process.argv = ["bun", "vellum", "tunnel", "Ada", "--provider", "ngrok"];
444
+
445
+ await runTunnelCapturingLogs();
446
+
447
+ const workspaceDir = join(
448
+ local.resources!.instanceDir,
449
+ ".vellum",
450
+ "workspace",
451
+ );
452
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
453
+ port: EDGE_PORT,
454
+ assistantId: "assistant-1",
455
+ workspaceDir,
456
+ });
457
+ });
458
+
459
+ test("an unquoted multi-word display name resolves the local assistant", async () => {
460
+ const local = makeLocalEntry();
461
+ local.name = "Ada Lovelace";
462
+ writeLockfile([makeCloudEntry(), local], "cloud-1");
463
+ process.argv = [
464
+ "bun",
465
+ "vellum",
466
+ "tunnel",
467
+ "Ada",
468
+ "Lovelace",
469
+ "--provider",
470
+ "ngrok",
471
+ ];
472
+
473
+ await runTunnelCapturingLogs();
474
+
475
+ const workspaceDir = join(
476
+ local.resources!.instanceDir,
477
+ ".vellum",
478
+ "workspace",
479
+ );
480
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
481
+ port: EDGE_PORT,
482
+ assistantId: "assistant-1",
483
+ workspaceDir,
484
+ });
485
+ });
486
+
487
+ test("a positional apple-container assistant name tunnels via its runtimeUrl gateway port", async () => {
488
+ const workspaceDir = useTempDefaultWorkspaceDir();
489
+ const local = makeLocalEntry();
490
+ writeLockfile([local, makeAppleContainerEntry()], local.assistantId);
491
+ process.argv = [
492
+ "bun",
493
+ "vellum",
494
+ "tunnel",
495
+ "apple-1",
496
+ "--provider",
497
+ "ngrok",
498
+ ];
499
+
500
+ await runTunnelCapturingLogs();
501
+
502
+ expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
503
+ assistantId: "apple-1",
504
+ workspaceDir,
505
+ gatewayPort: 8030,
506
+ });
507
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
508
+ port: EDGE_PORT,
509
+ assistantId: "apple-1",
510
+ workspaceDir,
511
+ });
512
+ });
513
+
514
+ test("an active cloud assistant falls back to a sole apple-container entry with a note", async () => {
515
+ const workspaceDir = useTempDefaultWorkspaceDir();
516
+ const cloud = makeCloudEntry();
517
+ writeLockfile([cloud, makeAppleContainerEntry()], cloud.assistantId);
518
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
519
+
520
+ const logs = await runTunnelCapturingLogs();
521
+
522
+ expect(logs).toContain(
523
+ "Assistant 'cloud-1' runs on Vellum Cloud and needs no tunnel. " +
524
+ "Tunneling the local assistant 'apple-1' instead.",
145
525
  );
146
- expect(init?.method).toBe("GET");
526
+ expect(ensureTunnelEdgeMock).toHaveBeenCalledWith({
527
+ assistantId: "apple-1",
528
+ workspaceDir,
529
+ gatewayPort: 8030,
530
+ });
147
531
  expect(runNgrokTunnelMock).toHaveBeenCalledWith({
148
- port: 7830,
532
+ port: EDGE_PORT,
533
+ assistantId: "apple-1",
534
+ workspaceDir,
535
+ });
536
+ });
537
+
538
+ test("an exact assistant ID wins over a colliding display name", async () => {
539
+ const decoy = makeLocalEntry("assistant-a");
540
+ decoy.name = "assistant-b";
541
+ const target = makeLocalEntry("assistant-b");
542
+ writeLockfile([decoy, target], "assistant-a");
543
+ process.argv = [
544
+ "bun",
545
+ "vellum",
546
+ "tunnel",
547
+ "assistant-b",
548
+ "--provider",
549
+ "ngrok",
550
+ ];
551
+
552
+ await runTunnelCapturingLogs();
553
+
554
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith(
555
+ expect.objectContaining({ assistantId: "assistant-b" }),
556
+ );
557
+ });
558
+
559
+ test("a positional display name of a cloud assistant errors without auto-fallback", async () => {
560
+ const cloud = makeCloudEntry();
561
+ cloud.name = "Cloudy";
562
+ const local = makeLocalEntry();
563
+ writeLockfile([cloud, local], local.assistantId);
564
+ process.argv = ["bun", "vellum", "tunnel", "Cloudy", "--provider", "ngrok"];
565
+
566
+ const { exited, errors } = await runTunnelExpectingExit1();
567
+
568
+ expect(exited).toBe(true);
569
+ expect(errors).toContain(
570
+ "Assistant 'Cloudy (cloud-1)' runs on Vellum Cloud and needs no tunnel.",
571
+ );
572
+ expect(errors).toContain(
573
+ "Pass a local assistant as the name argument: assistant-1.",
574
+ );
575
+ expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
576
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
577
+ });
578
+
579
+ test("an ambiguous positional display name errors listing the candidates", async () => {
580
+ const first = makeLocalEntry("assistant-a");
581
+ first.name = "Ada";
582
+ const second = makeLocalEntry("assistant-b");
583
+ second.name = "Ada";
584
+ writeLockfile([first, second], "assistant-a");
585
+ process.argv = ["bun", "vellum", "tunnel", "Ada", "--provider", "ngrok"];
586
+
587
+ const { exited, errors } = await runTunnelExpectingExit1();
588
+
589
+ expect(exited).toBe(true);
590
+ expect(errors).toContain(
591
+ "Multiple assistants match 'Ada': Ada (assistant-a), Ada (assistant-b).",
592
+ );
593
+ expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
594
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
595
+ });
596
+
597
+ test("an unknown positional name errors", async () => {
598
+ process.argv = ["bun", "vellum", "tunnel", "nope", "--provider", "ngrok"];
599
+
600
+ const { exited, errors } = await runTunnelExpectingExit1();
601
+
602
+ expect(exited).toBe(true);
603
+ expect(errors).toContain("No assistant found with name or ID 'nope'.");
604
+ expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
605
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
606
+ });
607
+
608
+ test("targets the edge port for cloudflare", async () => {
609
+ const entry = makeLocalEntry();
610
+ writeLockfile(entry);
611
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "cloudflare"];
612
+
613
+ await runTunnelCapturingLogs();
614
+
615
+ expect(runCloudflareTunnelMock).toHaveBeenCalledWith({
616
+ port: EDGE_PORT,
617
+ assistantId: "assistant-1",
618
+ workspaceDir: join(entry.resources!.instanceDir, ".vellum", "workspace"),
619
+ });
620
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
621
+ });
622
+
623
+ test("targets the edge port for tailscale and notes a reused webhooks-only edge", async () => {
624
+ const entry = makeLocalEntry();
625
+ writeLockfile(entry);
626
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "tailscale"];
627
+ ensureTunnelEdgeMock.mockResolvedValue({
628
+ port: EDGE_PORT,
629
+ started: false,
630
+ includesWebApp: false,
631
+ });
632
+
633
+ const logs = await runTunnelCapturingLogs();
634
+
635
+ expect(runTailscaleTunnelMock).toHaveBeenCalledWith({
636
+ port: EDGE_PORT,
149
637
  assistantId: "assistant-1",
150
638
  workspaceDir: join(entry.resources!.instanceDir, ".vellum", "workspace"),
151
- preferNginxIngress: true,
152
639
  });
640
+ expect(logs).toContain(`Reusing the nginx edge on 127.0.0.1:${EDGE_PORT}`);
641
+ expect(logs).toContain("serves webhooks only");
642
+ });
643
+
644
+ test("missing nginx aborts before any provider spawn", async () => {
645
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
646
+ ensureTunnelEdgeMock.mockRejectedValue(
647
+ new Error(
648
+ "nginx is not installed, so the tunnel edge cannot start. " +
649
+ "Install it (macOS: `brew install nginx`, Linux: `sudo apt install nginx`) " +
650
+ "or point NGINX_BIN at an existing binary.",
651
+ ),
652
+ );
653
+
654
+ const { exited, errors } = await runTunnelExpectingExit1();
655
+
656
+ expect(exited).toBe(true);
657
+ expect(errors).toContain("brew install nginx");
658
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
153
659
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
660
+ expect(runTailscaleTunnelMock).not.toHaveBeenCalled();
154
661
  });
155
662
 
156
- test("does not start cloudflared when the flag lookup fails", async () => {
663
+ test("does not start cloudflared when the edge flag lookup fails", async () => {
157
664
  process.argv = ["bun", "vellum", "tunnel", "--provider", "cloudflare"];
665
+ ensureTunnelEdgeMock.mockImplementation(realNginxIngress.ensureTunnelEdge);
666
+
667
+ const { exited, errors } = await runTunnelExpectingExit1();
158
668
 
159
- await expect(tunnel()).rejects.toThrow(
669
+ expect(exited).toBe(true);
670
+ expect(errors).toContain(
160
671
  "Could not verify the `web-remote-ingress` feature flag",
161
672
  );
162
-
163
673
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
164
674
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
165
675
  });
166
676
 
167
677
  test("rejects an unknown --provider with a stale-CLI hint", async () => {
168
678
  process.argv = ["bun", "vellum", "tunnel", "--provider", "bogus"];
169
- const errors: string[] = [];
170
- const errSpy = spyOn(console, "error").mockImplementation(
171
- (...a: unknown[]) => {
172
- errors.push(a.join(" "));
173
- },
679
+
680
+ const { exited, errors } = await runTunnelExpectingExit1();
681
+
682
+ expect(exited).toBe(true);
683
+ expect(errors).toContain("unknown tunnel provider 'bogus'");
684
+ expect(errors).toContain("your CLI may be out of date");
685
+ expect(errors).toContain("bun install -g vellum@latest");
686
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
687
+ expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
688
+ });
689
+
690
+ test("threads --domain through to runNgrokTunnel", async () => {
691
+ const entry = makeLocalEntry();
692
+ writeLockfile(entry);
693
+ process.argv = [
694
+ "bun",
695
+ "vellum",
696
+ "tunnel",
697
+ "--provider",
698
+ "ngrok",
699
+ "--domain",
700
+ "foo.ngrok.app",
701
+ ];
702
+
703
+ await runTunnelCapturingLogs();
704
+
705
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
706
+ port: EDGE_PORT,
707
+ assistantId: "assistant-1",
708
+ workspaceDir: join(entry.resources!.instanceDir, ".vellum", "workspace"),
709
+ domain: "foo.ngrok.app",
710
+ });
711
+ });
712
+
713
+ test("rejects --domain with a non-ngrok provider", async () => {
714
+ process.argv = [
715
+ "bun",
716
+ "vellum",
717
+ "tunnel",
718
+ "--provider",
719
+ "cloudflare",
720
+ "--domain",
721
+ "foo.ngrok.app",
722
+ ];
723
+
724
+ const { exited, errors } = await runTunnelExpectingExit1();
725
+
726
+ expect(exited).toBe(true);
727
+ expect(errors).toContain(
728
+ "--domain is only supported with --provider ngrok",
174
729
  );
175
- const exitSpy = spyOn(process, "exit").mockImplementation(((
176
- code?: number,
177
- ) => {
178
- throw new Error(`exit:${code}`);
179
- }) as never);
730
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
731
+ expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
732
+ });
180
733
 
181
- let exited = false;
182
- try {
183
- await tunnel();
184
- } catch (e) {
185
- exited = (e as Error).message === "exit:1";
186
- } finally {
187
- errSpy.mockRestore();
188
- exitSpy.mockRestore();
189
- }
734
+ test("--clear-domain drops the saved domain and runs domainless", async () => {
735
+ const entry = makeLocalEntry();
736
+ writeLockfile(entry);
737
+ const workspaceDir = join(
738
+ entry.resources!.instanceDir,
739
+ ".vellum",
740
+ "workspace",
741
+ );
742
+ mkdirSync(workspaceDir, { recursive: true });
743
+ writeFileSync(
744
+ join(workspaceDir, "config.json"),
745
+ JSON.stringify({ ingress: { ngrok: { domain: "dead.ngrok.app" } } }),
746
+ );
747
+ process.argv = [
748
+ "bun",
749
+ "vellum",
750
+ "tunnel",
751
+ "--provider",
752
+ "ngrok",
753
+ "--clear-domain",
754
+ ];
755
+
756
+ const logs = await runTunnelCapturingLogs();
757
+
758
+ expect(logs).toContain("Cleared the saved ngrok domain");
759
+ const config = JSON.parse(
760
+ readFileSync(join(workspaceDir, "config.json"), "utf-8"),
761
+ ) as { ingress?: { ngrok?: { domain?: string } } };
762
+ expect(config.ingress?.ngrok).toBeUndefined();
763
+ // The run proceeds domainless: no `domain` key reaches runNgrokTunnel.
764
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
765
+ port: EDGE_PORT,
766
+ assistantId: "assistant-1",
767
+ workspaceDir,
768
+ });
769
+ });
770
+
771
+ test("rejects --clear-domain combined with --domain", async () => {
772
+ process.argv = [
773
+ "bun",
774
+ "vellum",
775
+ "tunnel",
776
+ "--provider",
777
+ "ngrok",
778
+ "--domain",
779
+ "foo.ngrok.app",
780
+ "--clear-domain",
781
+ ];
782
+
783
+ const { exited, errors } = await runTunnelExpectingExit1();
784
+
785
+ expect(exited).toBe(true);
786
+ expect(errors).toContain("--clear-domain cannot be combined with --domain");
787
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
788
+ });
789
+
790
+ test("rejects --clear-domain with a non-ngrok provider", async () => {
791
+ process.argv = [
792
+ "bun",
793
+ "vellum",
794
+ "tunnel",
795
+ "--provider",
796
+ "cloudflare",
797
+ "--clear-domain",
798
+ ];
799
+
800
+ const { exited, errors } = await runTunnelExpectingExit1();
190
801
 
191
802
  expect(exited).toBe(true);
192
- const joined = errors.join("\n");
193
- expect(joined).toContain("unknown tunnel provider 'bogus'");
194
- expect(joined).toContain("your CLI may be out of date");
195
- expect(joined).toContain("bun install -g vellum@latest");
803
+ expect(errors).toContain(
804
+ "--clear-domain is only supported with --provider ngrok",
805
+ );
196
806
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
197
807
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
198
808
  });
199
809
 
810
+ test("errors when --domain is missing its value", async () => {
811
+ process.argv = [
812
+ "bun",
813
+ "vellum",
814
+ "tunnel",
815
+ "--provider",
816
+ "ngrok",
817
+ "--domain",
818
+ ];
819
+
820
+ const { exited, errors } = await runTunnelExpectingExit1();
821
+
822
+ expect(exited).toBe(true);
823
+ expect(errors).toContain("--domain requires a value");
824
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
825
+ });
826
+
200
827
  test("a not-yet-implemented provider error carries the stale-CLI hint", async () => {
201
828
  // The default `vellum` provider has no runtime yet, so it exercises the
202
829
  // not-yet-implemented path without any network call.
@@ -212,7 +839,424 @@ describe("tunnel nginx ingress feature flag", () => {
212
839
  expect(err?.message).toContain("is not yet implemented");
213
840
  expect(err?.message).toContain("your CLI may be out of date");
214
841
  expect(err?.message).toContain("bun install -g vellum@latest");
842
+ expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
215
843
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
216
844
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
217
845
  });
218
846
  });
847
+
848
+ describe("ngrok --domain spawn args", () => {
849
+ const originalContainerized = process.env.IS_CONTAINERIZED;
850
+ let lastChild: EventEmitter | null = null;
851
+
852
+ const spawnMock = mock((..._args: unknown[]) => {
853
+ const emitter = new EventEmitter();
854
+ lastChild = Object.assign(emitter, {
855
+ stdout: null,
856
+ stderr: null,
857
+ killed: false,
858
+ kill: () => true,
859
+ unref: () => {},
860
+ pid: 4242,
861
+ });
862
+ return lastChild as unknown as ChildProcess;
863
+ });
864
+ const execFileSyncMock = mock(() => "ngrok version 3.9.0");
865
+
866
+ beforeAll(() => {
867
+ mock.module("node:child_process", () => ({
868
+ ...realChildProcess,
869
+ spawn: spawnMock,
870
+ execFileSync: execFileSyncMock,
871
+ }));
872
+ });
873
+
874
+ afterAll(() => {
875
+ mock.module("node:child_process", () => realChildProcess);
876
+ });
877
+
878
+ beforeEach(() => {
879
+ spawnMock.mockClear();
880
+ lastChild = null;
881
+ delete process.env.IS_CONTAINERIZED;
882
+ mockNgrokApiFetch([{ tunnels: [] }]);
883
+ });
884
+
885
+ afterEach(() => {
886
+ globalThis.fetch = originalFetch;
887
+ if (originalContainerized === undefined) {
888
+ delete process.env.IS_CONTAINERIZED;
889
+ } else {
890
+ process.env.IS_CONTAINERIZED = originalContainerized;
891
+ }
892
+ for (const dir of tempDirs.splice(0)) {
893
+ rmSync(dir, { recursive: true, force: true });
894
+ }
895
+ });
896
+
897
+ function makeWorkspace(config: Record<string, unknown>): string {
898
+ const ws = mkdtempSync(join(tmpdir(), "vellum-ngrok-domain-test-"));
899
+ tempDirs.push(ws);
900
+ writeFileSync(join(ws, "config.json"), JSON.stringify(config, null, 2));
901
+ return ws;
902
+ }
903
+
904
+ interface StubTunnel {
905
+ public_url: string;
906
+ config: { addr: string };
907
+ }
908
+
909
+ /** ngrok local API stub replaying one response per call, last one repeated. */
910
+ function mockNgrokApiFetch(responses: { tunnels: StubTunnel[] }[]): void {
911
+ let call = 0;
912
+ globalThis.fetch = (async () => {
913
+ const body = responses[Math.min(call, responses.length - 1)];
914
+ call++;
915
+ return new Response(JSON.stringify(body), {
916
+ status: 200,
917
+ headers: { "Content-Type": "application/json" },
918
+ });
919
+ }) as unknown as typeof globalThis.fetch;
920
+ }
921
+
922
+ const unrelatedPortTunnel: StubTunnel = {
923
+ public_url: "https://unrelated.ngrok.app",
924
+ config: { addr: "localhost:65500" },
925
+ };
926
+
927
+ test("runNgrokTunnel spawns ngrok with --domain and persists the domain", async () => {
928
+ const ws = makeWorkspace({});
929
+ // The pre-spawn listing is empty (any running tunnel would abort the run);
930
+ // post-spawn, only the tunnel matching the target port and domain may be
931
+ // saved even with an unrelated-port tunnel listed first.
932
+ mockNgrokApiFetch([
933
+ { tunnels: [] },
934
+ {
935
+ tunnels: [
936
+ unrelatedPortTunnel,
937
+ {
938
+ public_url: "https://foo.ngrok.app",
939
+ config: { addr: "localhost:7831" },
940
+ },
941
+ ],
942
+ },
943
+ ]);
944
+
945
+ const run = realNgrok.runNgrokTunnel({
946
+ port: 7831,
947
+ workspaceDir: ws,
948
+ domain: "foo.ngrok.app",
949
+ });
950
+ // runNgrokTunnel blocks until the ngrok process exits; pump exit events
951
+ // until its final exit listener is registered and the promise settles.
952
+ const pump = setInterval(() => lastChild?.emit("exit", 0), 10);
953
+ try {
954
+ await run;
955
+ } finally {
956
+ clearInterval(pump);
957
+ }
958
+
959
+ expect(spawnMock).toHaveBeenCalledTimes(1);
960
+ const [cmd, args] = spawnMock.mock.calls[0] as unknown as [
961
+ string,
962
+ string[],
963
+ ];
964
+ expect(cmd).toBe("ngrok");
965
+ expect(args).toEqual([
966
+ "http",
967
+ "127.0.0.1:7831",
968
+ "--log=stdout",
969
+ "--domain=foo.ngrok.app",
970
+ ]);
971
+
972
+ const config = JSON.parse(
973
+ readFileSync(join(ws, "config.json"), "utf-8"),
974
+ ) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
975
+ expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
976
+ expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
977
+ });
978
+
979
+ function mockTunnelListFetch(publicUrl: string, addr: string): void {
980
+ globalThis.fetch = (async () =>
981
+ new Response(
982
+ JSON.stringify({
983
+ tunnels: [{ public_url: publicUrl, config: { addr } }],
984
+ }),
985
+ { status: 200, headers: { "Content-Type": "application/json" } },
986
+ )) as unknown as typeof globalThis.fetch;
987
+ }
988
+
989
+ test("runNgrokTunnel rejects an existing tunnel that mismatches the requested domain", async () => {
990
+ const ws = makeWorkspace({});
991
+ mockTunnelListFetch("https://other.ngrok-free.app", "localhost:7831");
992
+
993
+ const errors: string[] = [];
994
+ const errSpy = spyOn(console, "error").mockImplementation(
995
+ (...a: unknown[]) => {
996
+ errors.push(a.join(" "));
997
+ },
998
+ );
999
+ const exitSpy = spyOn(process, "exit").mockImplementation(((
1000
+ code?: number,
1001
+ ) => {
1002
+ throw new Error(`exit:${code}`);
1003
+ }) as never);
1004
+
1005
+ try {
1006
+ await expect(
1007
+ realNgrok.runNgrokTunnel({
1008
+ port: 7831,
1009
+ workspaceDir: ws,
1010
+ domain: "foo.ngrok.app",
1011
+ }),
1012
+ ).rejects.toThrow("exit:1");
1013
+ } finally {
1014
+ errSpy.mockRestore();
1015
+ exitSpy.mockRestore();
1016
+ }
1017
+
1018
+ const combined = errors.join("\n");
1019
+ expect(combined).toContain("https://other.ngrok-free.app");
1020
+ expect(combined).toContain(
1021
+ "does not match the requested domain 'foo.ngrok.app'",
1022
+ );
1023
+ expect(combined).toContain("Stop the existing ngrok agent");
1024
+ expect(spawnMock).not.toHaveBeenCalled();
1025
+
1026
+ const config = JSON.parse(
1027
+ readFileSync(join(ws, "config.json"), "utf-8"),
1028
+ ) as { ingress?: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1029
+ expect(config.ingress?.publicBaseUrl).toBeUndefined();
1030
+ expect(config.ingress?.ngrok).toBeUndefined();
1031
+ });
1032
+
1033
+ test("runNgrokTunnel adopts an existing tunnel that matches the requested domain", async () => {
1034
+ const ws = makeWorkspace({});
1035
+ mockTunnelListFetch("https://foo.ngrok.app", "localhost:7831");
1036
+
1037
+ const run = realNgrok.runNgrokTunnel({
1038
+ port: 7831,
1039
+ workspaceDir: ws,
1040
+ domain: "foo.ngrok.app",
1041
+ });
1042
+ // The adopt path blocks until SIGINT/SIGTERM; pump SIGINT until the
1043
+ // listener is registered and the promise settles. Earlier tests leak
1044
+ // SIGINT handlers that call process.exit, so no-op it while pumping.
1045
+ const exitSpy = spyOn(process, "exit").mockImplementation(
1046
+ (() => undefined) as never,
1047
+ );
1048
+ const pump = setInterval(() => process.emit("SIGINT"), 10);
1049
+ try {
1050
+ await run;
1051
+ } finally {
1052
+ clearInterval(pump);
1053
+ exitSpy.mockRestore();
1054
+ }
1055
+
1056
+ expect(spawnMock).not.toHaveBeenCalled();
1057
+ const config = JSON.parse(
1058
+ readFileSync(join(ws, "config.json"), "utf-8"),
1059
+ ) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1060
+ expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
1061
+ expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
1062
+ });
1063
+
1064
+ test("maybeStartNgrokTunnel rejects a mismatched existing tunnel without adopting or spawning", async () => {
1065
+ const ws = makeWorkspace({
1066
+ telegram: { botUsername: "example_bot" },
1067
+ ingress: { ngrok: { domain: "foo.ngrok.app" } },
1068
+ });
1069
+ mockTunnelListFetch("https://other.ngrok-free.app", "localhost:7830");
1070
+
1071
+ const warnings: string[] = [];
1072
+ const warnSpy = spyOn(console, "warn").mockImplementation(
1073
+ (...a: unknown[]) => {
1074
+ warnings.push(a.join(" "));
1075
+ },
1076
+ );
1077
+
1078
+ let child: unknown;
1079
+ try {
1080
+ child = await realNgrok.maybeStartNgrokTunnel(7830, ws);
1081
+ } finally {
1082
+ warnSpy.mockRestore();
1083
+ }
1084
+
1085
+ expect(child).toBeNull();
1086
+ expect(spawnMock).not.toHaveBeenCalled();
1087
+ const combined = warnings.join("\n");
1088
+ expect(combined).toContain("https://other.ngrok-free.app");
1089
+ expect(combined).toContain(
1090
+ "does not match the reserved domain 'foo.ngrok.app'",
1091
+ );
1092
+ expect(combined).toContain(
1093
+ "vellum tunnel --provider ngrok --domain foo.ngrok.app",
1094
+ );
1095
+
1096
+ const config = JSON.parse(
1097
+ readFileSync(join(ws, "config.json"), "utf-8"),
1098
+ ) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1099
+ // The mismatched tunnel is not blessed in config…
1100
+ expect(config.ingress.publicBaseUrl).toBeUndefined();
1101
+ // …and the reserved domain stays saved as standing intent.
1102
+ expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
1103
+ });
1104
+
1105
+ test("maybeStartNgrokTunnel skips when an existing agent tunnels a different port", async () => {
1106
+ const ws = makeWorkspace({
1107
+ telegram: { botUsername: "example_bot" },
1108
+ });
1109
+ // A stale pre-upgrade agent still tunnels the raw gateway port; the edge
1110
+ // port (18080) has no tunnel.
1111
+ mockTunnelListFetch("https://stale.ngrok-free.app", "localhost:7830");
1112
+
1113
+ const warnings: string[] = [];
1114
+ const warnSpy = spyOn(console, "warn").mockImplementation(
1115
+ (...a: unknown[]) => {
1116
+ warnings.push(a.join(" "));
1117
+ },
1118
+ );
1119
+
1120
+ let child: unknown;
1121
+ try {
1122
+ child = await realNgrok.maybeStartNgrokTunnel(18080, ws);
1123
+ } finally {
1124
+ warnSpy.mockRestore();
1125
+ }
1126
+
1127
+ expect(child).toBeNull();
1128
+ expect(spawnMock).not.toHaveBeenCalled();
1129
+ const combined = warnings.join("\n");
1130
+ expect(combined).toContain("different local port");
1131
+ expect(combined).toContain(
1132
+ "https://stale.ngrok-free.app -> localhost:7830",
1133
+ );
1134
+ expect(combined).toContain("not 18080");
1135
+ expect(combined).toContain("vellum tunnel --provider ngrok");
1136
+
1137
+ const config = JSON.parse(
1138
+ readFileSync(join(ws, "config.json"), "utf-8"),
1139
+ ) as { ingress?: { publicBaseUrl?: string } };
1140
+ expect(config.ingress?.publicBaseUrl).toBeUndefined();
1141
+ });
1142
+
1143
+ test("runNgrokTunnel fails loudly when an existing agent tunnels a different port", async () => {
1144
+ const ws = makeWorkspace({});
1145
+ mockTunnelListFetch("https://stale.ngrok-free.app", "localhost:7830");
1146
+
1147
+ const errors: string[] = [];
1148
+ const errSpy = spyOn(console, "error").mockImplementation(
1149
+ (...a: unknown[]) => {
1150
+ errors.push(a.join(" "));
1151
+ },
1152
+ );
1153
+ const exitSpy = spyOn(process, "exit").mockImplementation(((
1154
+ code?: number,
1155
+ ) => {
1156
+ throw new Error(`exit:${code}`);
1157
+ }) as never);
1158
+
1159
+ try {
1160
+ await expect(
1161
+ realNgrok.runNgrokTunnel({ port: 18080, workspaceDir: ws }),
1162
+ ).rejects.toThrow("exit:1");
1163
+ } finally {
1164
+ errSpy.mockRestore();
1165
+ exitSpy.mockRestore();
1166
+ }
1167
+
1168
+ const combined = errors.join("\n");
1169
+ expect(combined).toContain("different local port");
1170
+ expect(combined).toContain(
1171
+ "https://stale.ngrok-free.app -> localhost:7830",
1172
+ );
1173
+ expect(combined).toContain("not 18080");
1174
+ expect(combined).toContain("Stop the existing ngrok agent");
1175
+ expect(spawnMock).not.toHaveBeenCalled();
1176
+
1177
+ const config = JSON.parse(
1178
+ readFileSync(join(ws, "config.json"), "utf-8"),
1179
+ ) as { ingress?: { publicBaseUrl?: string } };
1180
+ expect(config.ingress?.publicBaseUrl).toBeUndefined();
1181
+ });
1182
+
1183
+ test("maybeStartNgrokTunnel passes the saved domain to the spawn args", async () => {
1184
+ const ws = makeWorkspace({
1185
+ telegram: { botUsername: "example_bot" },
1186
+ ingress: { ngrok: { domain: "foo.ngrok.app" } },
1187
+ });
1188
+ mockNgrokApiFetch([
1189
+ { tunnels: [] },
1190
+ {
1191
+ tunnels: [
1192
+ unrelatedPortTunnel,
1193
+ {
1194
+ public_url: "https://foo.ngrok.app",
1195
+ config: { addr: "localhost:7830" },
1196
+ },
1197
+ ],
1198
+ },
1199
+ ]);
1200
+
1201
+ const child = await realNgrok.maybeStartNgrokTunnel(7830, ws);
1202
+
1203
+ expect(child).not.toBeNull();
1204
+ expect(spawnMock).toHaveBeenCalledTimes(1);
1205
+ const [cmd, args] = spawnMock.mock.calls[0] as unknown as [
1206
+ string,
1207
+ string[],
1208
+ ];
1209
+ expect(cmd).toBe("ngrok");
1210
+ expect(args).toEqual([
1211
+ "http",
1212
+ "127.0.0.1:7830",
1213
+ "--log=stdout",
1214
+ "--domain=foo.ngrok.app",
1215
+ ]);
1216
+
1217
+ const config = JSON.parse(
1218
+ readFileSync(join(ws, "config.json"), "utf-8"),
1219
+ ) as { ingress: { publicBaseUrl?: string } };
1220
+ expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
1221
+ });
1222
+
1223
+ test("runNgrokTunnel without --domain reuses the saved domain and does not delete it", async () => {
1224
+ const ws = makeWorkspace({
1225
+ ingress: { ngrok: { domain: "foo.ngrok.app" } },
1226
+ });
1227
+ mockNgrokApiFetch([
1228
+ { tunnels: [] },
1229
+ {
1230
+ tunnels: [
1231
+ {
1232
+ public_url: "https://foo.ngrok.app",
1233
+ config: { addr: "localhost:7831" },
1234
+ },
1235
+ ],
1236
+ },
1237
+ ]);
1238
+
1239
+ const run = realNgrok.runNgrokTunnel({ port: 7831, workspaceDir: ws });
1240
+ const pump = setInterval(() => lastChild?.emit("exit", 0), 10);
1241
+ try {
1242
+ await run;
1243
+ } finally {
1244
+ clearInterval(pump);
1245
+ }
1246
+
1247
+ expect(spawnMock).toHaveBeenCalledTimes(1);
1248
+ const [, args] = spawnMock.mock.calls[0] as unknown as [string, string[]];
1249
+ expect(args).toEqual([
1250
+ "http",
1251
+ "127.0.0.1:7831",
1252
+ "--log=stdout",
1253
+ "--domain=foo.ngrok.app",
1254
+ ]);
1255
+
1256
+ const config = JSON.parse(
1257
+ readFileSync(join(ws, "config.json"), "utf-8"),
1258
+ ) as { ingress: { publicBaseUrl?: string; ngrok?: { domain?: string } } };
1259
+ expect(config.ingress.publicBaseUrl).toBe("https://foo.ngrok.app");
1260
+ expect(config.ingress.ngrok?.domain).toBe("foo.ngrok.app");
1261
+ });
1262
+ });