@vellumai/cli 0.10.12-staging.1 → 0.10.12-staging.3
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.
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +25 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +7 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +24 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +69 -1
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +118 -2
- package/src/__tests__/flags.test.ts +77 -0
- package/src/__tests__/pair.test.ts +146 -0
- package/src/__tests__/tunnel.test.ts +53 -0
- package/src/__tests__/wake.test.ts +178 -0
- package/src/commands/flags.ts +4 -1
- package/src/commands/nginx-ingress.ts +57 -71
- package/src/commands/pair.ts +25 -0
- package/src/commands/tunnel.ts +15 -2
- package/src/commands/wake.ts +118 -0
- package/src/lib/assistant-config.ts +5 -2
- package/src/lib/environments/__tests__/detect.test.ts +101 -0
- package/src/lib/environments/detect.ts +79 -0
- package/src/lib/nginx-ingress.ts +90 -0
- package/src/lib/stale-cli-hint.ts +11 -0
|
@@ -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 =
|
|
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,5 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
});
|