@vellumai/cli 0.8.1 → 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.
- package/package.json +1 -1
- package/src/__tests__/backup.test.ts +13 -3
- package/src/__tests__/input-history.test.ts +102 -0
- package/src/__tests__/preload.ts +5 -1
- package/src/__tests__/provider-secrets.test.ts +290 -0
- package/src/__tests__/setup.test.ts +296 -0
- package/src/__tests__/teleport.test.ts +190 -163
- package/src/commands/client.ts +57 -1
- package/src/commands/setup.ts +101 -96
- package/src/components/DefaultMainScreen.tsx +72 -119
- package/src/lib/assistant-config.ts +4 -2
- package/src/lib/environments/paths.ts +21 -0
- package/src/lib/input-history.ts +5 -8
- package/src/lib/provider-secrets.ts +413 -0
- package/src/lib/sync-cloud-assistants.ts +23 -9
- package/src/shared/provider-env-vars.ts +3 -0
- package/src/lib/doctor-client.ts +0 -153
package/package.json
CHANGED
|
@@ -81,12 +81,20 @@ const localRuntimePollJobStatusMock = spyOn(
|
|
|
81
81
|
|
|
82
82
|
// Mode 1 (runtime-direct local backup) uses guardian tokens. Don't exercise
|
|
83
83
|
// it here, but the spies need to exist so the module under test can import
|
|
84
|
-
// them without surprises.
|
|
85
|
-
|
|
84
|
+
// them without surprises. Saved to variables so afterAll can restore them —
|
|
85
|
+
// otherwise the spied loadGuardianToken leaks into guardian-token.test.ts and
|
|
86
|
+
// setup.test.ts when they run later in the same `bun test` invocation.
|
|
87
|
+
const loadGuardianTokenSpy = spyOn(
|
|
88
|
+
guardianToken,
|
|
89
|
+
"loadGuardianToken",
|
|
90
|
+
).mockReturnValue({
|
|
86
91
|
accessToken: "local-token",
|
|
87
92
|
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
88
93
|
} as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
|
|
89
|
-
spyOn(
|
|
94
|
+
const leaseGuardianTokenSpy = spyOn(
|
|
95
|
+
guardianToken,
|
|
96
|
+
"leaseGuardianToken",
|
|
97
|
+
).mockResolvedValue({
|
|
90
98
|
accessToken: "leased-token",
|
|
91
99
|
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
92
100
|
} as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
|
|
@@ -177,6 +185,8 @@ afterAll(() => {
|
|
|
177
185
|
getBackupsDirMock.mockRestore();
|
|
178
186
|
mkdirSyncMock.mockRestore();
|
|
179
187
|
writeFileSyncMock.mockRestore();
|
|
188
|
+
loadGuardianTokenSpy.mockRestore();
|
|
189
|
+
leaseGuardianTokenSpy.mockRestore();
|
|
180
190
|
rmSync(testDir, { recursive: true, force: true });
|
|
181
191
|
});
|
|
182
192
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { getInputHistoryPath } from "../lib/environments/paths.js";
|
|
12
|
+
import { appendHistory, loadHistory } from "../lib/input-history.js";
|
|
13
|
+
|
|
14
|
+
describe("input-history XDG paths", () => {
|
|
15
|
+
let tempDir: string;
|
|
16
|
+
let savedState: string | undefined;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
savedState = process.env.XDG_STATE_HOME;
|
|
20
|
+
tempDir = mkdtempSync(join(tmpdir(), "cli-input-history-test-"));
|
|
21
|
+
process.env.XDG_STATE_HOME = join(tempDir, ".local", "state");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
if (savedState === undefined) {
|
|
26
|
+
delete process.env.XDG_STATE_HOME;
|
|
27
|
+
} else {
|
|
28
|
+
process.env.XDG_STATE_HOME = savedState;
|
|
29
|
+
}
|
|
30
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("appendHistory writes to $XDG_STATE_HOME/vellum/input-history", () => {
|
|
34
|
+
appendHistory("hello world");
|
|
35
|
+
|
|
36
|
+
const canonical = getInputHistoryPath();
|
|
37
|
+
expect(canonical).toBe(
|
|
38
|
+
join(tempDir, ".local", "state", "vellum", "input-history"),
|
|
39
|
+
);
|
|
40
|
+
expect(existsSync(canonical)).toBe(true);
|
|
41
|
+
expect(readFileSync(canonical, "utf-8")).toBe("hello world\n");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("appendHistory does NOT touch ~/.vellum/", () => {
|
|
45
|
+
// Crucially: the CLI must not create or write to ~/.vellum/ per the
|
|
46
|
+
// "No `.vellum/` directory access" boundary in cli/AGENTS.md. We snapshot
|
|
47
|
+
// the legacy path's existence before the call (some test machines already
|
|
48
|
+
// have a ~/.vellum/ for unrelated daemon state) and assert the file at
|
|
49
|
+
// that path is unchanged afterwards.
|
|
50
|
+
const legacyPath = join(homedir(), ".vellum", "input-history");
|
|
51
|
+
const existedBefore = existsSync(legacyPath);
|
|
52
|
+
const contentBefore: string = existedBefore
|
|
53
|
+
? readFileSync(legacyPath, "utf-8")
|
|
54
|
+
: "";
|
|
55
|
+
|
|
56
|
+
appendHistory("hello");
|
|
57
|
+
|
|
58
|
+
expect(existsSync(legacyPath)).toBe(existedBefore);
|
|
59
|
+
if (existedBefore) {
|
|
60
|
+
expect(readFileSync(legacyPath, "utf-8")).toBe(contentBefore);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("XDG_STATE_HOME default is ~/.local/state when unset", () => {
|
|
65
|
+
delete process.env.XDG_STATE_HOME;
|
|
66
|
+
|
|
67
|
+
// os.homedir() is cached at process start by Bun and ignores
|
|
68
|
+
// process.env.HOME mutations, so compute the expected path from the same
|
|
69
|
+
// source the production helper uses.
|
|
70
|
+
expect(getInputHistoryPath()).toBe(
|
|
71
|
+
join(homedir(), ".local", "state", "vellum", "input-history"),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("appendHistory skips empty and slash-command entries", () => {
|
|
76
|
+
appendHistory("");
|
|
77
|
+
appendHistory(" ");
|
|
78
|
+
appendHistory("/help");
|
|
79
|
+
appendHistory("real entry");
|
|
80
|
+
|
|
81
|
+
expect(loadHistory()).toEqual(["real entry"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("appendHistory deduplicates by moving to most recent", () => {
|
|
85
|
+
appendHistory("a");
|
|
86
|
+
appendHistory("b");
|
|
87
|
+
appendHistory("a");
|
|
88
|
+
|
|
89
|
+
expect(loadHistory()).toEqual(["b", "a"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("appendHistory caps history at MAX_ENTRIES (1000)", () => {
|
|
93
|
+
for (let i = 0; i < 1100; i++) {
|
|
94
|
+
appendHistory(`entry-${i}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const history = loadHistory();
|
|
98
|
+
expect(history.length).toBe(1000);
|
|
99
|
+
expect(history[0]).toBe("entry-100");
|
|
100
|
+
expect(history[999]).toBe("entry-1099");
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/__tests__/preload.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import { afterAll } from "bun:test";
|
|
13
|
+
import { afterAll, mock } from "bun:test";
|
|
14
14
|
|
|
15
15
|
const testDir = realpathSync(
|
|
16
16
|
mkdtempSync(join(tmpdir(), "vellum-cli-test-workspace-")),
|
|
@@ -24,4 +24,8 @@ afterAll(() => {
|
|
|
24
24
|
} catch {
|
|
25
25
|
/* best-effort cleanup */
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
// Reset all module mocks so mock.module() calls in one test file
|
|
29
|
+
// don't leak into the next file in the same bun test run.
|
|
30
|
+
mock.restore();
|
|
27
31
|
});
|
|
@@ -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
|
+
});
|