@vellumai/cli 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,290 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+
4
+ import {
5
+ ensureProviderApiKey,
6
+ injectGatewayApiKey,
7
+ promptSecret,
8
+ readGatewayApiKey,
9
+ type ProviderSecretFetch,
10
+ } from "../lib/provider-secrets.js";
11
+
12
+ interface RecordedFetchCall {
13
+ url: string;
14
+ init?: RequestInit;
15
+ body: unknown;
16
+ }
17
+
18
+ function jsonResponse(body: unknown, status = 200): Response {
19
+ return new Response(JSON.stringify(body), {
20
+ status,
21
+ headers: { "Content-Type": "application/json" },
22
+ });
23
+ }
24
+
25
+ function makeFetch(responses: Response[]): {
26
+ calls: RecordedFetchCall[];
27
+ fetchImpl: ProviderSecretFetch;
28
+ } {
29
+ const calls: RecordedFetchCall[] = [];
30
+ const fetchImpl: ProviderSecretFetch = async (input, init) => {
31
+ calls.push({
32
+ url: String(input),
33
+ init,
34
+ body: typeof init?.body === "string" ? JSON.parse(init.body) : init?.body,
35
+ });
36
+ const response = responses.shift();
37
+ if (!response) {
38
+ throw new Error("Unexpected fetch call.");
39
+ }
40
+ return response;
41
+ };
42
+
43
+ return { calls, fetchImpl };
44
+ }
45
+
46
+ class FakePromptInput extends EventEmitter {
47
+ isTTY = true;
48
+ isRaw = false;
49
+ private paused = false;
50
+ pauseCount = 0;
51
+ rawModes: boolean[] = [];
52
+
53
+ isPaused(): boolean {
54
+ return this.paused;
55
+ }
56
+
57
+ resume(): this {
58
+ this.paused = false;
59
+ return this;
60
+ }
61
+
62
+ pause(): this {
63
+ this.paused = true;
64
+ this.pauseCount += 1;
65
+ return this;
66
+ }
67
+
68
+ setRawMode(value: boolean): this {
69
+ this.isRaw = value;
70
+ this.rawModes.push(value);
71
+ return this;
72
+ }
73
+ }
74
+
75
+ describe("provider secret helpers", () => {
76
+ test("reads provider keys from the api_key namespace", async () => {
77
+ const { calls, fetchImpl } = makeFetch([jsonResponse({ found: true })]);
78
+
79
+ await readGatewayApiKey(
80
+ "http://127.0.0.1:3000/",
81
+ "anthropic",
82
+ "guardian-token",
83
+ fetchImpl,
84
+ );
85
+
86
+ expect(calls).toHaveLength(1);
87
+ expect(calls[0].url).toBe("http://127.0.0.1:3000/v1/secrets/read");
88
+ expect(calls[0].init?.method).toBe("POST");
89
+ expect(calls[0].init?.headers).toMatchObject({
90
+ Authorization: "Bearer guardian-token",
91
+ "Content-Type": "application/json",
92
+ });
93
+ expect(calls[0].body).toEqual({
94
+ type: "api_key",
95
+ name: "anthropic",
96
+ reveal: false,
97
+ });
98
+ });
99
+
100
+ test("explains a missing secret route as a wrong active assistant URL", async () => {
101
+ const { fetchImpl } = makeFetch([
102
+ jsonResponse(
103
+ {
104
+ error: {
105
+ code: "not_found",
106
+ message: "Not found",
107
+ path: "/v1/secrets/read",
108
+ },
109
+ },
110
+ 404,
111
+ ),
112
+ ]);
113
+
114
+ await expect(
115
+ readGatewayApiKey(
116
+ "https://platform.vellum.ai",
117
+ "anthropic",
118
+ undefined,
119
+ fetchImpl,
120
+ ),
121
+ ).rejects.toThrow("does not expose /v1/secrets/read");
122
+ });
123
+
124
+ test("injects provider keys into the api_key namespace", async () => {
125
+ const { calls, fetchImpl } = makeFetch([jsonResponse({ success: true })]);
126
+
127
+ await injectGatewayApiKey(
128
+ "http://127.0.0.1:3000",
129
+ "openai",
130
+ "test-provider-key",
131
+ "guardian-token",
132
+ fetchImpl,
133
+ );
134
+
135
+ expect(calls).toHaveLength(1);
136
+ expect(calls[0].url).toBe("http://127.0.0.1:3000/v1/secrets");
137
+ expect(calls[0].body).toEqual({
138
+ type: "api_key",
139
+ name: "openai",
140
+ value: "test-provider-key",
141
+ });
142
+ });
143
+
144
+ test("does not prompt or rewrite an existing provider key", async () => {
145
+ const { calls, fetchImpl } = makeFetch([jsonResponse({ found: true })]);
146
+ let prompted = false;
147
+
148
+ const result = await ensureProviderApiKey({
149
+ gatewayUrl: "http://127.0.0.1:3000",
150
+ provider: "anthropic",
151
+ env: { ANTHROPIC_API_KEY: "test-provider-key" },
152
+ fetchImpl,
153
+ prompt: async () => {
154
+ prompted = true;
155
+ return "unused";
156
+ },
157
+ });
158
+
159
+ expect(result).toEqual({
160
+ status: "already_configured",
161
+ provider: "anthropic",
162
+ });
163
+ expect(prompted).toBe(false);
164
+ expect(calls).toHaveLength(1);
165
+ });
166
+
167
+ test("stores a provider key from the matching environment variable", async () => {
168
+ const { calls, fetchImpl } = makeFetch([
169
+ jsonResponse({ found: false }),
170
+ jsonResponse({ success: true }),
171
+ ]);
172
+
173
+ const result = await ensureProviderApiKey({
174
+ gatewayUrl: "http://127.0.0.1:3000",
175
+ provider: "anthropic",
176
+ env: { ANTHROPIC_API_KEY: " test-provider-key " },
177
+ fetchImpl,
178
+ stdinIsTTY: false,
179
+ });
180
+
181
+ expect(result).toEqual({
182
+ status: "configured",
183
+ provider: "anthropic",
184
+ source: "env",
185
+ });
186
+ expect(calls).toHaveLength(2);
187
+ expect(calls[1].body).toEqual({
188
+ type: "api_key",
189
+ name: "anthropic",
190
+ value: "test-provider-key",
191
+ });
192
+ });
193
+
194
+ test("prompts when no matching provider key is in the environment", async () => {
195
+ const { calls, fetchImpl } = makeFetch([
196
+ jsonResponse({ found: false }),
197
+ jsonResponse({ success: true }),
198
+ ]);
199
+ let promptText = "";
200
+
201
+ const result = await ensureProviderApiKey({
202
+ gatewayUrl: "http://127.0.0.1:3000",
203
+ provider: "openai",
204
+ env: {},
205
+ fetchImpl,
206
+ prompt: async (prompt) => {
207
+ promptText = prompt;
208
+ return "test-openai-key";
209
+ },
210
+ });
211
+
212
+ expect(result).toEqual({
213
+ status: "configured",
214
+ provider: "openai",
215
+ source: "prompt",
216
+ });
217
+ expect(promptText).toContain("OpenAI");
218
+ expect(promptText).toContain("OPENAI_API_KEY");
219
+ expect(calls[1].body).toEqual({
220
+ type: "api_key",
221
+ name: "openai",
222
+ value: "test-openai-key",
223
+ });
224
+ });
225
+
226
+ test("pauses prompt input after reading a secret", async () => {
227
+ const input = new FakePromptInput();
228
+ let outputText = "";
229
+ const output = {
230
+ write: (text: string) => {
231
+ outputText += text;
232
+ return true;
233
+ },
234
+ };
235
+
236
+ const resultPromise = promptSecret("Enter key: ", {
237
+ input: input as unknown as NodeJS.ReadStream,
238
+ output: output as unknown as NodeJS.WriteStream,
239
+ });
240
+ input.emit("data", Buffer.from("test-provider-key\n"));
241
+
242
+ await expect(resultPromise).resolves.toBe("test-provider-key");
243
+ expect(input.pauseCount).toBe(1);
244
+ expect(input.listenerCount("data")).toBe(0);
245
+ expect(input.rawModes).toEqual([true, false]);
246
+ expect(outputText).toBe("Enter key: \n");
247
+ });
248
+
249
+ test("returns a missing-key result in non-interactive shells", async () => {
250
+ const { calls, fetchImpl } = makeFetch([jsonResponse({ found: false })]);
251
+
252
+ const result = await ensureProviderApiKey({
253
+ gatewayUrl: "http://127.0.0.1:3000",
254
+ provider: "anthropic",
255
+ env: {},
256
+ fetchImpl,
257
+ stdinIsTTY: false,
258
+ });
259
+
260
+ expect(result).toEqual({
261
+ status: "missing",
262
+ provider: "anthropic",
263
+ message:
264
+ "Missing ANTHROPIC_API_KEY. Set it in the environment or run vellum setup from an interactive terminal.",
265
+ });
266
+ expect(calls).toHaveLength(1);
267
+ });
268
+
269
+ test("reports an unavailable credential store without prompting", async () => {
270
+ const { calls, fetchImpl } = makeFetch([
271
+ jsonResponse({ found: false, unreachable: true }),
272
+ ]);
273
+ let prompted = false;
274
+
275
+ const result = await ensureProviderApiKey({
276
+ gatewayUrl: "http://127.0.0.1:3000",
277
+ provider: "anthropic",
278
+ env: {},
279
+ fetchImpl,
280
+ prompt: async () => {
281
+ prompted = true;
282
+ return "unused";
283
+ },
284
+ });
285
+
286
+ expect(result.status).toBe("failed");
287
+ expect(prompted).toBe(false);
288
+ expect(calls).toHaveLength(1);
289
+ });
290
+ });
@@ -0,0 +1,182 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Temp directory for lockfile isolation
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const testDir = mkdtempSync(join(tmpdir(), "cli-ps-platform-status-test-"));
11
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Mocks — set up before importing the command under test. All spies are
15
+ // restored in afterAll so we don't leak module state to neighbouring suites.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ import * as assistantConfig from "../lib/assistant-config.js";
19
+ import * as orphanDetection from "../lib/orphan-detection.js";
20
+ import * as platformClient from "../lib/platform-client.js";
21
+
22
+ const loadAllAssistantsMock = spyOn(
23
+ assistantConfig,
24
+ "loadAllAssistants",
25
+ ).mockReturnValue([]);
26
+ const getActiveAssistantMock = spyOn(
27
+ assistantConfig,
28
+ "getActiveAssistant",
29
+ ).mockReturnValue(null);
30
+ const detectOrphanedProcessesMock = spyOn(
31
+ orphanDetection,
32
+ "detectOrphanedProcesses",
33
+ ).mockResolvedValue([]);
34
+ const getPlatformUrlMock = spyOn(
35
+ platformClient,
36
+ "getPlatformUrl",
37
+ ).mockReturnValue("http://platform.test");
38
+
39
+ // Per-test toggle for `readPlatformToken`.
40
+ const readPlatformTokenMock = spyOn(
41
+ platformClient,
42
+ "readPlatformToken",
43
+ ).mockReturnValue(null);
44
+
45
+ // `fetchCurrentUser` + `fetchPlatformAssistants` are spied so we can assert
46
+ // they're never invoked on the no-token path, and re-shaped per-test for the
47
+ // token-but-unreachable path.
48
+ const fetchCurrentUserMock = spyOn(
49
+ platformClient,
50
+ "fetchCurrentUser",
51
+ ).mockResolvedValue({
52
+ id: "u1",
53
+ email: "test@example.com",
54
+ display: "Test",
55
+ });
56
+ const fetchPlatformAssistantsMock = spyOn(
57
+ platformClient,
58
+ "fetchPlatformAssistants",
59
+ ).mockResolvedValue([]);
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // stdout / stderr capture
63
+ // ---------------------------------------------------------------------------
64
+
65
+ let stdout: string[];
66
+ let stderr: string[];
67
+ let originalLog: typeof console.log;
68
+ let originalError: typeof console.error;
69
+
70
+ beforeEach(() => {
71
+ stdout = [];
72
+ stderr = [];
73
+ originalLog = console.log;
74
+ originalError = console.error;
75
+ console.log = ((...args: unknown[]) => {
76
+ stdout.push(args.map((a) => String(a)).join(" "));
77
+ }) as typeof console.log;
78
+ console.error = ((...args: unknown[]) => {
79
+ stderr.push(args.map((a) => String(a)).join(" "));
80
+ }) as typeof console.error;
81
+ });
82
+
83
+ afterEach(() => {
84
+ console.log = originalLog;
85
+ console.error = originalError;
86
+ readPlatformTokenMock.mockReturnValue(null);
87
+ fetchCurrentUserMock.mockReset();
88
+ fetchCurrentUserMock.mockResolvedValue({
89
+ id: "u1",
90
+ email: "test@example.com",
91
+ display: "Test",
92
+ });
93
+ fetchPlatformAssistantsMock.mockReset();
94
+ fetchPlatformAssistantsMock.mockResolvedValue([]);
95
+ });
96
+
97
+ afterAll(() => {
98
+ loadAllAssistantsMock.mockRestore();
99
+ getActiveAssistantMock.mockRestore();
100
+ detectOrphanedProcessesMock.mockRestore();
101
+ getPlatformUrlMock.mockRestore();
102
+ readPlatformTokenMock.mockRestore();
103
+ fetchCurrentUserMock.mockRestore();
104
+ fetchPlatformAssistantsMock.mockRestore();
105
+ rmSync(testDir, { recursive: true, force: true });
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Import the command under test AFTER mocks are wired up
110
+ // ---------------------------------------------------------------------------
111
+
112
+ import { listAllAssistants } from "../commands/ps.js";
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Tests
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe("vellum ps — platform status line", () => {
119
+ test("no local token: prints 'Platform: not logged in' and skips ALL network fetches", async () => {
120
+ readPlatformTokenMock.mockReturnValue(null);
121
+
122
+ await listAllAssistants(false);
123
+
124
+ // The status line is present, exactly once, with no redundant error log.
125
+ expect(stdout.filter((l) => l.startsWith("Platform:"))).toEqual([
126
+ "Platform: not logged in",
127
+ ]);
128
+ expect(
129
+ stderr.some((l) => l.includes("Failed to fetch organization")),
130
+ ).toBe(false);
131
+ expect(
132
+ stdout.some((l) => l.includes("Failed to fetch organization")),
133
+ ).toBe(false);
134
+
135
+ // Structural guarantee: we never even tried to talk to the platform.
136
+ expect(fetchCurrentUserMock).not.toHaveBeenCalled();
137
+ expect(fetchPlatformAssistantsMock).not.toHaveBeenCalled();
138
+ });
139
+
140
+ test("local token present but platform is unreachable: still shows 'Platform: not logged in' with no leaked org-fetch error", async () => {
141
+ readPlatformTokenMock.mockReturnValue("session_abc123");
142
+ // Simulate the exact Bun connect failure the user reported:
143
+ // "Unable to connect. Is the computer able to access the url?"
144
+ const connectError = new Error(
145
+ "Unable to connect. Is the computer able to access the url?",
146
+ );
147
+ fetchCurrentUserMock.mockRejectedValue(connectError);
148
+ fetchPlatformAssistantsMock.mockRejectedValue(connectError);
149
+
150
+ await listAllAssistants(false);
151
+
152
+ expect(stdout.filter((l) => l.startsWith("Platform:"))).toEqual([
153
+ "Platform: not logged in",
154
+ ]);
155
+ expect(
156
+ stderr.some((l) => l.includes("Failed to fetch organization")),
157
+ ).toBe(false);
158
+ expect(
159
+ stdout.some((l) => l.includes("Failed to fetch organization")),
160
+ ).toBe(false);
161
+ expect(
162
+ stderr.some((l) => l.includes("Unable to connect")),
163
+ ).toBe(false);
164
+ });
165
+
166
+ test("local token present and platform reachable: prints 'Platform: logged in as <email>'", async () => {
167
+ readPlatformTokenMock.mockReturnValue("session_abc123");
168
+ fetchCurrentUserMock.mockResolvedValue({
169
+ id: "u1",
170
+ email: "vargas@vellum.ai",
171
+ display: "Vargas",
172
+ });
173
+ fetchPlatformAssistantsMock.mockResolvedValue([]);
174
+
175
+ await listAllAssistants(false);
176
+
177
+ expect(stdout).toContain("Platform: logged in as vargas@vellum.ai");
178
+ expect(
179
+ stderr.some((l) => l.includes("Failed to fetch organization")),
180
+ ).toBe(false);
181
+ });
182
+ });
@@ -0,0 +1,48 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, test } from "bun:test";
4
+
5
+ import { SEARCH_PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
6
+
7
+ /**
8
+ * Drift guard for the CLI-side search provider env-var mirror.
9
+ *
10
+ * `cli/src/shared/provider-env-vars.ts` hardcodes the search env-var names so
11
+ * the CLI doesn't need to import the assistant's
12
+ * `SEARCH_PROVIDER_CATALOG` (no CLI → assistant cross-package imports exist).
13
+ * This test pulls the catalog JSON at `meta/web-search-provider-catalog.json`
14
+ * — which is kept in sync with `SEARCH_PROVIDER_CATALOG` by
15
+ * `assistant/src/__tests__/web-search-catalog-parity.test.ts` — and asserts
16
+ * the CLI's mirror matches the catalog's `envVar` entries.
17
+ *
18
+ * Mirrors `llm-provider-env-var-parity.test.ts`.
19
+ */
20
+
21
+ const REPO_ROOT = join(import.meta.dir, "..", "..", "..");
22
+
23
+ interface SearchCatalogEntry {
24
+ id: string;
25
+ kind: "managed" | "byok";
26
+ envVar?: string;
27
+ }
28
+
29
+ interface SearchCatalog {
30
+ version: number;
31
+ providers: SearchCatalogEntry[];
32
+ }
33
+
34
+ function loadSearchCatalog(): SearchCatalog {
35
+ const path = join(REPO_ROOT, "meta", "web-search-provider-catalog.json");
36
+ return JSON.parse(readFileSync(path, "utf-8"));
37
+ }
38
+
39
+ describe("CLI search provider env-var parity", () => {
40
+ test("SEARCH_PROVIDER_ENV_VAR_NAMES matches meta/web-search-provider-catalog.json entries with envVar", () => {
41
+ const catalog = loadSearchCatalog();
42
+ const expected: Record<string, string> = {};
43
+ for (const provider of catalog.providers) {
44
+ if (provider.envVar) expected[provider.id] = provider.envVar;
45
+ }
46
+ expect(SEARCH_PROVIDER_ENV_VAR_NAMES).toEqual(expected);
47
+ });
48
+ });