@vellumai/cli 0.10.11-dev.202607240139.777dbcd → 0.10.11-dev.202607240310.e82c8d4

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,6 +24,7 @@ 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",
27
28
  resources: { gatewayPort: 7777, daemonPort: 7778 },
28
29
  },
29
30
  ],
@@ -186,6 +187,30 @@ describe("parseLockfile", () => {
186
187
  ]);
187
188
  });
188
189
 
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
+
189
214
  test("drops a resources object missing its numeric ports", () => {
190
215
  const raw = {
191
216
  assistants: [
@@ -115,6 +115,13 @@ 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(),
118
125
  resources: LocalAssistantResourcesSchema.optional(),
119
126
  });
120
127
 
@@ -81,6 +81,30 @@ 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
+
84
108
  test("fails with status 500 on malformed JSON", () => {
85
109
  fs.writeFileSync(lockfilePath, "{ not json");
86
110
  const result = getLockfileData([lockfilePath]);
@@ -109,12 +109,77 @@ 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 = "unparseable" | "loopback" | "non-https";
112
+ export type PublicBaseUrlRejection =
113
+ | "unparseable"
114
+ | "loopback"
115
+ | "non-https"
116
+ | "service-website";
113
117
 
114
118
  export type PublicBaseUrlResult =
115
119
  | { ok: true; url: string }
116
120
  | { ok: false; reason: PublicBaseUrlRejection };
117
121
 
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
+
118
183
  /**
119
184
  * A loopback URL — `localhost`, `[::1]`, or `127.x.x.x`. A pairing link that
120
185
  * encodes a loopback address is unreachable from the scanning device.
@@ -167,6 +232,9 @@ export function resolvePublicBaseUrl(raw: string): PublicBaseUrlResult {
167
232
  if (isLoopbackPublicUrl(normalized)) {
168
233
  return { ok: false, reason: "loopback" };
169
234
  }
235
+ if (isTunnelProviderWebsiteUrl(normalized)) {
236
+ return { ok: false, reason: "service-website" };
237
+ }
170
238
  if (new URL(normalized).protocol !== "https:") {
171
239
  return { ok: false, reason: "non-https" };
172
240
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.11-dev.202607240139.777dbcd",
3
+ "version": "0.10.11-dev.202607240310.e82c8d4",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,19 @@
1
- import { describe, test, expect, beforeEach, afterAll } from "bun:test";
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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";
3
17
  import { tmpdir } from "node:os";
4
18
  import { join } from "node:path";
5
19
 
@@ -16,6 +30,7 @@ import {
16
30
  lookupAssistantByIdentifier,
17
31
  removeAssistantEntry,
18
32
  loadAllAssistants,
33
+ resolveTargetAssistant,
19
34
  saveAssistantEntry,
20
35
  getActiveAssistant,
21
36
  migrateLegacyEntry,
@@ -700,3 +715,104 @@ describe("env-scoped lockfile and migration", () => {
700
715
  }
701
716
  });
702
717
  });
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,3 +239,80 @@ 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,6 +159,100 @@ 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
+
162
256
  test("refuses to advertise a loopback URL without --url (suggests the assistant's own port)", async () => {
163
257
  // Local hatch on a NON-default gateway port (e.g. a 2nd instance).
164
258
  const LOOPBACK_CUSTOM = "http://127.0.0.1:7842";
@@ -211,6 +305,11 @@ describe("pair command", () => {
211
305
  expect(errors.join("\n")).toContain(":7842");
212
306
  expect(errors.join("\n")).not.toContain(":7830");
213
307
 
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
+
214
313
  // Exited with an error before minting — no token created for a dead URL.
215
314
  expect(exited).toBe(true);
216
315
  expect(fetchCalled).toBe(false);
@@ -748,6 +847,53 @@ describe("pair command", () => {
748
847
  expect(fetchCalled).toBe(false);
749
848
  });
750
849
 
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
+
751
897
  test("--qr refuses when the web remote ingress feature flag is off", async () => {
752
898
  const calls: Array<[string, RequestInit | undefined]> = [];
753
899
  const origFetch = globalThis.fetch;
@@ -5,6 +5,7 @@ import {
5
5
  describe,
6
6
  expect,
7
7
  mock,
8
+ spyOn,
8
9
  test,
9
10
  } from "bun:test";
10
11
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -162,4 +163,56 @@ describe("tunnel nginx ingress feature flag", () => {
162
163
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
163
164
  expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
164
165
  });
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
+ });
165
218
  });
@@ -4,6 +4,7 @@ import {
4
4
  formatAssistantLookupError,
5
5
  lookupAssistantByIdentifier,
6
6
  } from "../lib/assistant-config.js";
7
+ import { crossEnvironmentAssistantHint } from "../lib/environments/detect.js";
7
8
 
8
9
  type FeatureFlagEntry = {
9
10
  key: string;
@@ -132,7 +133,9 @@ function rethrowFetchError(err: unknown): never {
132
133
  (err.message.includes("fetch") || err.message.includes("connect"))
133
134
  ) {
134
135
  throw new Error(
135
- "Could not reach the assistant gateway. Is it running? Try 'vellum wake'.",
136
+ `Could not reach the assistant gateway. Is it running? Try 'vellum wake'.${
137
+ crossEnvironmentAssistantHint() ?? ""
138
+ }`,
136
139
  );
137
140
  }
138
141
  throw err;
@@ -19,6 +19,7 @@ import {
19
19
  buildRemoteWebPairingUrl,
20
20
  normalizePublicBaseUrl,
21
21
  resolvePublicBaseUrl,
22
+ tunnelProviderWebsiteName,
22
23
  type PublicBaseUrlRejection,
23
24
  type RemoteWebPairingChallengeRequest,
24
25
  type RemoteWebPairingChallengeResponse,
@@ -46,6 +47,7 @@ import {
46
47
  } from "../lib/feature-flags.js";
47
48
  import { getLocalLanIPv4 } from "../lib/local.js";
48
49
  import { isLoopbackUrl, loopbackSafeFetch } from "../lib/loopback-fetch.js";
50
+ import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
49
51
 
50
52
  function assistantDisplayName(entry: AssistantEntry): string {
51
53
  return entry.name || entry.assistantName || entry.assistantId;
@@ -277,6 +279,22 @@ export async function pair(): Promise<void> {
277
279
  );
278
280
  args = afterAppScheme;
279
281
 
282
+ // Any `--`-prefixed token left after known-flag extraction is an option this
283
+ // CLI version doesn't support. Fail loud before any network call rather than
284
+ // letting it fall through: an unknown flag would otherwise be silently
285
+ // dropped and the command would run the wrong flow (e.g. `--qr` on an older
286
+ // CLI minting a copy-paste bundle instead of a QR). Positional names never
287
+ // start with `--`, so multi-word assistant targets are unaffected.
288
+ const unknownFlag = args.find((a) => a.startsWith("--"));
289
+ if (unknownFlag) {
290
+ console.error(`Error: unknown option '${unknownFlag}'.`);
291
+ console.error("Run `vellum pair --help` to see available options.");
292
+ console.error(
293
+ `If this option is from newer docs, ${STALE_CLI_UPDATE_HINT}`,
294
+ );
295
+ process.exit(1);
296
+ }
297
+
280
298
  if (webPairing && webApproveCode) {
281
299
  console.error("Error: use either --web or --web-approve, not both.");
282
300
  process.exit(1);
@@ -371,6 +389,10 @@ export async function pair(): Promise<void> {
371
389
  console.error(
372
390
  `Re-run with a reachable URL, e.g.:\n vellum pair --url ${suggestion}`,
373
391
  );
392
+ console.error(
393
+ "Pairing a phone by QR instead? Use: vellum pair --qr " +
394
+ "(needs an https URL — run `vellum tunnel --provider tailscale` first).",
395
+ );
374
396
  process.exit(1);
375
397
  }
376
398
 
@@ -451,6 +473,9 @@ export async function pair(): Promise<void> {
451
473
  unparseable: `${advertisedUrl} isn't a valid URL`,
452
474
  loopback: `${advertisedUrl} is a loopback address`,
453
475
  "non-https": `${advertisedUrl} is not https`,
476
+ "service-website": `${advertisedUrl} is ${
477
+ tunnelProviderWebsiteName(advertisedUrl) ?? "a tunnel provider"
478
+ }'s website, not your assistant's address`,
454
479
  };
455
480
  console.error(
456
481
  "Error: --qr needs a public https URL the phone can open — " +
@@ -8,6 +8,7 @@ import {
8
8
  WEB_REMOTE_INGRESS_FLAG,
9
9
  } from "../lib/feature-flags.js";
10
10
  import { runNgrokTunnel } from "../lib/ngrok";
11
+ import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
11
12
  import { runTailscaleTunnel } from "../lib/tailscale-tunnel.js";
12
13
 
13
14
  const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
@@ -78,12 +79,21 @@ function parseArgs(): TunnelArgs {
78
79
  process.exit(0);
79
80
  } else if (arg === "--provider") {
80
81
  const next = args[i + 1];
81
- if (!next || !VALID_PROVIDERS.includes(next as TunnelProvider)) {
82
+ if (!next || next.startsWith("-")) {
82
83
  console.error(
83
84
  `Error: --provider requires one of: ${VALID_PROVIDERS.join(", ")}`,
84
85
  );
85
86
  process.exit(1);
86
87
  }
88
+ if (!VALID_PROVIDERS.includes(next as TunnelProvider)) {
89
+ console.error(
90
+ `Error: unknown tunnel provider '${next}'. Valid providers: ${VALID_PROVIDERS.join(", ")}.`,
91
+ );
92
+ console.error(
93
+ `If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
94
+ );
95
+ process.exit(1);
96
+ }
87
97
  provider = next as TunnelProvider;
88
98
  i++;
89
99
  } else if (arg.startsWith("-")) {
@@ -199,5 +209,8 @@ export async function tunnel(): Promise<void> {
199
209
  return;
200
210
  }
201
211
 
202
- throw new Error(`Tunnel provider '${provider}' is not yet implemented.`);
212
+ throw new Error(
213
+ `Tunnel provider '${provider}' is not yet implemented. ` +
214
+ `If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
215
+ );
203
216
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from "@vellumai/local-mode/contract";
19
19
 
20
20
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
21
+ import { crossEnvironmentAssistantHint } from "./environments/detect.js";
21
22
  import {
22
23
  getDefaultPorts,
23
24
  getLockfilePath,
@@ -423,7 +424,7 @@ export function formatAssistantLookupError(
423
424
  return `Multiple assistants match '${identifier}': ${matches}. Use the assistant ID to disambiguate.`;
424
425
  }
425
426
 
426
- return `No assistant found with name or ID '${identifier}'.`;
427
+ return `No assistant found with name or ID '${identifier}'.${crossEnvironmentAssistantHint() ?? ""}`;
427
428
  }
428
429
 
429
430
  export function removeAssistantEntry(assistantId: string): void {
@@ -580,7 +581,9 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
580
581
  if (all.length === 1) return all[0];
581
582
 
582
583
  if (all.length === 0) {
583
- console.error("No assistant found. Run 'vellum hatch' first.");
584
+ console.error(
585
+ `No assistant found. Run 'vellum hatch' first.${crossEnvironmentAssistantHint() ?? ""}`,
586
+ );
584
587
  } else {
585
588
  console.error(
586
589
  `Multiple assistants found. Set an active assistant with 'vellum use <name>'.`,
@@ -0,0 +1,101 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ crossEnvironmentAssistantHint,
8
+ detectOtherEnvironmentAssistants,
9
+ } from "../detect.js";
10
+ import { getSeed } from "../resolve.js";
11
+
12
+ const prod = getSeed("production")!;
13
+
14
+ /** Create `<XDG_DATA_HOME>/vellum-<env>/assistants/<id>/` for a non-prod env. */
15
+ function seedAssistantDir(dataHome: string, envName: string, id: string): void {
16
+ mkdirSync(join(dataHome, `vellum-${envName}`, "assistants", id), {
17
+ recursive: true,
18
+ });
19
+ }
20
+
21
+ describe("cross-environment assistant detection", () => {
22
+ let dataHome: string;
23
+ let prevXdg: string | undefined;
24
+ let prevEnv: string | undefined;
25
+
26
+ beforeEach(() => {
27
+ prevXdg = process.env.XDG_DATA_HOME;
28
+ prevEnv = process.env.VELLUM_ENVIRONMENT;
29
+ dataHome = mkdtempSync(join(tmpdir(), "cli-detect-xdg-"));
30
+ process.env.XDG_DATA_HOME = dataHome;
31
+ // Pin the current env so the hint's "this CLI targets '<env>'" copy and
32
+ // the current-env skip are deterministic regardless of machine config.
33
+ process.env.VELLUM_ENVIRONMENT = "production";
34
+ });
35
+
36
+ afterEach(() => {
37
+ if (prevXdg === undefined) {
38
+ delete process.env.XDG_DATA_HOME;
39
+ } else {
40
+ process.env.XDG_DATA_HOME = prevXdg;
41
+ }
42
+ if (prevEnv === undefined) {
43
+ delete process.env.VELLUM_ENVIRONMENT;
44
+ } else {
45
+ process.env.VELLUM_ENVIRONMENT = prevEnv;
46
+ }
47
+ rmSync(dataHome, { recursive: true, force: true });
48
+ });
49
+
50
+ test("finds an env whose data dir holds an assistant subdirectory", () => {
51
+ seedAssistantDir(dataHome, "dev", "dev-bot");
52
+ expect(detectOtherEnvironmentAssistants(prod)).toContain("dev");
53
+ });
54
+
55
+ test("reports every other env that holds assistants", () => {
56
+ seedAssistantDir(dataHome, "dev", "dev-bot");
57
+ seedAssistantDir(dataHome, "staging", "staging-bot");
58
+ const others = detectOtherEnvironmentAssistants(prod);
59
+ expect(others).toContain("dev");
60
+ expect(others).toContain("staging");
61
+ });
62
+
63
+ test("skips the current environment even when it holds assistants", () => {
64
+ // production's own multi-instance dir (no env suffix).
65
+ mkdirSync(join(dataHome, "vellum", "assistants", "prod-bot"), {
66
+ recursive: true,
67
+ });
68
+ expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
69
+ });
70
+
71
+ test("ignores an env dir that holds no assistant subdirectory", () => {
72
+ mkdirSync(join(dataHome, "vellum-dev", "assistants"), { recursive: true });
73
+ expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
74
+ });
75
+
76
+ test("returns empty when no env data dirs exist", () => {
77
+ expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
78
+ });
79
+
80
+ test("swallows fs errors (data root is a file, not a dir) and returns empty", () => {
81
+ const filePath = join(dataHome, "not-a-dir");
82
+ writeFileSync(filePath, "x");
83
+ process.env.XDG_DATA_HOME = filePath;
84
+ expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
85
+ expect(crossEnvironmentAssistantHint()).toBeNull();
86
+ });
87
+
88
+ test("hint names the env, the current env, and both fixes", () => {
89
+ seedAssistantDir(dataHome, "dev", "dev-bot");
90
+ const hint = crossEnvironmentAssistantHint();
91
+ expect(hint).not.toBeNull();
92
+ expect(hint).toContain("'dev'");
93
+ expect(hint).toContain("This CLI targets 'production'");
94
+ expect(hint).toContain("Install vellum Command");
95
+ expect(hint).toContain("VELLUM_ENVIRONMENT=dev");
96
+ });
97
+
98
+ test("hint is null when no other env has assistants", () => {
99
+ expect(crossEnvironmentAssistantHint()).toBeNull();
100
+ });
101
+ });
@@ -0,0 +1,79 @@
1
+ import { readdirSync } from "fs";
2
+
3
+ import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
4
+
5
+ import { getMultiInstanceDir } from "./paths.js";
6
+ import { getCurrentEnvironment } from "./resolve.js";
7
+
8
+ /**
9
+ * Names of OTHER environments (every seed except `currentEnv`) whose
10
+ * multi-instance data dir exists and holds at least one assistant
11
+ * subdirectory.
12
+ *
13
+ * Each environment has its own on-host data layout — see
14
+ * {@link getMultiInstanceDir}. A user who hatched via a dev/staging desktop
15
+ * app but runs the production npm CLI (or vice versa) targets a different
16
+ * layout than the app did, so the assistant the app created is invisible to
17
+ * this CLI. This cheap filesystem scan surfaces that split so callers can
18
+ * point the user at the environment that actually holds their assistants.
19
+ *
20
+ * Never throws: a missing or unreadable dir just means that environment has no
21
+ * discoverable assistants, so it is skipped. Returns an empty array on any
22
+ * error.
23
+ */
24
+ export function detectOtherEnvironmentAssistants(
25
+ currentEnv: EnvironmentDefinition,
26
+ ): string[] {
27
+ const found: string[] = [];
28
+ for (const env of Object.values(SEEDS)) {
29
+ if (env.name === currentEnv.name) {
30
+ continue;
31
+ }
32
+ try {
33
+ const hasAssistant = readdirSync(getMultiInstanceDir(env), {
34
+ withFileTypes: true,
35
+ }).some((dirent) => dirent.isDirectory());
36
+ if (hasAssistant) {
37
+ found.push(env.name);
38
+ }
39
+ } catch {
40
+ // Missing or unreadable dir — this environment has no discoverable
41
+ // assistants. Skip it.
42
+ }
43
+ }
44
+ return found;
45
+ }
46
+
47
+ /**
48
+ * One-line hint appended to "no assistant found" and "gateway unreachable"
49
+ * errors when another environment's data dir holds assistants. A first-time
50
+ * user who hatched via a dev/staging desktop app while running the production
51
+ * npm CLI otherwise gets a bare failure with no clue the two tools target
52
+ * different environments.
53
+ *
54
+ * Returns null when nothing is found so callers can append unconditionally:
55
+ * `message + (crossEnvironmentAssistantHint() ?? "")`. Detection failure never
56
+ * propagates — the caller's original error must survive intact.
57
+ */
58
+ export function crossEnvironmentAssistantHint(): string | null {
59
+ let currentEnv: EnvironmentDefinition;
60
+ let others: string[];
61
+ try {
62
+ currentEnv = getCurrentEnvironment();
63
+ others = detectOtherEnvironmentAssistants(currentEnv);
64
+ } catch {
65
+ return null;
66
+ }
67
+ if (others.length === 0) {
68
+ return null;
69
+ }
70
+
71
+ const quoted = others.map((name) => `'${name}'`).join(", ");
72
+ const noun = others.length > 1 ? "environments" : "environment";
73
+ return (
74
+ `\n\nFound assistants in the ${quoted} ${noun} (managed by a dev/staging ` +
75
+ `desktop app). This CLI targets '${currentEnv.name}'. Use the desktop ` +
76
+ `app's 'Install vellum Command…' menu item to get a matching CLI, or ` +
77
+ `set VELLUM_ENVIRONMENT=${others[0]}.`
78
+ );
79
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared tail for errors where this CLI doesn't recognize an option or provider
3
+ * the user named. The name may be valid in a newer release, so point the user
4
+ * at the self-update path before they conclude the feature doesn't exist.
5
+ *
6
+ * `bun install -g vellum@latest` refreshes the `vellum` binary directly — the
7
+ * mechanism the installer and the CLI's own self-update use. (`vellum upgrade`
8
+ * targets the assistant runtime, not the CLI binary, for local assistants.)
9
+ */
10
+ export const STALE_CLI_UPDATE_HINT =
11
+ "your CLI may be out of date — update it with `bun install -g vellum@latest` and retry.";