@vellumai/cli 0.8.3 → 0.8.5
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/AGENTS.md +29 -7
- package/package.json +1 -1
- package/src/__tests__/api-key-check.test.ts +78 -0
- package/src/__tests__/assistant-config.test.ts +108 -0
- package/src/__tests__/assistant-target-args.test.ts +30 -0
- package/src/__tests__/host-image-loader.test.ts +206 -0
- package/src/__tests__/ps-platform-status.test.ts +100 -22
- package/src/__tests__/retire.test.ts +241 -0
- package/src/__tests__/use.test.ts +144 -0
- package/src/commands/client.ts +27 -24
- package/src/commands/ps.ts +107 -105
- package/src/commands/retire.ts +144 -34
- package/src/commands/roadmap.ts +449 -0
- package/src/commands/use.ts +24 -10
- package/src/components/DefaultMainScreen.tsx +27 -115
- package/src/index.ts +3 -0
- package/src/lib/__tests__/port-allocator.test.ts +117 -0
- package/src/lib/__tests__/step-runner.test.ts +85 -0
- package/src/lib/api-key-check.ts +40 -0
- package/src/lib/assistant-config.ts +84 -5
- package/src/lib/assistant-target-args.ts +21 -0
- package/src/lib/docker.ts +67 -16
- package/src/lib/hatch-local.ts +11 -0
- package/src/lib/host-image-loader.ts +138 -0
- package/src/lib/platform-releases.ts +12 -5
- package/src/lib/port-allocator.ts +93 -0
- package/src/lib/statefulset.ts +0 -10
- package/src/lib/step-runner.ts +40 -7
- package/src/shared/provider-env-vars.ts +1 -0
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
2
10
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
11
|
import { tmpdir } from "node:os";
|
|
4
12
|
import { join } from "node:path";
|
|
@@ -16,6 +24,7 @@ process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
|
16
24
|
// ---------------------------------------------------------------------------
|
|
17
25
|
|
|
18
26
|
import * as assistantConfig from "../lib/assistant-config.js";
|
|
27
|
+
import * as healthCheck from "../lib/health-check.js";
|
|
19
28
|
import * as orphanDetection from "../lib/orphan-detection.js";
|
|
20
29
|
import * as platformClient from "../lib/platform-client.js";
|
|
21
30
|
|
|
@@ -57,6 +66,10 @@ const fetchPlatformAssistantsMock = spyOn(
|
|
|
57
66
|
platformClient,
|
|
58
67
|
"fetchPlatformAssistants",
|
|
59
68
|
).mockResolvedValue([]);
|
|
69
|
+
const checkManagedHealthMock = spyOn(
|
|
70
|
+
healthCheck,
|
|
71
|
+
"checkManagedHealth",
|
|
72
|
+
).mockResolvedValue({ status: "healthy", detail: null });
|
|
60
73
|
|
|
61
74
|
// ---------------------------------------------------------------------------
|
|
62
75
|
// stdout / stderr capture
|
|
@@ -66,23 +79,32 @@ let stdout: string[];
|
|
|
66
79
|
let stderr: string[];
|
|
67
80
|
let originalLog: typeof console.log;
|
|
68
81
|
let originalError: typeof console.error;
|
|
82
|
+
let originalStdoutWrite: typeof process.stdout.write;
|
|
69
83
|
|
|
70
84
|
beforeEach(() => {
|
|
71
85
|
stdout = [];
|
|
72
86
|
stderr = [];
|
|
73
87
|
originalLog = console.log;
|
|
74
88
|
originalError = console.error;
|
|
89
|
+
originalStdoutWrite = process.stdout.write;
|
|
75
90
|
console.log = ((...args: unknown[]) => {
|
|
76
91
|
stdout.push(args.map((a) => String(a)).join(" "));
|
|
77
92
|
}) as typeof console.log;
|
|
78
93
|
console.error = ((...args: unknown[]) => {
|
|
79
94
|
stderr.push(args.map((a) => String(a)).join(" "));
|
|
80
95
|
}) as typeof console.error;
|
|
96
|
+
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
97
|
+
stdout.push(String(chunk));
|
|
98
|
+
return true;
|
|
99
|
+
}) as typeof process.stdout.write;
|
|
81
100
|
});
|
|
82
101
|
|
|
83
102
|
afterEach(() => {
|
|
84
103
|
console.log = originalLog;
|
|
85
104
|
console.error = originalError;
|
|
105
|
+
process.stdout.write = originalStdoutWrite;
|
|
106
|
+
loadAllAssistantsMock.mockReturnValue([]);
|
|
107
|
+
getActiveAssistantMock.mockReturnValue(null);
|
|
86
108
|
readPlatformTokenMock.mockReturnValue(null);
|
|
87
109
|
fetchCurrentUserMock.mockReset();
|
|
88
110
|
fetchCurrentUserMock.mockResolvedValue({
|
|
@@ -92,6 +114,8 @@ afterEach(() => {
|
|
|
92
114
|
});
|
|
93
115
|
fetchPlatformAssistantsMock.mockReset();
|
|
94
116
|
fetchPlatformAssistantsMock.mockResolvedValue([]);
|
|
117
|
+
checkManagedHealthMock.mockReset();
|
|
118
|
+
checkManagedHealthMock.mockResolvedValue({ status: "healthy", detail: null });
|
|
95
119
|
});
|
|
96
120
|
|
|
97
121
|
afterAll(() => {
|
|
@@ -102,6 +126,7 @@ afterAll(() => {
|
|
|
102
126
|
readPlatformTokenMock.mockRestore();
|
|
103
127
|
fetchCurrentUserMock.mockRestore();
|
|
104
128
|
fetchPlatformAssistantsMock.mockRestore();
|
|
129
|
+
checkManagedHealthMock.mockRestore();
|
|
105
130
|
rmSync(testDir, { recursive: true, force: true });
|
|
106
131
|
});
|
|
107
132
|
|
|
@@ -125,12 +150,12 @@ describe("vellum ps — platform status line", () => {
|
|
|
125
150
|
expect(stdout.filter((l) => l.startsWith("Platform:"))).toEqual([
|
|
126
151
|
"Platform: not logged in",
|
|
127
152
|
]);
|
|
128
|
-
expect(
|
|
129
|
-
|
|
130
|
-
)
|
|
131
|
-
expect(
|
|
132
|
-
|
|
133
|
-
)
|
|
153
|
+
expect(stderr.some((l) => l.includes("Failed to fetch organization"))).toBe(
|
|
154
|
+
false,
|
|
155
|
+
);
|
|
156
|
+
expect(stdout.some((l) => l.includes("Failed to fetch organization"))).toBe(
|
|
157
|
+
false,
|
|
158
|
+
);
|
|
134
159
|
|
|
135
160
|
// Structural guarantee: we never even tried to talk to the platform.
|
|
136
161
|
expect(fetchCurrentUserMock).not.toHaveBeenCalled();
|
|
@@ -152,31 +177,84 @@ describe("vellum ps — platform status line", () => {
|
|
|
152
177
|
expect(stdout.filter((l) => l.startsWith("Platform:"))).toEqual([
|
|
153
178
|
"Platform: not logged in",
|
|
154
179
|
]);
|
|
155
|
-
expect(
|
|
156
|
-
|
|
157
|
-
)
|
|
158
|
-
expect(
|
|
159
|
-
|
|
160
|
-
)
|
|
161
|
-
expect(
|
|
162
|
-
stderr.some((l) => l.includes("Unable to connect")),
|
|
163
|
-
).toBe(false);
|
|
180
|
+
expect(stderr.some((l) => l.includes("Failed to fetch organization"))).toBe(
|
|
181
|
+
false,
|
|
182
|
+
);
|
|
183
|
+
expect(stdout.some((l) => l.includes("Failed to fetch organization"))).toBe(
|
|
184
|
+
false,
|
|
185
|
+
);
|
|
186
|
+
expect(stderr.some((l) => l.includes("Unable to connect"))).toBe(false);
|
|
164
187
|
});
|
|
165
188
|
|
|
166
189
|
test("local token present and platform reachable: prints 'Platform: logged in as <email>'", async () => {
|
|
167
190
|
readPlatformTokenMock.mockReturnValue("session_abc123");
|
|
168
191
|
fetchCurrentUserMock.mockResolvedValue({
|
|
169
192
|
id: "u1",
|
|
170
|
-
email: "
|
|
171
|
-
display: "
|
|
193
|
+
email: "user@example.com",
|
|
194
|
+
display: "Example User",
|
|
172
195
|
});
|
|
173
196
|
fetchPlatformAssistantsMock.mockResolvedValue([]);
|
|
174
197
|
|
|
175
198
|
await listAllAssistants(false);
|
|
176
199
|
|
|
177
|
-
expect(stdout).toContain("Platform: logged in as
|
|
178
|
-
expect(
|
|
179
|
-
|
|
180
|
-
)
|
|
200
|
+
expect(stdout).toContain("Platform: logged in as user@example.com");
|
|
201
|
+
expect(stderr.some((l) => l.includes("Failed to fetch organization"))).toBe(
|
|
202
|
+
false,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("assistant rows use display name as primary label and keep id visible", async () => {
|
|
207
|
+
loadAllAssistantsMock.mockReturnValue([
|
|
208
|
+
{
|
|
209
|
+
assistantId: "assistant-123",
|
|
210
|
+
name: "Alice",
|
|
211
|
+
runtimeUrl: "https://platform.example",
|
|
212
|
+
cloud: "vellum",
|
|
213
|
+
species: "vellum",
|
|
214
|
+
},
|
|
215
|
+
]);
|
|
216
|
+
getActiveAssistantMock.mockReturnValue("assistant-123");
|
|
217
|
+
|
|
218
|
+
await listAllAssistants(false);
|
|
219
|
+
|
|
220
|
+
const output = stdout.join("\n");
|
|
221
|
+
expect(output).toContain("* Alice");
|
|
222
|
+
expect(output).toContain("id: assistant-123");
|
|
223
|
+
expect(output).toContain("https://platform.example");
|
|
224
|
+
expect(output).not.toContain("* assistant-123");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("assistant list renders one stable final row per assistant", async () => {
|
|
228
|
+
loadAllAssistantsMock.mockReturnValue([
|
|
229
|
+
{
|
|
230
|
+
assistantId: "assistant-123",
|
|
231
|
+
name: "Alice",
|
|
232
|
+
runtimeUrl: "https://platform.example/a",
|
|
233
|
+
cloud: "vellum",
|
|
234
|
+
species: "vellum",
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
assistantId: "assistant-456",
|
|
238
|
+
name: "Bob",
|
|
239
|
+
runtimeUrl: "https://platform.example/b",
|
|
240
|
+
cloud: "vellum",
|
|
241
|
+
species: "vellum",
|
|
242
|
+
},
|
|
243
|
+
]);
|
|
244
|
+
getActiveAssistantMock.mockReturnValue("assistant-123");
|
|
245
|
+
checkManagedHealthMock.mockImplementation(async (_runtimeUrl, id) => ({
|
|
246
|
+
status: id === "assistant-123" ? "healthy" : "sleeping",
|
|
247
|
+
detail: null,
|
|
248
|
+
}));
|
|
249
|
+
|
|
250
|
+
await listAllAssistants(false);
|
|
251
|
+
|
|
252
|
+
const output = stdout.join("\n");
|
|
253
|
+
expect(output).not.toContain("\x1b[");
|
|
254
|
+
expect(output).not.toContain("checking...");
|
|
255
|
+
expect(output.match(/Alice/g)).toHaveLength(1);
|
|
256
|
+
expect(output.match(/Bob/g)).toHaveLength(1);
|
|
257
|
+
expect(output).toContain("healthy");
|
|
258
|
+
expect(output).toContain("sleeping");
|
|
181
259
|
});
|
|
182
260
|
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
mock,
|
|
8
|
+
spyOn,
|
|
9
|
+
test,
|
|
10
|
+
} from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
mkdirSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
22
|
+
import { loadAllAssistants } from "../lib/assistant-config.js";
|
|
23
|
+
import * as retireLocalModule from "../lib/retire-local.js";
|
|
24
|
+
|
|
25
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-retire-test-"));
|
|
26
|
+
const originalArgv = [...process.argv];
|
|
27
|
+
const originalExit = process.exit;
|
|
28
|
+
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
29
|
+
const originalStdinIsTTY = process.stdin.isTTY;
|
|
30
|
+
const originalStdoutIsTTY = process.stdout.isTTY;
|
|
31
|
+
const originalStdinIsRaw = process.stdin.isRaw;
|
|
32
|
+
const originalSetRawMode = process.stdin.setRawMode;
|
|
33
|
+
const originalStdoutWrite = process.stdout.write;
|
|
34
|
+
const realRetireLocalModule = { ...retireLocalModule };
|
|
35
|
+
|
|
36
|
+
const retireLocalMock = mock(async () => {});
|
|
37
|
+
|
|
38
|
+
mock.module("../lib/retire-local.js", () => ({
|
|
39
|
+
...realRetireLocalModule,
|
|
40
|
+
retireLocal: retireLocalMock,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { retire } from "../commands/retire.js";
|
|
44
|
+
|
|
45
|
+
let consoleLogSpy: ReturnType<typeof spyOn>;
|
|
46
|
+
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
|
47
|
+
|
|
48
|
+
function makeEntry(
|
|
49
|
+
assistantId: string,
|
|
50
|
+
extra: Partial<AssistantEntry> = {},
|
|
51
|
+
): AssistantEntry {
|
|
52
|
+
return {
|
|
53
|
+
assistantId,
|
|
54
|
+
runtimeUrl: `http://127.0.0.1:${7800 + assistantId.length}`,
|
|
55
|
+
cloud: "local",
|
|
56
|
+
resources: {
|
|
57
|
+
instanceDir: join(testDir, assistantId),
|
|
58
|
+
daemonPort: 7801,
|
|
59
|
+
gatewayPort: 7831,
|
|
60
|
+
qdrantPort: 6334,
|
|
61
|
+
cesPort: 7790,
|
|
62
|
+
},
|
|
63
|
+
...extra,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeLockfile(entries: AssistantEntry[]): void {
|
|
68
|
+
mkdirSync(testDir, { recursive: true });
|
|
69
|
+
writeFileSync(
|
|
70
|
+
join(testDir, ".vellum.lock.json"),
|
|
71
|
+
JSON.stringify({ assistants: entries }, null, 2) + "\n",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readLockfile(): string {
|
|
76
|
+
return readFileSync(join(testDir, ".vellum.lock.json"), "utf-8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function setTerminalMode(isTTY: boolean): void {
|
|
80
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
81
|
+
configurable: true,
|
|
82
|
+
value: isTTY,
|
|
83
|
+
});
|
|
84
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
85
|
+
configurable: true,
|
|
86
|
+
value: isTTY,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function setInteractiveTerminal(): void {
|
|
91
|
+
setTerminalMode(true);
|
|
92
|
+
Object.defineProperty(process.stdin, "isRaw", {
|
|
93
|
+
configurable: true,
|
|
94
|
+
value: false,
|
|
95
|
+
});
|
|
96
|
+
Object.defineProperty(process.stdin, "setRawMode", {
|
|
97
|
+
configurable: true,
|
|
98
|
+
value: mock(() => process.stdin),
|
|
99
|
+
});
|
|
100
|
+
process.stdout.write = (() => true) as typeof process.stdout.write;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function restoreTerminal(): void {
|
|
104
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
105
|
+
configurable: true,
|
|
106
|
+
value: originalStdinIsTTY,
|
|
107
|
+
});
|
|
108
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
109
|
+
configurable: true,
|
|
110
|
+
value: originalStdoutIsTTY,
|
|
111
|
+
});
|
|
112
|
+
Object.defineProperty(process.stdin, "isRaw", {
|
|
113
|
+
configurable: true,
|
|
114
|
+
value: originalStdinIsRaw,
|
|
115
|
+
});
|
|
116
|
+
Object.defineProperty(process.stdin, "setRawMode", {
|
|
117
|
+
configurable: true,
|
|
118
|
+
value: originalSetRawMode,
|
|
119
|
+
});
|
|
120
|
+
process.stdout.write = originalStdoutWrite;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
describe("vellum retire", () => {
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
126
|
+
rmSync(join(testDir, ".vellum.lock.json"), { force: true });
|
|
127
|
+
process.argv = ["bun", "vellum", "retire"];
|
|
128
|
+
process.exit = ((code?: number) => {
|
|
129
|
+
throw new Error(`process.exit:${code}`);
|
|
130
|
+
}) as typeof process.exit;
|
|
131
|
+
retireLocalMock.mockReset();
|
|
132
|
+
retireLocalMock.mockResolvedValue(undefined);
|
|
133
|
+
setTerminalMode(false);
|
|
134
|
+
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
135
|
+
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
process.argv = originalArgv;
|
|
140
|
+
process.exit = originalExit;
|
|
141
|
+
restoreTerminal();
|
|
142
|
+
consoleLogSpy.mockRestore();
|
|
143
|
+
consoleErrorSpy.mockRestore();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterAll(() => {
|
|
147
|
+
mock.module("../lib/retire-local.js", () => realRetireLocalModule);
|
|
148
|
+
if (originalLockfileDir === undefined) {
|
|
149
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
150
|
+
} else {
|
|
151
|
+
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
152
|
+
}
|
|
153
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("--yes retires by unquoted display name and removes by assistant ID", async () => {
|
|
157
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
158
|
+
writeLockfile([entry]);
|
|
159
|
+
process.argv = ["bun", "vellum", "retire", "Example", "Assistant", "--yes"];
|
|
160
|
+
|
|
161
|
+
await retire();
|
|
162
|
+
|
|
163
|
+
expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
|
|
164
|
+
expect(loadAllAssistants()).toEqual([]);
|
|
165
|
+
const output = consoleLogSpy.mock.calls.flat().join("\n");
|
|
166
|
+
expect(output).toContain("Name: Example Assistant");
|
|
167
|
+
expect(output).toContain("ID: assistant-1");
|
|
168
|
+
expect(output).toContain(
|
|
169
|
+
"Removed Example Assistant (assistant-1) from config.",
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("non-interactive retire without --yes fails before deleting", async () => {
|
|
174
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
175
|
+
writeLockfile([entry]);
|
|
176
|
+
const before = readLockfile();
|
|
177
|
+
process.argv = ["bun", "vellum", "retire", "Example", "Assistant"];
|
|
178
|
+
|
|
179
|
+
await expect(retire()).rejects.toThrow("process.exit:1");
|
|
180
|
+
|
|
181
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
182
|
+
expect(readLockfile()).toBe(before);
|
|
183
|
+
const output = consoleErrorSpy.mock.calls.flat().join("\n");
|
|
184
|
+
expect(output).toContain("Refusing to retire without confirmation");
|
|
185
|
+
expect(output).toContain("--yes");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("interactive cancel leaves the assistant untouched", async () => {
|
|
189
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
190
|
+
writeLockfile([entry]);
|
|
191
|
+
const before = readLockfile();
|
|
192
|
+
setInteractiveTerminal();
|
|
193
|
+
process.argv = ["bun", "vellum", "retire", "Example", "Assistant"];
|
|
194
|
+
|
|
195
|
+
const pending = retire();
|
|
196
|
+
queueMicrotask(() => {
|
|
197
|
+
process.stdin.emit("data", Buffer.from("q"));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await expect(pending).rejects.toThrow("process.exit:1");
|
|
201
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
202
|
+
expect(readLockfile()).toBe(before);
|
|
203
|
+
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain(
|
|
204
|
+
"Retire cancelled.",
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("interactive confirmation retires the assistant", async () => {
|
|
209
|
+
const entry = makeEntry("assistant-1", { name: "Example Assistant" });
|
|
210
|
+
writeLockfile([entry]);
|
|
211
|
+
setInteractiveTerminal();
|
|
212
|
+
process.argv = ["bun", "vellum", "retire", "Example", "Assistant"];
|
|
213
|
+
|
|
214
|
+
const pending = retire();
|
|
215
|
+
queueMicrotask(() => {
|
|
216
|
+
process.stdin.emit("data", Buffer.from([13]));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await pending;
|
|
220
|
+
expect(retireLocalMock).toHaveBeenCalledWith("assistant-1", entry);
|
|
221
|
+
expect(loadAllAssistants()).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("ambiguous display names fail before deleting", async () => {
|
|
225
|
+
writeLockfile([
|
|
226
|
+
makeEntry("assistant-1", { name: "Example Assistant" }),
|
|
227
|
+
makeEntry("assistant-2", { name: "Example Assistant" }),
|
|
228
|
+
]);
|
|
229
|
+
const before = readLockfile();
|
|
230
|
+
process.argv = ["bun", "vellum", "retire", "Example", "Assistant", "--yes"];
|
|
231
|
+
|
|
232
|
+
await expect(retire()).rejects.toThrow("process.exit:1");
|
|
233
|
+
|
|
234
|
+
expect(retireLocalMock).not.toHaveBeenCalled();
|
|
235
|
+
expect(readLockfile()).toBe(before);
|
|
236
|
+
const output = consoleErrorSpy.mock.calls.flat().join("\n");
|
|
237
|
+
expect(output).toContain("Multiple assistants match 'Example Assistant'");
|
|
238
|
+
expect(output).toContain("assistant-1");
|
|
239
|
+
expect(output).toContain("assistant-2");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
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 {
|
|
15
|
+
getActiveAssistant,
|
|
16
|
+
type AssistantEntry,
|
|
17
|
+
} from "../lib/assistant-config.js";
|
|
18
|
+
import { use } from "../commands/use.js";
|
|
19
|
+
|
|
20
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-use-test-"));
|
|
21
|
+
const originalArgv = [...process.argv];
|
|
22
|
+
const originalExit = process.exit;
|
|
23
|
+
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
24
|
+
|
|
25
|
+
let consoleLogSpy: ReturnType<typeof spyOn>;
|
|
26
|
+
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
|
27
|
+
|
|
28
|
+
function makeEntry(
|
|
29
|
+
assistantId: string,
|
|
30
|
+
extra: Partial<AssistantEntry> = {},
|
|
31
|
+
): AssistantEntry {
|
|
32
|
+
return {
|
|
33
|
+
assistantId,
|
|
34
|
+
runtimeUrl: `http://localhost:${7800 + assistantId.length}`,
|
|
35
|
+
cloud: "local",
|
|
36
|
+
...extra,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeLockfile(
|
|
41
|
+
entries: AssistantEntry[],
|
|
42
|
+
activeAssistant?: string,
|
|
43
|
+
): void {
|
|
44
|
+
mkdirSync(testDir, { recursive: true });
|
|
45
|
+
writeFileSync(
|
|
46
|
+
join(testDir, ".vellum.lock.json"),
|
|
47
|
+
JSON.stringify(
|
|
48
|
+
{
|
|
49
|
+
assistants: entries,
|
|
50
|
+
...(activeAssistant ? { activeAssistant } : {}),
|
|
51
|
+
},
|
|
52
|
+
null,
|
|
53
|
+
2,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("vellum use", () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
61
|
+
rmSync(join(testDir, ".vellum.lock.json"), { force: true });
|
|
62
|
+
process.argv = ["bun", "vellum", "use"];
|
|
63
|
+
process.exit = ((code?: number) => {
|
|
64
|
+
throw new Error(`process.exit:${code}`);
|
|
65
|
+
}) as typeof process.exit;
|
|
66
|
+
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
67
|
+
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
process.argv = originalArgv;
|
|
72
|
+
process.exit = originalExit;
|
|
73
|
+
consoleLogSpy.mockRestore();
|
|
74
|
+
consoleErrorSpy.mockRestore();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterAll(() => {
|
|
78
|
+
if (originalLockfileDir === undefined) {
|
|
79
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
80
|
+
} else {
|
|
81
|
+
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
82
|
+
}
|
|
83
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("sets active assistant by unique display name", async () => {
|
|
87
|
+
writeLockfile([
|
|
88
|
+
makeEntry("assistant-1", { name: "Alice" }),
|
|
89
|
+
makeEntry("assistant-2", { name: "Bob" }),
|
|
90
|
+
]);
|
|
91
|
+
process.argv = ["bun", "vellum", "use", "Alice"];
|
|
92
|
+
|
|
93
|
+
await use();
|
|
94
|
+
|
|
95
|
+
expect(getActiveAssistant()).toBe("assistant-1");
|
|
96
|
+
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain(
|
|
97
|
+
"Active assistant set to Alice (assistant-1).",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("sets active assistant by unquoted multi-word display name", async () => {
|
|
102
|
+
writeLockfile([
|
|
103
|
+
makeEntry("assistant-1", { name: "Example Assistant" }),
|
|
104
|
+
makeEntry("assistant-2", { name: "Bob" }),
|
|
105
|
+
]);
|
|
106
|
+
process.argv = ["bun", "vellum", "use", "Example", "Assistant"];
|
|
107
|
+
|
|
108
|
+
await use();
|
|
109
|
+
|
|
110
|
+
expect(getActiveAssistant()).toBe("assistant-1");
|
|
111
|
+
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain(
|
|
112
|
+
"Active assistant set to Example Assistant (assistant-1).",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("prints active assistant with display name and id", async () => {
|
|
117
|
+
writeLockfile([makeEntry("assistant-1", { name: "Alice" })], "assistant-1");
|
|
118
|
+
|
|
119
|
+
await use();
|
|
120
|
+
|
|
121
|
+
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain(
|
|
122
|
+
"Active assistant: Alice (assistant-1)",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("rejects ambiguous display names without changing active assistant", async () => {
|
|
127
|
+
writeLockfile(
|
|
128
|
+
[
|
|
129
|
+
makeEntry("assistant-1", { name: "Alice" }),
|
|
130
|
+
makeEntry("assistant-2", { name: "Alice" }),
|
|
131
|
+
],
|
|
132
|
+
"assistant-2",
|
|
133
|
+
);
|
|
134
|
+
process.argv = ["bun", "vellum", "use", "Alice"];
|
|
135
|
+
|
|
136
|
+
await expect(use()).rejects.toThrow("process.exit:1");
|
|
137
|
+
|
|
138
|
+
expect(getActiveAssistant()).toBe("assistant-2");
|
|
139
|
+
const output = consoleErrorSpy.mock.calls.flat().join("\n");
|
|
140
|
+
expect(output).toContain("Multiple assistants match 'Alice'");
|
|
141
|
+
expect(output).toContain("assistant-1");
|
|
142
|
+
expect(output).toContain("assistant-2");
|
|
143
|
+
});
|
|
144
|
+
});
|