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