@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.
- 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__/orphan-detection.test.ts +287 -0
- package/src/__tests__/preload.ts +5 -1
- package/src/__tests__/provider-secrets.test.ts +290 -0
- package/src/__tests__/ps-platform-status.test.ts +182 -0
- package/src/__tests__/search-provider-env-var-parity.test.ts +48 -0
- package/src/__tests__/setup.test.ts +296 -0
- package/src/__tests__/sync-events.test.ts +54 -0
- package/src/__tests__/teleport.test.ts +190 -163
- package/src/commands/client.ts +128 -10
- package/src/commands/events.ts +13 -1
- package/src/commands/login.ts +3 -2
- package/src/commands/ps.ts +28 -17
- package/src/commands/setup.ts +101 -96
- package/src/components/DefaultMainScreen.tsx +80 -128
- package/src/lib/__tests__/docker.test.ts +11 -0
- package/src/lib/assistant-config.ts +69 -2
- package/src/lib/client-identity.ts +1 -0
- package/src/lib/environments/paths.ts +21 -0
- package/src/lib/input-history.ts +5 -8
- package/src/lib/orphan-detection.ts +66 -1
- package/src/lib/platform-client.ts +8 -7
- package/src/lib/provider-secrets.ts +413 -0
- package/src/lib/statefulset.ts +12 -0
- package/src/lib/sync-cloud-assistants.ts +39 -18
- package/src/lib/upgrade-lifecycle.ts +9 -73
- package/src/shared/provider-env-vars.ts +15 -8
- package/src/lib/doctor-client.ts +0 -153
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
spyOn,
|
|
7
|
+
test,
|
|
8
|
+
type Mock,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
15
|
+
import {
|
|
16
|
+
saveGuardianToken,
|
|
17
|
+
type GuardianTokenData,
|
|
18
|
+
} from "../lib/guardian-token.js";
|
|
19
|
+
import { setup } from "../commands/setup.js";
|
|
20
|
+
|
|
21
|
+
interface RecordedFetchCall {
|
|
22
|
+
url: string;
|
|
23
|
+
method?: string;
|
|
24
|
+
headers: Headers;
|
|
25
|
+
body: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const originalArgv = [...process.argv];
|
|
29
|
+
const originalFetch = globalThis.fetch;
|
|
30
|
+
const originalEnv = {
|
|
31
|
+
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
|
32
|
+
openaiApiKey: process.env.OPENAI_API_KEY,
|
|
33
|
+
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
34
|
+
xdgDataHome: process.env.XDG_DATA_HOME,
|
|
35
|
+
vellumEnvironment: process.env.VELLUM_ENVIRONMENT,
|
|
36
|
+
vellumLockfileDir: process.env.VELLUM_LOCKFILE_DIR,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let testDir = "";
|
|
40
|
+
let fetchCalls: RecordedFetchCall[] = [];
|
|
41
|
+
let consoleLogSpy: Mock<(...args: unknown[]) => void>;
|
|
42
|
+
let consoleErrorSpy: Mock<(...args: unknown[]) => void>;
|
|
43
|
+
|
|
44
|
+
function guardianTokenFixture(
|
|
45
|
+
overrides: Partial<GuardianTokenData> = {},
|
|
46
|
+
): GuardianTokenData {
|
|
47
|
+
return {
|
|
48
|
+
guardianPrincipalId: "guardian-principal-123",
|
|
49
|
+
accessToken: "guardian-token",
|
|
50
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
51
|
+
refreshToken: "refresh-token",
|
|
52
|
+
refreshTokenExpiresAt: new Date(Date.now() + 120_000).toISOString(),
|
|
53
|
+
refreshAfter: new Date(Date.now() + 30_000).toISOString(),
|
|
54
|
+
isNew: false,
|
|
55
|
+
deviceId: "device-123",
|
|
56
|
+
leasedAt: new Date().toISOString(),
|
|
57
|
+
...overrides,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeLockfile(entry: AssistantEntry): void {
|
|
62
|
+
const lockfileDir = process.env.VELLUM_LOCKFILE_DIR!;
|
|
63
|
+
mkdirSync(lockfileDir, { recursive: true });
|
|
64
|
+
writeFileSync(
|
|
65
|
+
join(lockfileDir, ".vellum.lock.json"),
|
|
66
|
+
JSON.stringify(
|
|
67
|
+
{
|
|
68
|
+
assistants: [entry],
|
|
69
|
+
activeAssistant: entry.assistantId,
|
|
70
|
+
},
|
|
71
|
+
null,
|
|
72
|
+
2,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function installFetchStub(
|
|
78
|
+
options: { refreshedToken?: GuardianTokenData } = {},
|
|
79
|
+
) {
|
|
80
|
+
fetchCalls = [];
|
|
81
|
+
globalThis.fetch = (async (input, init) => {
|
|
82
|
+
const headers = new Headers(init?.headers);
|
|
83
|
+
const body =
|
|
84
|
+
typeof init?.body === "string" ? JSON.parse(init.body) : init?.body;
|
|
85
|
+
const url = String(input);
|
|
86
|
+
fetchCalls.push({
|
|
87
|
+
url,
|
|
88
|
+
method: init?.method,
|
|
89
|
+
headers,
|
|
90
|
+
body,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (url.endsWith("/v1/guardian/refresh")) {
|
|
94
|
+
if (!options.refreshedToken) {
|
|
95
|
+
return new Response(JSON.stringify({ error: "expired" }), {
|
|
96
|
+
status: 401,
|
|
97
|
+
headers: { "Content-Type": "application/json" },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return new Response(JSON.stringify(options.refreshedToken), {
|
|
101
|
+
status: 200,
|
|
102
|
+
headers: { "Content-Type": "application/json" },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (url.endsWith("/v1/secrets/read")) {
|
|
107
|
+
return new Response(JSON.stringify({ found: false }), {
|
|
108
|
+
status: 200,
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (url.endsWith("/v1/secrets")) {
|
|
114
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
115
|
+
status: 200,
|
|
116
|
+
headers: { "Content-Type": "application/json" },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return new Response(JSON.stringify({ error: "Unexpected URL" }), {
|
|
121
|
+
status: 500,
|
|
122
|
+
headers: { "Content-Type": "application/json" },
|
|
123
|
+
});
|
|
124
|
+
}) as typeof fetch;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function secretWriteCall(): RecordedFetchCall {
|
|
128
|
+
const call = fetchCalls.find((record) => record.url.endsWith("/v1/secrets"));
|
|
129
|
+
if (!call) {
|
|
130
|
+
throw new Error("Expected /v1/secrets call.");
|
|
131
|
+
}
|
|
132
|
+
return call;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe("setup command", () => {
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
testDir = mkdtempSync(join(tmpdir(), "vellum-setup-test-"));
|
|
138
|
+
process.argv = ["bun", "vellum", "setup"];
|
|
139
|
+
process.env.XDG_CONFIG_HOME = join(testDir, "config");
|
|
140
|
+
process.env.XDG_DATA_HOME = join(testDir, "data");
|
|
141
|
+
process.env.VELLUM_LOCKFILE_DIR = join(testDir, "lockfile");
|
|
142
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
143
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
144
|
+
delete process.env.OPENAI_API_KEY;
|
|
145
|
+
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
146
|
+
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
147
|
+
installFetchStub();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
process.argv = originalArgv;
|
|
152
|
+
globalThis.fetch = originalFetch;
|
|
153
|
+
setOptionalEnv("ANTHROPIC_API_KEY", originalEnv.anthropicApiKey);
|
|
154
|
+
setOptionalEnv("OPENAI_API_KEY", originalEnv.openaiApiKey);
|
|
155
|
+
setOptionalEnv("XDG_CONFIG_HOME", originalEnv.xdgConfigHome);
|
|
156
|
+
setOptionalEnv("XDG_DATA_HOME", originalEnv.xdgDataHome);
|
|
157
|
+
setOptionalEnv("VELLUM_ENVIRONMENT", originalEnv.vellumEnvironment);
|
|
158
|
+
setOptionalEnv("VELLUM_LOCKFILE_DIR", originalEnv.vellumLockfileDir);
|
|
159
|
+
consoleLogSpy.mockRestore();
|
|
160
|
+
consoleErrorSpy.mockRestore();
|
|
161
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("configures the default provider through the active assistant gateway", async () => {
|
|
165
|
+
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
166
|
+
writeLockfile({
|
|
167
|
+
assistantId: "assistant-123",
|
|
168
|
+
runtimeUrl: "http://runtime.example",
|
|
169
|
+
localUrl: "http://127.0.0.1:3000",
|
|
170
|
+
cloud: "local",
|
|
171
|
+
});
|
|
172
|
+
saveGuardianToken("assistant-123", guardianTokenFixture());
|
|
173
|
+
|
|
174
|
+
await setup();
|
|
175
|
+
|
|
176
|
+
expect(fetchCalls[0].url).toBe("http://127.0.0.1:3000/v1/secrets/read");
|
|
177
|
+
expect(fetchCalls[0].headers.get("Authorization")).toBe(
|
|
178
|
+
"Bearer guardian-token",
|
|
179
|
+
);
|
|
180
|
+
expect(secretWriteCall().body).toEqual({
|
|
181
|
+
type: "api_key",
|
|
182
|
+
name: "anthropic",
|
|
183
|
+
value: "test-anthropic-key",
|
|
184
|
+
});
|
|
185
|
+
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain(
|
|
186
|
+
"Anthropic API key saved to assistant from the environment.",
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("honors an explicit provider option", async () => {
|
|
191
|
+
process.argv = ["bun", "vellum", "setup", "--provider", "openai"];
|
|
192
|
+
process.env.OPENAI_API_KEY = "test-openai-key";
|
|
193
|
+
writeLockfile({
|
|
194
|
+
assistantId: "assistant-123",
|
|
195
|
+
runtimeUrl: "http://127.0.0.1:3000",
|
|
196
|
+
cloud: "local",
|
|
197
|
+
});
|
|
198
|
+
saveGuardianToken("assistant-123", guardianTokenFixture());
|
|
199
|
+
|
|
200
|
+
await setup();
|
|
201
|
+
|
|
202
|
+
expect(secretWriteCall().body).toEqual({
|
|
203
|
+
type: "api_key",
|
|
204
|
+
name: "openai",
|
|
205
|
+
value: "test-openai-key",
|
|
206
|
+
});
|
|
207
|
+
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain(
|
|
208
|
+
"OpenAI API key saved to assistant from the environment.",
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("falls back to runtime URL and lockfile bearer token", async () => {
|
|
213
|
+
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
214
|
+
writeLockfile({
|
|
215
|
+
assistantId: "assistant-123",
|
|
216
|
+
runtimeUrl: "https://assistant.example",
|
|
217
|
+
bearerToken: "entry-token",
|
|
218
|
+
cloud: "vellum",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await setup();
|
|
222
|
+
|
|
223
|
+
expect(fetchCalls[0].url).toBe("https://assistant.example/v1/secrets/read");
|
|
224
|
+
expect(fetchCalls[0].headers.get("Authorization")).toBe(
|
|
225
|
+
"Bearer entry-token",
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("falls back to the lockfile bearer token when guardian token is expired", async () => {
|
|
230
|
+
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
231
|
+
writeLockfile({
|
|
232
|
+
assistantId: "assistant-123",
|
|
233
|
+
runtimeUrl: "https://assistant.example",
|
|
234
|
+
bearerToken: "entry-token",
|
|
235
|
+
cloud: "vellum",
|
|
236
|
+
});
|
|
237
|
+
saveGuardianToken(
|
|
238
|
+
"assistant-123",
|
|
239
|
+
guardianTokenFixture({
|
|
240
|
+
accessToken: "expired-guardian-token",
|
|
241
|
+
accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString(),
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
await setup();
|
|
246
|
+
|
|
247
|
+
expect(fetchCalls[0].url).toBe(
|
|
248
|
+
"https://assistant.example/v1/guardian/refresh",
|
|
249
|
+
);
|
|
250
|
+
expect(fetchCalls[0].headers.get("Authorization")).toBe(
|
|
251
|
+
"Bearer expired-guardian-token",
|
|
252
|
+
);
|
|
253
|
+
expect(fetchCalls[1].headers.get("Authorization")).toBe(
|
|
254
|
+
"Bearer entry-token",
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("uses a refreshed guardian token before lockfile fallback", async () => {
|
|
259
|
+
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
260
|
+
writeLockfile({
|
|
261
|
+
assistantId: "assistant-123",
|
|
262
|
+
runtimeUrl: "https://assistant.example",
|
|
263
|
+
bearerToken: "entry-token",
|
|
264
|
+
cloud: "vellum",
|
|
265
|
+
});
|
|
266
|
+
saveGuardianToken(
|
|
267
|
+
"assistant-123",
|
|
268
|
+
guardianTokenFixture({
|
|
269
|
+
accessToken: "expired-guardian-token",
|
|
270
|
+
accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString(),
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
installFetchStub({
|
|
274
|
+
refreshedToken: guardianTokenFixture({
|
|
275
|
+
accessToken: "fresh-guardian-token",
|
|
276
|
+
}),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await setup();
|
|
280
|
+
|
|
281
|
+
expect(fetchCalls[0].url).toBe(
|
|
282
|
+
"https://assistant.example/v1/guardian/refresh",
|
|
283
|
+
);
|
|
284
|
+
expect(fetchCalls[1].headers.get("Authorization")).toBe(
|
|
285
|
+
"Bearer fresh-guardian-token",
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
function setOptionalEnv(name: string, value: string | undefined): void {
|
|
291
|
+
if (value === undefined) {
|
|
292
|
+
delete process.env[name];
|
|
293
|
+
} else {
|
|
294
|
+
process.env[name] = value;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { renderMarkdown } from "../commands/events.js";
|
|
4
|
+
|
|
5
|
+
type AssistantEvent = Parameters<typeof renderMarkdown>[0];
|
|
6
|
+
|
|
7
|
+
function makeEvent(message: AssistantEvent["message"]): AssistantEvent {
|
|
8
|
+
return {
|
|
9
|
+
id: "event-123",
|
|
10
|
+
assistantId: "assistant-123",
|
|
11
|
+
emittedAt: "2026-01-01T00:00:00.000Z",
|
|
12
|
+
message,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("sync_changed events", () => {
|
|
17
|
+
test("renders sync tags clearly in vellum events markdown output", () => {
|
|
18
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
19
|
+
try {
|
|
20
|
+
renderMarkdown(
|
|
21
|
+
makeEvent({
|
|
22
|
+
type: "sync_changed",
|
|
23
|
+
tags: ["assistant:self:avatar", "conversations:list"],
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
expect(consoleLog).toHaveBeenCalledWith(
|
|
28
|
+
"\n> **Sync changed:** `assistant:self:avatar`, `conversations:list`",
|
|
29
|
+
);
|
|
30
|
+
} finally {
|
|
31
|
+
consoleLog.mockRestore();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("tolerates malformed sync tags without throwing", () => {
|
|
36
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
37
|
+
try {
|
|
38
|
+
expect(() =>
|
|
39
|
+
renderMarkdown(
|
|
40
|
+
makeEvent({
|
|
41
|
+
type: "sync_changed",
|
|
42
|
+
tags: ["assistant:self:avatar", 42, null],
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
).not.toThrow();
|
|
46
|
+
|
|
47
|
+
expect(consoleLog).toHaveBeenCalledWith(
|
|
48
|
+
"\n> **Sync changed:** `assistant:self:avatar`",
|
|
49
|
+
);
|
|
50
|
+
} finally {
|
|
51
|
+
consoleLog.mockRestore();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|