@vellumai/cli 0.10.12-dev.202607250047.75b9ec6 → 0.10.12-staging.1

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.
@@ -24,7 +24,6 @@ describe("parseLockfile", () => {
24
24
  platformAssistantId: "platform-assistant-1",
25
25
  platformBaseUrl: "https://platform.example.com",
26
26
  platformOrganizationId: "org_1",
27
- ingressUrl: "https://tunnel.example.ts.net",
28
27
  resources: { gatewayPort: 7777, daemonPort: 7778 },
29
28
  },
30
29
  ],
@@ -187,30 +186,6 @@ describe("parseLockfile", () => {
187
186
  ]);
188
187
  });
189
188
 
190
- test("keeps a well-typed ingressUrl and drops it when mistyped", () => {
191
- // The tunnel-recorded public URL rides the entry so remote-web pairing
192
- // surfaces can prefill it; a mistyped value is dropped, entry preserved.
193
- const raw = {
194
- assistants: [
195
- {
196
- assistantId: "asst_1",
197
- cloud: "local",
198
- ingressUrl: "https://tunnel.example.ts.net",
199
- },
200
- { assistantId: "asst_2", cloud: "local", ingressUrl: 7 }, // mistyped
201
- ],
202
- activeAssistant: null,
203
- };
204
- expect(parseLockfile(raw).assistants).toEqual([
205
- {
206
- assistantId: "asst_1",
207
- cloud: "local",
208
- ingressUrl: "https://tunnel.example.ts.net",
209
- },
210
- { assistantId: "asst_2", cloud: "local" },
211
- ]);
212
- });
213
-
214
189
  test("drops a resources object missing its numeric ports", () => {
215
190
  const raw = {
216
191
  assistants: [
@@ -115,13 +115,6 @@ export const LockfileAssistantSchema = z.object({
115
115
  platformBaseUrl: z.string().optional(),
116
116
  /** Platform organization UUID used for a self-hosted local assistant registration. */
117
117
  platformOrganizationId: z.string().optional(),
118
- /**
119
- * Public https URL a `vellum tunnel` provider recorded for this assistant
120
- * (mirrored from the workspace ingress config). A public address, so it is
121
- * renderer-safe: remote-web pairing surfaces (the CLI `--qr` flow, the web
122
- * "Pair a device" card) default to it instead of asking the user to retype it.
123
- */
124
- ingressUrl: z.string().optional(),
125
118
  resources: LocalAssistantResourcesSchema.optional(),
126
119
  });
127
120
 
@@ -81,30 +81,6 @@ describe("getLockfileData", () => {
81
81
  }
82
82
  });
83
83
 
84
- test("surfaces a tunnel-recorded ingressUrl to the renderer", () => {
85
- // The exact read the "Pair a device" card relies on: `vellum tunnel` stamps
86
- // ingressUrl onto the entry, and the host read must carry it through.
87
- writeOnDisk({
88
- activeAssistant: "asst_1",
89
- assistants: [
90
- {
91
- assistantId: "asst_1",
92
- cloud: "local",
93
- runtimeUrl: "http://127.0.0.1:7777",
94
- ingressUrl: "https://tunnel.example.ts.net",
95
- },
96
- ],
97
- });
98
-
99
- const result = getLockfileData([lockfilePath]);
100
- expect(result.ok).toBe(true);
101
- if (result.ok) {
102
- expect(result.data.assistants[0]?.ingressUrl).toBe(
103
- "https://tunnel.example.ts.net",
104
- );
105
- }
106
- });
107
-
108
84
  test("fails with status 500 on malformed JSON", () => {
109
85
  fs.writeFileSync(lockfilePath, "{ not json");
110
86
  const result = getLockfileData([lockfilePath]);
@@ -17,7 +17,6 @@
17
17
  "./rpc": "./src/rpc.ts",
18
18
  "./attachment-naming": "./src/attachment-naming.ts",
19
19
  "./redacted-credential": "./src/redacted-credential.ts",
20
- "./secret-detection": "./src/secret-detection.ts",
21
20
  "./error": "./src/error.ts"
22
21
  },
23
22
  "scripts": {
@@ -109,77 +109,12 @@ export type RemoteWebPairingTokenResponse =
109
109
  // only) so both Node and browser callers share one implementation.
110
110
 
111
111
  /** Why a public base URL can't be advertised in a pairing challenge. */
112
- export type PublicBaseUrlRejection =
113
- | "unparseable"
114
- | "loopback"
115
- | "non-https"
116
- | "service-website";
112
+ export type PublicBaseUrlRejection = "unparseable" | "loopback" | "non-https";
117
113
 
118
114
  export type PublicBaseUrlResult =
119
115
  | { ok: true; url: string }
120
116
  | { ok: false; reason: PublicBaseUrlRejection };
121
117
 
122
- /**
123
- * Hosts that are a tunnel/ingress vendor's own website, not a user's assistant
124
- * endpoint. These are the tunnel vendors our docs mention, and their sites are
125
- * exactly where a lost user grabs a URL from — e.g. a Tailscale admin invite
126
- * link (`login.tailscale.com/admin/invite/…`), which is https and non-loopback
127
- * and so clears every other check. Pairing refuses them with a targeted message
128
- * rather than minting a challenge the scanning device can never reach.
129
- */
130
- export const TUNNEL_PROVIDER_WEBSITE_HOSTS = [
131
- "login.tailscale.com",
132
- "tailscale.com",
133
- "www.tailscale.com",
134
- "ngrok.com",
135
- "dashboard.ngrok.com",
136
- "dash.cloudflare.com",
137
- "cloudflare.com",
138
- "www.cloudflare.com",
139
- ] as const;
140
-
141
- /**
142
- * A URL whose exact host is a tunnel/ingress vendor's own website (see
143
- * {@link TUNNEL_PROVIDER_WEBSITE_HOSTS}). Exact-host only: a user's real
144
- * Tailscale endpoint (`*.ts.net`) or Cloudflare-fronted domain is never a
145
- * listed host, so a legitimate address is never mistaken for a vendor site.
146
- */
147
- export function isTunnelProviderWebsiteUrl(url: string): boolean {
148
- let hostname: string;
149
- try {
150
- // WHATWG URL lowercases the hostname during parsing.
151
- hostname = new URL(url).hostname;
152
- } catch {
153
- return false;
154
- }
155
- return (TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(
156
- hostname,
157
- );
158
- }
159
-
160
- /**
161
- * The display name of the tunnel/ingress vendor a service-website URL points
162
- * at (`"Tailscale"` / `"ngrok"` / `"Cloudflare"`), or null when the host is not
163
- * a known vendor site. Drives the "This is <Name>'s website" pairing guidance.
164
- */
165
- export function tunnelProviderWebsiteName(url: string): string | null {
166
- let hostname: string;
167
- try {
168
- hostname = new URL(url).hostname;
169
- } catch {
170
- return null;
171
- }
172
- if (
173
- !(TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(hostname)
174
- ) {
175
- return null;
176
- }
177
- if (hostname.includes("tailscale")) return "Tailscale";
178
- if (hostname.includes("ngrok")) return "ngrok";
179
- if (hostname.includes("cloudflare")) return "Cloudflare";
180
- return null;
181
- }
182
-
183
118
  /**
184
119
  * A loopback URL — `localhost`, `[::1]`, or `127.x.x.x`. A pairing link that
185
120
  * encodes a loopback address is unreachable from the scanning device.
@@ -232,9 +167,6 @@ export function resolvePublicBaseUrl(raw: string): PublicBaseUrlResult {
232
167
  if (isLoopbackPublicUrl(normalized)) {
233
168
  return { ok: false, reason: "loopback" };
234
169
  }
235
- if (isTunnelProviderWebsiteUrl(normalized)) {
236
- return { ok: false, reason: "service-website" };
237
- }
238
170
  if (new URL(normalized).protocol !== "https:") {
239
171
  return { ok: false, reason: "non-https" };
240
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.12-dev.202607250047.75b9ec6",
3
+ "version": "0.10.12-staging.1",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,19 +1,5 @@
1
- import {
2
- describe,
3
- test,
4
- expect,
5
- beforeEach,
6
- afterEach,
7
- afterAll,
8
- spyOn,
9
- } from "bun:test";
10
- import {
11
- mkdirSync,
12
- mkdtempSync,
13
- readFileSync,
14
- rmSync,
15
- writeFileSync,
16
- } from "node:fs";
1
+ import { describe, test, expect, beforeEach, afterAll } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
3
  import { tmpdir } from "node:os";
18
4
  import { join } from "node:path";
19
5
 
@@ -30,7 +16,6 @@ import {
30
16
  lookupAssistantByIdentifier,
31
17
  removeAssistantEntry,
32
18
  loadAllAssistants,
33
- resolveTargetAssistant,
34
19
  saveAssistantEntry,
35
20
  getActiveAssistant,
36
21
  migrateLegacyEntry,
@@ -715,104 +700,3 @@ describe("env-scoped lockfile and migration", () => {
715
700
  }
716
701
  });
717
702
  });
718
-
719
- describe("cross-environment hint on resolution failure", () => {
720
- let dataHome: string;
721
- let prevXdg: string | undefined;
722
- let prevEnv: string | undefined;
723
-
724
- beforeEach(() => {
725
- prevXdg = process.env.XDG_DATA_HOME;
726
- prevEnv = process.env.VELLUM_ENVIRONMENT;
727
- dataHome = mkdtempSync(join(tmpdir(), "cli-config-xdg-"));
728
- process.env.XDG_DATA_HOME = dataHome;
729
- // Pin the current env to production so the detector skips it and the hint
730
- // copy is deterministic. The current-env lockfile is emptied so resolution
731
- // genuinely finds nothing.
732
- process.env.VELLUM_ENVIRONMENT = "production";
733
- rmSync(join(testDir, ".vellum.lock.json"), { force: true });
734
- rmSync(join(testDir, ".vellum.lockfile.json"), { force: true });
735
- });
736
-
737
- afterEach(() => {
738
- if (prevXdg === undefined) {
739
- delete process.env.XDG_DATA_HOME;
740
- } else {
741
- process.env.XDG_DATA_HOME = prevXdg;
742
- }
743
- if (prevEnv === undefined) {
744
- delete process.env.VELLUM_ENVIRONMENT;
745
- } else {
746
- process.env.VELLUM_ENVIRONMENT = prevEnv;
747
- }
748
- rmSync(dataHome, { recursive: true, force: true });
749
- });
750
-
751
- // A dev-env assistant lives under the dev data dir, invisible to this
752
- // production CLI — the exact split the hint explains.
753
- function seedDevAssistant(): void {
754
- mkdirSync(join(dataHome, "vellum-dev", "assistants", "dev-bot"), {
755
- recursive: true,
756
- });
757
- }
758
-
759
- function captureResolveTargetAssistantError(): string {
760
- const errors: string[] = [];
761
- const errorSpy = spyOn(console, "error").mockImplementation(
762
- (...args: unknown[]) => {
763
- errors.push(args.join(" "));
764
- },
765
- );
766
- const exitSpy = spyOn(process, "exit").mockImplementation(((
767
- _code?: number,
768
- ) => {
769
- throw new Error("exit");
770
- }) as typeof process.exit);
771
- try {
772
- expect(() => resolveTargetAssistant()).toThrow("exit");
773
- } finally {
774
- errorSpy.mockRestore();
775
- exitSpy.mockRestore();
776
- }
777
- return errors.join("\n");
778
- }
779
-
780
- test("formatAssistantLookupError appends the hint when another env has assistants", () => {
781
- seedDevAssistant();
782
- const msg = formatAssistantLookupError("ghost", { status: "not_found" });
783
- expect(msg).toContain("No assistant found with name or ID 'ghost'.");
784
- expect(msg).toContain("Install vellum Command");
785
- expect(msg).toContain("VELLUM_ENVIRONMENT=dev");
786
- });
787
-
788
- test("formatAssistantLookupError stays bare when no other env has assistants", () => {
789
- // dataHome is empty — detection finds nothing, so the message is intact.
790
- const msg = formatAssistantLookupError("ghost", { status: "not_found" });
791
- expect(msg).toBe("No assistant found with name or ID 'ghost'.");
792
- });
793
-
794
- test("ambiguous lookup never gets the hint", () => {
795
- seedDevAssistant();
796
- const msg = formatAssistantLookupError("Alice", {
797
- status: "ambiguous",
798
- matches: [
799
- makeEntry("assistant-1", "http://localhost:7821", { name: "Alice" }),
800
- makeEntry("assistant-2", "http://localhost:7822", { name: "Alice" }),
801
- ],
802
- });
803
- expect(msg).not.toContain("Install vellum Command");
804
- });
805
-
806
- test("resolveTargetAssistant zero-assistants error carries the hint", () => {
807
- seedDevAssistant();
808
- const combined = captureResolveTargetAssistantError();
809
- expect(combined).toContain("No assistant found. Run 'vellum hatch' first.");
810
- expect(combined).toContain("Install vellum Command");
811
- });
812
-
813
- test("resolveTargetAssistant zero-assistants error stays bare without other envs", () => {
814
- const combined = captureResolveTargetAssistantError();
815
- expect(combined).toContain("No assistant found. Run 'vellum hatch' first.");
816
- expect(combined).not.toContain("Install vellum Command");
817
- });
818
- });
@@ -239,80 +239,3 @@ describe("vellum flags --assistant routing", () => {
239
239
  expect(fetchCalls.length).toBe(0);
240
240
  });
241
241
  });
242
-
243
- describe("vellum flags cross-environment hint", () => {
244
- let xdgDataHome: string;
245
- let prevXdg: string | undefined;
246
- let prevEnv: string | undefined;
247
-
248
- beforeEach(() => {
249
- process.env.VELLUM_LOCKFILE_DIR = testDir;
250
- rmSync(join(testDir, ".vellum.lock.json"), { force: true });
251
- prevXdg = process.env.XDG_DATA_HOME;
252
- prevEnv = process.env.VELLUM_ENVIRONMENT;
253
- xdgDataHome = mkdtempSync(join(tmpdir(), "cli-flags-hint-xdg-"));
254
- process.env.XDG_DATA_HOME = xdgDataHome;
255
- // Pin production so the detector skips it and the lockfile routes to
256
- // testDir; the VELLUM_ENVIRONMENT var beats this machine's env config.
257
- process.env.VELLUM_ENVIRONMENT = "production";
258
- // An unreachable gateway: every request throws like a dead socket would.
259
- // Cast through `unknown` since an always-throwing stub returns
260
- // `Promise<never>`, which does not structurally overlap with `fetch`.
261
- globalThis.fetch = (async () => {
262
- throw new TypeError("fetch failed");
263
- }) as unknown as typeof globalThis.fetch;
264
- process.exit = ((code?: number) => {
265
- throw new Error(`process.exit:${code}`);
266
- }) as typeof process.exit;
267
- consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
268
- consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
269
- });
270
-
271
- afterEach(() => {
272
- process.argv = originalArgv;
273
- process.exit = originalExit;
274
- globalThis.fetch = originalFetch;
275
- consoleLogSpy.mockRestore();
276
- consoleErrorSpy.mockRestore();
277
- if (prevXdg === undefined) {
278
- delete process.env.XDG_DATA_HOME;
279
- } else {
280
- process.env.XDG_DATA_HOME = prevXdg;
281
- }
282
- if (prevEnv === undefined) {
283
- delete process.env.VELLUM_ENVIRONMENT;
284
- } else {
285
- process.env.VELLUM_ENVIRONMENT = prevEnv;
286
- }
287
- rmSync(xdgDataHome, { recursive: true, force: true });
288
- });
289
-
290
- // A dev-env assistant lives under the dev data dir, invisible to this
291
- // production CLI — the split the hint explains.
292
- function seedDevAssistant(): void {
293
- mkdirSync(join(xdgDataHome, "vellum-dev", "assistants", "dev-bot"), {
294
- recursive: true,
295
- });
296
- }
297
-
298
- test("gateway-unreachable error gains the hint when another env has assistants", async () => {
299
- writeLockfile([makeEntry("alice-1", { name: "Alice" })], "alice-1");
300
- seedDevAssistant();
301
- process.argv = ["bun", "vellum", "flags", "set", "voice-mode", "true"];
302
- await expect(flags()).rejects.toThrow(/Install vellum Command/);
303
- });
304
-
305
- test("gateway-unreachable error stays bare when no other env has assistants", async () => {
306
- writeLockfile([makeEntry("alice-1", { name: "Alice" })], "alice-1");
307
- // xdgDataHome is empty — detection finds nothing, so the message is intact.
308
- process.argv = ["bun", "vellum", "flags", "set", "voice-mode", "true"];
309
- let error: Error | undefined;
310
- try {
311
- await flags();
312
- } catch (e) {
313
- error = e as Error;
314
- }
315
- expect(error?.message).toContain("Could not reach the assistant gateway");
316
- expect(error?.message).not.toContain("Install vellum Command");
317
- });
318
- });
@@ -159,100 +159,6 @@ describe("pair command", () => {
159
159
  expect(fetchCalled).toBe(true);
160
160
  });
161
161
 
162
- test("rejects an unknown --flag before any network call", async () => {
163
- let fetchCalled = false;
164
- const origFetch = globalThis.fetch;
165
- globalThis.fetch = (async () => {
166
- fetchCalled = true;
167
- return new Response("{}", { status: 200 });
168
- }) as unknown as typeof fetch;
169
- const errors: string[] = [];
170
- const errSpy = spyOn(console, "error").mockImplementation(
171
- (...a: unknown[]) => {
172
- errors.push(a.join(" "));
173
- },
174
- );
175
- const exitSpy = spyOn(process, "exit").mockImplementation(((
176
- code?: number,
177
- ) => {
178
- throw new Error(`exit:${code}`);
179
- }) as never);
180
-
181
- process.argv = ["bun", "vellum", "pair", "--frobnicate"];
182
- let exited = false;
183
- try {
184
- await pair();
185
- } catch (e) {
186
- exited = (e as Error).message === "exit:1";
187
- } finally {
188
- errSpy.mockRestore();
189
- exitSpy.mockRestore();
190
- globalThis.fetch = origFetch;
191
- }
192
-
193
- // A `--` flag this version doesn't know is a hard error, and it points at
194
- // the CLI self-update path — never a silent fall-through to another flow.
195
- expect(exited).toBe(true);
196
- const joined = errors.join("\n");
197
- expect(joined).toContain("unknown option '--frobnicate'");
198
- expect(joined).toContain("your CLI may be out of date");
199
- expect(joined).toContain("bun install -g vellum@latest");
200
- expect(fetchCalled).toBe(false);
201
- });
202
-
203
- test("rejects an unknown --flag even alongside a multi-word positional name", async () => {
204
- writeFileSync(
205
- join(testDir, ".vellum.lock.json"),
206
- JSON.stringify({
207
- assistants: [
208
- {
209
- assistantId: "pair-test",
210
- name: "My Assistant",
211
- runtimeUrl: RUNTIME_URL,
212
- localUrl: LOCAL_URL,
213
- cloud: "local",
214
- },
215
- ],
216
- activeAssistant: "pair-test",
217
- }),
218
- );
219
-
220
- let fetchCalled = false;
221
- const origFetch = globalThis.fetch;
222
- globalThis.fetch = (async () => {
223
- fetchCalled = true;
224
- return new Response("{}", { status: 200 });
225
- }) as unknown as typeof fetch;
226
- const errors: string[] = [];
227
- const errSpy = spyOn(console, "error").mockImplementation(
228
- (...a: unknown[]) => {
229
- errors.push(a.join(" "));
230
- },
231
- );
232
- const exitSpy = spyOn(process, "exit").mockImplementation(((
233
- code?: number,
234
- ) => {
235
- throw new Error(`exit:${code}`);
236
- }) as never);
237
-
238
- // Positional name tokens must not rescue an unknown flag from rejection.
239
- process.argv = ["bun", "vellum", "pair", "My", "Assistant", "--frobnicate"];
240
- let exited = false;
241
- try {
242
- await pair();
243
- } catch (e) {
244
- exited = (e as Error).message === "exit:1";
245
- } finally {
246
- errSpy.mockRestore();
247
- exitSpy.mockRestore();
248
- globalThis.fetch = origFetch;
249
- }
250
-
251
- expect(exited).toBe(true);
252
- expect(errors.join("\n")).toContain("unknown option '--frobnicate'");
253
- expect(fetchCalled).toBe(false);
254
- });
255
-
256
162
  test("refuses to advertise a loopback URL without --url (suggests the assistant's own port)", async () => {
257
163
  // Local hatch on a NON-default gateway port (e.g. a 2nd instance).
258
164
  const LOOPBACK_CUSTOM = "http://127.0.0.1:7842";
@@ -305,11 +211,6 @@ describe("pair command", () => {
305
211
  expect(errors.join("\n")).toContain(":7842");
306
212
  expect(errors.join("\n")).not.toContain(":7830");
307
213
 
308
- // The refusal cross-points to the QR flow (the phone-pairing path) and its
309
- // https prerequisite, so a user who wanted a QR isn't left at a dead end.
310
- expect(errors.join("\n")).toContain("vellum pair --qr");
311
- expect(errors.join("\n")).toContain("vellum tunnel --provider tailscale");
312
-
313
214
  // Exited with an error before minting — no token created for a dead URL.
314
215
  expect(exited).toBe(true);
315
216
  expect(fetchCalled).toBe(false);
@@ -847,53 +748,6 @@ describe("pair command", () => {
847
748
  expect(fetchCalled).toBe(false);
848
749
  });
849
750
 
850
- test("--qr refuses a tunnel-provider website URL (Tailscale admin invite) without minting", async () => {
851
- let fetchCalled = false;
852
- const origFetch = globalThis.fetch;
853
- globalThis.fetch = (async () => {
854
- fetchCalled = true;
855
- return new Response("{}", { status: 200 });
856
- }) as unknown as typeof fetch;
857
- const errors: string[] = [];
858
- const errSpy = spyOn(console, "error").mockImplementation(
859
- (...a: unknown[]) => {
860
- errors.push(a.join(" "));
861
- },
862
- );
863
- const exitSpy = spyOn(process, "exit").mockImplementation(((
864
- code?: number,
865
- ) => {
866
- throw new Error(`exit:${code}`);
867
- }) as never);
868
-
869
- process.argv = [
870
- "bun",
871
- "vellum",
872
- "pair",
873
- "--qr",
874
- "--url",
875
- "https://login.tailscale.com/admin/invite/abc123",
876
- ];
877
- let exited = false;
878
- try {
879
- await pair();
880
- } catch (e) {
881
- exited = (e as Error).message === "exit:1";
882
- } finally {
883
- errSpy.mockRestore();
884
- exitSpy.mockRestore();
885
- globalThis.fetch = origFetch;
886
- }
887
-
888
- // The admin-invite link is a vendor website — named as such, not mislabeled
889
- // as non-https, and refused before any challenge is minted.
890
- expect(exited).toBe(true);
891
- const joined = errors.join("\n");
892
- expect(joined).toContain("Tailscale's website");
893
- expect(joined).not.toContain("is not https");
894
- expect(fetchCalled).toBe(false);
895
- });
896
-
897
751
  test("--qr refuses when the web remote ingress feature flag is off", async () => {
898
752
  const calls: Array<[string, RequestInit | undefined]> = [];
899
753
  const origFetch = globalThis.fetch;
@@ -5,7 +5,6 @@ import {
5
5
  describe,
6
6
  expect,
7
7
  mock,
8
- spyOn,
9
8
  test,
10
9
  } from "bun:test";
11
10
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -163,56 +162,4 @@ describe("tunnel nginx ingress feature flag", () => {
163
162
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
164
163
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
165
164
  });
166
-
167
- test("rejects an unknown --provider with a stale-CLI hint", async () => {
168
- 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
- },
174
- );
175
- const exitSpy = spyOn(process, "exit").mockImplementation(((
176
- code?: number,
177
- ) => {
178
- throw new Error(`exit:${code}`);
179
- }) as never);
180
-
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
- }
190
-
191
- 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");
196
- expect(runNgrokTunnelMock).not.toHaveBeenCalled();
197
- expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
198
- });
199
-
200
- test("a not-yet-implemented provider error carries the stale-CLI hint", async () => {
201
- // The default `vellum` provider has no runtime yet, so it exercises the
202
- // not-yet-implemented path without any network call.
203
- process.argv = ["bun", "vellum", "tunnel", "--provider", "vellum"];
204
-
205
- let err: Error | undefined;
206
- try {
207
- await tunnel();
208
- } catch (e) {
209
- err = e as Error;
210
- }
211
-
212
- expect(err?.message).toContain("is not yet implemented");
213
- expect(err?.message).toContain("your CLI may be out of date");
214
- expect(err?.message).toContain("bun install -g vellum@latest");
215
- expect(runNgrokTunnelMock).not.toHaveBeenCalled();
216
- expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
217
- });
218
165
  });