@vellumai/cli 0.4.42 → 0.4.44

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.42",
3
+ "version": "0.4.44",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,11 +1,11 @@
1
1
  import { describe, test, expect, beforeEach, afterAll } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  // Point lockfile operations at a temp directory
7
7
  const testDir = mkdtempSync(join(tmpdir(), "cli-assistant-config-test-"));
8
- process.env.BASE_DATA_DIR = testDir;
8
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
9
9
 
10
10
  import {
11
11
  loadLatestAssistant,
@@ -13,12 +13,14 @@ import {
13
13
  removeAssistantEntry,
14
14
  loadAllAssistants,
15
15
  saveAssistantEntry,
16
+ getActiveAssistant,
17
+ migrateLegacyEntry,
16
18
  type AssistantEntry,
17
19
  } from "../lib/assistant-config.js";
18
20
 
19
21
  afterAll(() => {
20
22
  rmSync(testDir, { recursive: true, force: true });
21
- delete process.env.BASE_DATA_DIR;
23
+ delete process.env.VELLUM_LOCKFILE_DIR;
22
24
  });
23
25
 
24
26
  function writeLockfile(data: unknown): void {
@@ -100,6 +102,36 @@ describe("assistant-config", () => {
100
102
  expect(all.map((e) => e.assistantId)).toEqual(["a", "c"]);
101
103
  });
102
104
 
105
+ test("removeAssistantEntry reassigns activeAssistant to remaining entry", () => {
106
+ writeLockfile({
107
+ assistants: [makeEntry("a"), makeEntry("b")],
108
+ activeAssistant: "a",
109
+ });
110
+ removeAssistantEntry("a");
111
+ expect(getActiveAssistant()).toBe("b");
112
+ expect(loadAllAssistants()).toHaveLength(1);
113
+ });
114
+
115
+ test("removeAssistantEntry clears activeAssistant when no entries remain", () => {
116
+ writeLockfile({
117
+ assistants: [makeEntry("only")],
118
+ activeAssistant: "only",
119
+ });
120
+ removeAssistantEntry("only");
121
+ expect(getActiveAssistant()).toBeNull();
122
+ expect(loadAllAssistants()).toHaveLength(0);
123
+ });
124
+
125
+ test("removeAssistantEntry preserves activeAssistant when removing a different entry", () => {
126
+ writeLockfile({
127
+ assistants: [makeEntry("a"), makeEntry("b"), makeEntry("c")],
128
+ activeAssistant: "a",
129
+ });
130
+ removeAssistantEntry("b");
131
+ expect(getActiveAssistant()).toBe("a");
132
+ expect(loadAllAssistants()).toHaveLength(2);
133
+ });
134
+
103
135
  test("loadLatestAssistant returns null when empty", () => {
104
136
  expect(loadLatestAssistant()).toBeNull();
105
137
  });
@@ -149,3 +181,250 @@ describe("assistant-config", () => {
149
181
  expect(all[0].assistantId).toBe("valid");
150
182
  });
151
183
  });
184
+
185
+ describe("migrateLegacyEntry", () => {
186
+ test("rewrites baseDataDir as resources.instanceDir", () => {
187
+ /**
188
+ * Tests that a legacy entry with top-level baseDataDir gets migrated
189
+ * to the current resources.instanceDir format.
190
+ */
191
+
192
+ // GIVEN a legacy entry with baseDataDir set at the top level
193
+ const entry: Record<string, unknown> = {
194
+ assistantId: "my-assistant",
195
+ runtimeUrl: "http://localhost:7830",
196
+ cloud: "local",
197
+ baseDataDir: "/home/user/.local/share/vellum/assistants/my-assistant",
198
+ };
199
+
200
+ // WHEN we migrate the entry
201
+ const changed = migrateLegacyEntry(entry);
202
+
203
+ // THEN the entry should be mutated
204
+ expect(changed).toBe(true);
205
+
206
+ // AND baseDataDir should be removed
207
+ expect(entry.baseDataDir).toBeUndefined();
208
+
209
+ // AND resources.instanceDir should contain the old baseDataDir value
210
+ const resources = entry.resources as Record<string, unknown>;
211
+ expect(resources.instanceDir).toBe(
212
+ "/home/user/.local/share/vellum/assistants/my-assistant",
213
+ );
214
+ });
215
+
216
+ test("synthesises full resources when none exist", () => {
217
+ /**
218
+ * Tests that a legacy local entry with no resources object gets a
219
+ * complete resources object synthesised with default ports and pidFile.
220
+ */
221
+
222
+ // GIVEN a local entry with no resources
223
+ const entry: Record<string, unknown> = {
224
+ assistantId: "old-assistant",
225
+ runtimeUrl: "http://localhost:7830",
226
+ cloud: "local",
227
+ };
228
+
229
+ // WHEN we migrate the entry
230
+ const changed = migrateLegacyEntry(entry);
231
+
232
+ // THEN the entry should be mutated
233
+ expect(changed).toBe(true);
234
+
235
+ // AND resources should be fully populated
236
+ const resources = entry.resources as Record<string, unknown>;
237
+ expect(resources.instanceDir).toContain("old-assistant");
238
+ expect(resources.daemonPort).toBe(7821);
239
+ expect(resources.gatewayPort).toBe(7830);
240
+ expect(resources.qdrantPort).toBe(6333);
241
+ expect(resources.pidFile).toContain("vellum.pid");
242
+ });
243
+
244
+ test("infers gateway port from runtimeUrl", () => {
245
+ /**
246
+ * Tests that the gateway port is extracted from the runtimeUrl when
247
+ * synthesising resources for a legacy entry.
248
+ */
249
+
250
+ // GIVEN a local entry with a non-default gateway port in the runtimeUrl
251
+ const entry: Record<string, unknown> = {
252
+ assistantId: "custom-port",
253
+ runtimeUrl: "http://localhost:9999",
254
+ cloud: "local",
255
+ };
256
+
257
+ // WHEN we migrate the entry
258
+ migrateLegacyEntry(entry);
259
+
260
+ // THEN the gateway port should match the runtimeUrl port
261
+ const resources = entry.resources as Record<string, unknown>;
262
+ expect(resources.gatewayPort).toBe(9999);
263
+ });
264
+
265
+ test("skips non-local entries", () => {
266
+ /**
267
+ * Tests that remote (non-local) entries are left untouched.
268
+ */
269
+
270
+ // GIVEN a GCP entry without resources
271
+ const entry: Record<string, unknown> = {
272
+ assistantId: "gcp-assistant",
273
+ runtimeUrl: "https://example.com",
274
+ cloud: "gcp",
275
+ };
276
+
277
+ // WHEN we attempt to migrate it
278
+ const changed = migrateLegacyEntry(entry);
279
+
280
+ // THEN nothing should change
281
+ expect(changed).toBe(false);
282
+ expect(entry.resources).toBeUndefined();
283
+ });
284
+
285
+ test("backfills missing fields on partial resources", () => {
286
+ /**
287
+ * Tests that an entry with a partial resources object (e.g. only
288
+ * instanceDir) gets the remaining fields backfilled.
289
+ */
290
+
291
+ // GIVEN an entry with partial resources (only instanceDir)
292
+ const entry: Record<string, unknown> = {
293
+ assistantId: "partial",
294
+ runtimeUrl: "http://localhost:7830",
295
+ cloud: "local",
296
+ resources: {
297
+ instanceDir: "/custom/path",
298
+ },
299
+ };
300
+
301
+ // WHEN we migrate the entry
302
+ const changed = migrateLegacyEntry(entry);
303
+
304
+ // THEN the entry should be mutated
305
+ expect(changed).toBe(true);
306
+
307
+ // AND all missing resources fields should be filled in
308
+ const resources = entry.resources as Record<string, unknown>;
309
+ expect(resources.instanceDir).toBe("/custom/path");
310
+ expect(resources.daemonPort).toBe(7821);
311
+ expect(resources.gatewayPort).toBe(7830);
312
+ expect(resources.qdrantPort).toBe(6333);
313
+ expect(resources.pidFile).toBe("/custom/path/.vellum/vellum.pid");
314
+ });
315
+
316
+ test("does not overwrite existing resources fields", () => {
317
+ /**
318
+ * Tests that an entry with a complete resources object is left untouched.
319
+ */
320
+
321
+ // GIVEN an entry with a complete resources object
322
+ const entry: Record<string, unknown> = {
323
+ assistantId: "complete",
324
+ runtimeUrl: "http://localhost:7830",
325
+ cloud: "local",
326
+ resources: {
327
+ instanceDir: "/my/path",
328
+ daemonPort: 8000,
329
+ gatewayPort: 8001,
330
+ qdrantPort: 8002,
331
+ pidFile: "/my/path/.vellum/vellum.pid",
332
+ },
333
+ };
334
+
335
+ // WHEN we migrate the entry
336
+ const changed = migrateLegacyEntry(entry);
337
+
338
+ // THEN nothing should change
339
+ expect(changed).toBe(false);
340
+
341
+ // AND existing values should be preserved
342
+ const resources = entry.resources as Record<string, unknown>;
343
+ expect(resources.daemonPort).toBe(8000);
344
+ expect(resources.gatewayPort).toBe(8001);
345
+ expect(resources.qdrantPort).toBe(8002);
346
+ });
347
+
348
+ test("baseDataDir does not overwrite existing resources.instanceDir", () => {
349
+ /**
350
+ * Tests that when both baseDataDir and resources.instanceDir exist,
351
+ * the existing instanceDir is preserved and baseDataDir is removed.
352
+ */
353
+
354
+ // GIVEN an entry with both baseDataDir and resources.instanceDir
355
+ const entry: Record<string, unknown> = {
356
+ assistantId: "conflict",
357
+ runtimeUrl: "http://localhost:7830",
358
+ cloud: "local",
359
+ baseDataDir: "/old/path",
360
+ resources: {
361
+ instanceDir: "/new/path",
362
+ daemonPort: 7821,
363
+ gatewayPort: 7830,
364
+ qdrantPort: 6333,
365
+ pidFile: "/new/path/.vellum/vellum.pid",
366
+ },
367
+ };
368
+
369
+ // WHEN we migrate the entry
370
+ const changed = migrateLegacyEntry(entry);
371
+
372
+ // THEN baseDataDir should be removed
373
+ expect(changed).toBe(true);
374
+ expect(entry.baseDataDir).toBeUndefined();
375
+
376
+ // AND the existing instanceDir should be preserved
377
+ const resources = entry.resources as Record<string, unknown>;
378
+ expect(resources.instanceDir).toBe("/new/path");
379
+ });
380
+ });
381
+
382
+ describe("legacy migration via loadAllAssistants", () => {
383
+ beforeEach(() => {
384
+ try {
385
+ rmSync(join(testDir, ".vellum.lock.json"));
386
+ } catch {
387
+ // file may not exist
388
+ }
389
+ });
390
+
391
+ test("migrates legacy entries and persists to disk on read", () => {
392
+ /**
393
+ * Tests that reading assistants from a lockfile with legacy entries
394
+ * triggers migration and persists the updated format to disk.
395
+ */
396
+
397
+ // GIVEN a lockfile with a legacy entry containing baseDataDir
398
+ writeLockfile({
399
+ assistants: [
400
+ {
401
+ assistantId: "legacy-bot",
402
+ runtimeUrl: "http://localhost:7830",
403
+ cloud: "local",
404
+ baseDataDir: "/home/user/.local/share/vellum/assistants/legacy-bot",
405
+ },
406
+ ],
407
+ });
408
+
409
+ // WHEN we load assistants
410
+ const all = loadAllAssistants();
411
+
412
+ // THEN the entry should have resources populated
413
+ expect(all).toHaveLength(1);
414
+ expect(all[0].resources).toBeDefined();
415
+ expect(all[0].resources!.instanceDir).toBe(
416
+ "/home/user/.local/share/vellum/assistants/legacy-bot",
417
+ );
418
+ expect(all[0].resources!.gatewayPort).toBe(7830);
419
+
420
+ // AND the lockfile on disk should reflect the migration
421
+ const rawDisk = JSON.parse(
422
+ readFileSync(join(testDir, ".vellum.lock.json"), "utf-8"),
423
+ );
424
+ const diskEntry = rawDisk.assistants[0];
425
+ expect(diskEntry.baseDataDir).toBeUndefined();
426
+ expect(diskEntry.resources.instanceDir).toBe(
427
+ "/home/user/.local/share/vellum/assistants/legacy-bot",
428
+ );
429
+ });
430
+ });
@@ -4,9 +4,9 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  // Create a temp directory that acts as a fake home, so allocateLocalResources()
7
- // and defaultLocalResources() never touch the real ~/.vellum directory.
7
+ // never touches the real ~/.vellum directory.
8
8
  const testDir = mkdtempSync(join(tmpdir(), "cli-multi-local-test-"));
9
- process.env.BASE_DATA_DIR = testDir;
9
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
10
10
 
11
11
  // Mock homedir() to return testDir — this isolates allocateLocalResources()
12
12
  // which uses homedir() directly for instance directory creation.
@@ -31,7 +31,6 @@ mock.module("../lib/port-probe.js", () => ({
31
31
 
32
32
  import {
33
33
  allocateLocalResources,
34
- defaultLocalResources,
35
34
  resolveTargetAssistant,
36
35
  setActiveAssistant,
37
36
  getActiveAssistant,
@@ -47,7 +46,7 @@ import {
47
46
 
48
47
  afterAll(() => {
49
48
  rmSync(testDir, { recursive: true, force: true });
50
- delete process.env.BASE_DATA_DIR;
49
+ delete process.env.VELLUM_LOCKFILE_DIR;
51
50
  });
52
51
 
53
52
  function writeLockfile(data: unknown): void {
@@ -95,17 +94,22 @@ describe("multi-local", () => {
95
94
  });
96
95
 
97
96
  describe("allocateLocalResources() produces non-conflicting ports", () => {
98
- test("first instance returns default legacy resources", async () => {
97
+ test("first instance gets home directory and default ports", async () => {
99
98
  // GIVEN no local assistants exist in the lockfile
100
99
 
101
100
  // WHEN we allocate resources for the first instance
102
101
  const res = await allocateLocalResources("instance-a");
103
102
 
104
- // THEN it returns the default legacy layout (home dir, default ports)
103
+ // THEN it gets the home directory as its instance root
105
104
  expect(res.instanceDir).toBe(testDir);
105
+
106
+ // AND it gets the default ports since no other instances exist
106
107
  expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
107
108
  expect(res.gatewayPort).toBe(DEFAULT_GATEWAY_PORT);
108
109
  expect(res.qdrantPort).toBe(DEFAULT_QDRANT_PORT);
110
+
111
+ // AND the PID file is under ~/.vellum/
112
+ expect(res.pidFile).toBe(join(testDir, ".vellum", "vellum.pid"));
109
113
  });
110
114
 
111
115
  test("second instance gets distinct ports and dir when first instance is saved", async () => {
@@ -156,18 +160,6 @@ describe("multi-local", () => {
156
160
  });
157
161
  });
158
162
 
159
- describe("defaultLocalResources() returns legacy paths", () => {
160
- test("instanceDir is homedir", () => {
161
- const res = defaultLocalResources();
162
- expect(res.instanceDir).toBe(testDir);
163
- });
164
-
165
- test("daemonPort is DEFAULT_DAEMON_PORT", () => {
166
- const res = defaultLocalResources();
167
- expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
168
- });
169
- });
170
-
171
163
  describe("resolveTargetAssistant() priority chain", () => {
172
164
  test("explicit name returns that entry", () => {
173
165
  writeLockfile({
@@ -243,14 +235,14 @@ describe("multi-local", () => {
243
235
  });
244
236
  });
245
237
 
246
- describe("removeAssistantEntry() clears matching activeAssistant", () => {
247
- test("set active to foo, remove foo, verify active is null", () => {
238
+ describe("removeAssistantEntry() reassigns activeAssistant on removal", () => {
239
+ test("set active to foo, remove foo, verify active is reassigned to bar", () => {
248
240
  writeLockfile({
249
241
  assistants: [makeEntry("foo"), makeEntry("bar")],
250
242
  activeAssistant: "foo",
251
243
  });
252
244
  removeAssistantEntry("foo");
253
- expect(getActiveAssistant()).toBeNull();
245
+ expect(getActiveAssistant()).toBe("bar");
254
246
  });
255
247
 
256
248
  test("set active to foo, remove bar, verify active is still foo", () => {
@@ -0,0 +1,172 @@
1
+ import {
2
+ afterAll,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ mock,
7
+ spyOn,
8
+ test,
9
+ } from "bun:test";
10
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ // Create a temp directory and set VELLUM_LOCKFILE_DIR so the real
15
+ // assistant-config module reads/writes the lockfile here instead of ~/.
16
+ const testDir = mkdtempSync(join(tmpdir(), "sleep-command-test-"));
17
+ const assistantRootDir = join(testDir, ".vellum");
18
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
19
+
20
+ const stopProcessByPidFileMock = mock(async () => true);
21
+ const isProcessAliveMock = mock((): { alive: boolean; pid: number | null } => ({
22
+ alive: false,
23
+ pid: null,
24
+ }));
25
+
26
+ mock.module("../lib/process.js", () => ({
27
+ isProcessAlive: isProcessAliveMock,
28
+ stopProcessByPidFile: stopProcessByPidFileMock,
29
+ }));
30
+
31
+ import { sleep } from "../commands/sleep.js";
32
+ import {
33
+ DEFAULT_DAEMON_PORT,
34
+ DEFAULT_GATEWAY_PORT,
35
+ DEFAULT_QDRANT_PORT,
36
+ } from "../lib/constants.js";
37
+
38
+ // Write a lockfile entry so the real resolveTargetAssistant() finds our test
39
+ // assistant without needing to mock the entire assistant-config module.
40
+ function writeLockfile(): void {
41
+ writeFileSync(
42
+ join(testDir, ".vellum.lock.json"),
43
+ JSON.stringify(
44
+ {
45
+ assistants: [
46
+ {
47
+ assistantId: "sleep-test",
48
+ runtimeUrl: `http://127.0.0.1:${DEFAULT_DAEMON_PORT}`,
49
+ cloud: "local",
50
+ resources: {
51
+ instanceDir: testDir,
52
+ daemonPort: DEFAULT_DAEMON_PORT,
53
+ gatewayPort: DEFAULT_GATEWAY_PORT,
54
+ qdrantPort: DEFAULT_QDRANT_PORT,
55
+ pidFile: join(assistantRootDir, "vellum.pid"),
56
+ },
57
+ },
58
+ ],
59
+ activeAssistant: "sleep-test",
60
+ },
61
+ null,
62
+ 2,
63
+ ),
64
+ );
65
+ }
66
+
67
+ function writeLeaseFile(callSessionIds: string[]): void {
68
+ mkdirSync(assistantRootDir, { recursive: true });
69
+ writeFileSync(
70
+ join(assistantRootDir, "active-call-leases.json"),
71
+ JSON.stringify(
72
+ {
73
+ version: 1,
74
+ leases: callSessionIds.map((callSessionId) => ({
75
+ callSessionId,
76
+ providerCallSid: null,
77
+ updatedAt: Date.now(),
78
+ })),
79
+ },
80
+ null,
81
+ 2,
82
+ ),
83
+ );
84
+ }
85
+
86
+ describe("sleep command", () => {
87
+ let originalArgv: string[];
88
+
89
+ beforeEach(() => {
90
+ originalArgv = [...process.argv];
91
+ isProcessAliveMock.mockReset();
92
+ isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
93
+ stopProcessByPidFileMock.mockReset();
94
+ stopProcessByPidFileMock.mockResolvedValue(true);
95
+ rmSync(assistantRootDir, { recursive: true, force: true });
96
+ writeLockfile();
97
+ });
98
+
99
+ afterAll(() => {
100
+ process.argv = originalArgv;
101
+ rmSync(testDir, { recursive: true, force: true });
102
+ delete process.env.VELLUM_LOCKFILE_DIR;
103
+ });
104
+
105
+ test("refuses normal sleep while an active call lease exists", async () => {
106
+ isProcessAliveMock.mockReturnValue({ alive: true, pid: 12345 });
107
+ writeLeaseFile(["call-active-1", "call-active-2"]);
108
+ process.argv = ["bun", "vellum", "sleep", "sleep-test"];
109
+
110
+ const consoleError = spyOn(console, "error").mockImplementation(() => {});
111
+ const exitMock = mock((code?: number) => {
112
+ throw new Error(`process.exit:${code}`);
113
+ });
114
+ const originalExit = process.exit;
115
+ process.exit = exitMock as unknown as typeof process.exit;
116
+
117
+ try {
118
+ await expect(sleep()).rejects.toThrow("process.exit:1");
119
+ expect(consoleError).toHaveBeenCalledWith(
120
+ expect.stringContaining("vellum sleep --force"),
121
+ );
122
+ } finally {
123
+ process.exit = originalExit;
124
+ consoleError.mockRestore();
125
+ }
126
+
127
+ expect(stopProcessByPidFileMock).not.toHaveBeenCalled();
128
+ });
129
+
130
+ test("proceeds when assistant is not running even with stale lease file", async () => {
131
+ isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
132
+ writeLeaseFile(["call-stale-1"]);
133
+ process.argv = ["bun", "vellum", "sleep", "sleep-test"];
134
+
135
+ const consoleLog = spyOn(console, "log").mockImplementation(() => {});
136
+
137
+ try {
138
+ await sleep();
139
+ } finally {
140
+ consoleLog.mockRestore();
141
+ }
142
+
143
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
144
+ });
145
+
146
+ test("force stops the assistant even when an active call lease exists", async () => {
147
+ writeLeaseFile(["call-active-1"]);
148
+ process.argv = ["bun", "vellum", "sleep", "sleep-test", "--force"];
149
+
150
+ const consoleLog = spyOn(console, "log").mockImplementation(() => {});
151
+
152
+ try {
153
+ await sleep();
154
+ } finally {
155
+ consoleLog.mockRestore();
156
+ }
157
+
158
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
159
+ expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
160
+ 1,
161
+ join(assistantRootDir, "vellum.pid"),
162
+ "assistant",
163
+ );
164
+ expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
165
+ 2,
166
+ join(assistantRootDir, "gateway.pid"),
167
+ "gateway",
168
+ undefined,
169
+ 7000,
170
+ );
171
+ });
172
+ });