@vellumai/cli 0.4.41 → 0.4.43

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.41",
3
+ "version": "0.4.43",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,7 +5,7 @@ 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,13 @@ import {
13
13
  removeAssistantEntry,
14
14
  loadAllAssistants,
15
15
  saveAssistantEntry,
16
+ getActiveAssistant,
16
17
  type AssistantEntry,
17
18
  } from "../lib/assistant-config.js";
18
19
 
19
20
  afterAll(() => {
20
21
  rmSync(testDir, { recursive: true, force: true });
21
- delete process.env.BASE_DATA_DIR;
22
+ delete process.env.VELLUM_LOCKFILE_DIR;
22
23
  });
23
24
 
24
25
  function writeLockfile(data: unknown): void {
@@ -100,6 +101,36 @@ describe("assistant-config", () => {
100
101
  expect(all.map((e) => e.assistantId)).toEqual(["a", "c"]);
101
102
  });
102
103
 
104
+ test("removeAssistantEntry reassigns activeAssistant to remaining entry", () => {
105
+ writeLockfile({
106
+ assistants: [makeEntry("a"), makeEntry("b")],
107
+ activeAssistant: "a",
108
+ });
109
+ removeAssistantEntry("a");
110
+ expect(getActiveAssistant()).toBe("b");
111
+ expect(loadAllAssistants()).toHaveLength(1);
112
+ });
113
+
114
+ test("removeAssistantEntry clears activeAssistant when no entries remain", () => {
115
+ writeLockfile({
116
+ assistants: [makeEntry("only")],
117
+ activeAssistant: "only",
118
+ });
119
+ removeAssistantEntry("only");
120
+ expect(getActiveAssistant()).toBeNull();
121
+ expect(loadAllAssistants()).toHaveLength(0);
122
+ });
123
+
124
+ test("removeAssistantEntry preserves activeAssistant when removing a different entry", () => {
125
+ writeLockfile({
126
+ assistants: [makeEntry("a"), makeEntry("b"), makeEntry("c")],
127
+ activeAssistant: "a",
128
+ });
129
+ removeAssistantEntry("b");
130
+ expect(getActiveAssistant()).toBe("a");
131
+ expect(loadAllAssistants()).toHaveLength(2);
132
+ });
133
+
103
134
  test("loadLatestAssistant returns null when empty", () => {
104
135
  expect(loadLatestAssistant()).toBeNull();
105
136
  });
@@ -23,6 +23,7 @@ describe("constants", () => {
23
23
  expect(VALID_REMOTE_HOSTS).toContain("local");
24
24
  expect(VALID_REMOTE_HOSTS).toContain("gcp");
25
25
  expect(VALID_REMOTE_HOSTS).toContain("aws");
26
+ expect(VALID_REMOTE_HOSTS).toContain("docker");
26
27
  expect(VALID_REMOTE_HOSTS).toContain("custom");
27
28
  });
28
29
 
@@ -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,
@@ -39,11 +38,15 @@ import {
39
38
  saveAssistantEntry,
40
39
  type AssistantEntry,
41
40
  } from "../lib/assistant-config.js";
42
- import { DEFAULT_DAEMON_PORT } from "../lib/constants.js";
41
+ import {
42
+ DEFAULT_DAEMON_PORT,
43
+ DEFAULT_GATEWAY_PORT,
44
+ DEFAULT_QDRANT_PORT,
45
+ } from "../lib/constants.js";
43
46
 
44
47
  afterAll(() => {
45
48
  rmSync(testDir, { recursive: true, force: true });
46
- delete process.env.BASE_DATA_DIR;
49
+ delete process.env.VELLUM_LOCKFILE_DIR;
47
50
  });
48
51
 
49
52
  function writeLockfile(data: unknown): void {
@@ -91,40 +94,54 @@ describe("multi-local", () => {
91
94
  });
92
95
 
93
96
  describe("allocateLocalResources() produces non-conflicting ports", () => {
94
- test("two instances get distinct ports and dirs when first instance ports are occupied", async () => {
95
- // After the first allocation grabs its ports, simulate those ports
96
- // being in-use so the second allocation must pick different ones.
97
- const a = await allocateLocalResources("instance-a");
97
+ test("first instance gets XDG path and default ports", async () => {
98
+ // GIVEN no local assistants exist in the lockfile
99
+
100
+ // WHEN we allocate resources for the first instance
101
+ const res = await allocateLocalResources("instance-a");
102
+
103
+ // THEN it gets an XDG instance directory under the home dir
104
+ expect(res.instanceDir).toBe(
105
+ join(testDir, ".local", "share", "vellum", "assistants", "instance-a"),
106
+ );
107
+
108
+ // AND it gets the default ports since no other instances exist
109
+ expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
110
+ expect(res.gatewayPort).toBe(DEFAULT_GATEWAY_PORT);
111
+ expect(res.qdrantPort).toBe(DEFAULT_QDRANT_PORT);
112
+ });
113
+
114
+ test("second instance gets distinct ports and dir when first instance is saved", async () => {
115
+ // GIVEN a first local assistant already exists in the lockfile
116
+ saveAssistantEntry(makeEntry("instance-a"));
117
+
118
+ // AND the default ports are occupied
98
119
  const occupiedPorts = new Set([
99
- a.daemonPort,
100
- a.gatewayPort,
101
- a.qdrantPort,
120
+ DEFAULT_DAEMON_PORT,
121
+ DEFAULT_GATEWAY_PORT,
122
+ DEFAULT_QDRANT_PORT,
102
123
  ]);
103
124
  probePortMock.mockImplementation((port: number) =>
104
125
  Promise.resolve(occupiedPorts.has(port)),
105
126
  );
106
127
 
128
+ // WHEN we allocate resources for a second instance
107
129
  const b = await allocateLocalResources("instance-b");
108
130
 
109
- // All six ports must be unique across both instances
110
- const allPorts = [
111
- a.daemonPort,
112
- a.gatewayPort,
113
- a.qdrantPort,
114
- b.daemonPort,
115
- b.gatewayPort,
116
- b.qdrantPort,
117
- ];
118
- expect(new Set(allPorts).size).toBe(6);
119
-
120
- // Instance dirs must be distinct
121
- expect(a.instanceDir).not.toBe(b.instanceDir);
122
- expect(a.instanceDir).toContain("instance-a");
131
+ // THEN the second instance gets non-default ports
132
+ expect(occupiedPorts.has(b.daemonPort)).toBe(false);
133
+ expect(occupiedPorts.has(b.gatewayPort)).toBe(false);
134
+ expect(occupiedPorts.has(b.qdrantPort)).toBe(false);
135
+
136
+ // AND it gets its own dedicated instance directory
123
137
  expect(b.instanceDir).toContain("instance-b");
124
138
  });
125
139
 
126
140
  test("skips ports that probePort reports as in-use", async () => {
127
- // Simulate the default ports being occupied
141
+ // GIVEN a first local assistant already exists in the lockfile
142
+ saveAssistantEntry(makeEntry("existing"));
143
+
144
+ // AND the default daemon ports are occupied
128
145
  const portsInUse = new Set([
129
146
  DEFAULT_DAEMON_PORT,
130
147
  DEFAULT_DAEMON_PORT + 1,
@@ -133,24 +150,15 @@ describe("multi-local", () => {
133
150
  Promise.resolve(portsInUse.has(port)),
134
151
  );
135
152
 
153
+ // WHEN we allocate resources for a new instance
136
154
  const res = await allocateLocalResources("probe-test");
155
+
156
+ // THEN the daemon port skips all occupied ports
137
157
  expect(res.daemonPort).toBeGreaterThan(DEFAULT_DAEMON_PORT + 1);
138
158
  expect(portsInUse.has(res.daemonPort)).toBe(false);
139
159
  });
140
160
  });
141
161
 
142
- describe("defaultLocalResources() returns legacy paths", () => {
143
- test("instanceDir is homedir", () => {
144
- const res = defaultLocalResources();
145
- expect(res.instanceDir).toBe(testDir);
146
- });
147
-
148
- test("daemonPort is DEFAULT_DAEMON_PORT", () => {
149
- const res = defaultLocalResources();
150
- expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
151
- });
152
- });
153
-
154
162
  describe("resolveTargetAssistant() priority chain", () => {
155
163
  test("explicit name returns that entry", () => {
156
164
  writeLockfile({
@@ -226,14 +234,14 @@ describe("multi-local", () => {
226
234
  });
227
235
  });
228
236
 
229
- describe("removeAssistantEntry() clears matching activeAssistant", () => {
230
- test("set active to foo, remove foo, verify active is null", () => {
237
+ describe("removeAssistantEntry() reassigns activeAssistant on removal", () => {
238
+ test("set active to foo, remove foo, verify active is reassigned to bar", () => {
231
239
  writeLockfile({
232
240
  assistants: [makeEntry("foo"), makeEntry("bar")],
233
241
  activeAssistant: "foo",
234
242
  });
235
243
  removeAssistantEntry("foo");
236
- expect(getActiveAssistant()).toBeNull();
244
+ expect(getActiveAssistant()).toBe("bar");
237
245
  });
238
246
 
239
247
  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
+ });
@@ -1,9 +1,12 @@
1
+ import { hostname } from "os";
2
+
1
3
  import {
2
4
  findAssistantByName,
3
5
  getActiveAssistant,
4
6
  loadLatestAssistant,
5
7
  } from "../lib/assistant-config";
6
8
  import { GATEWAY_PORT, type Species } from "../lib/constants";
9
+ import { getLocalLanIPv4, getMacLocalHostname } from "../lib/local";
7
10
 
8
11
  const ANSI = {
9
12
  reset: "\x1b[0m",
@@ -46,7 +49,7 @@ function parseArgs(): ParsedArgs {
46
49
  }
47
50
  }
48
51
 
49
- let entry: ReturnType<typeof findAssistantByName>;
52
+ let entry: ReturnType<typeof findAssistantByName> = null;
50
53
  if (positionalName) {
51
54
  entry = findAssistantByName(positionalName);
52
55
  if (!entry) {
@@ -55,15 +58,30 @@ function parseArgs(): ParsedArgs {
55
58
  );
56
59
  process.exit(1);
57
60
  }
58
- } else if (process.env.RUNTIME_URL) {
59
- // Explicit env var — skip assistant resolution, will use env values below
60
- entry = loadLatestAssistant();
61
61
  } else {
62
- // Respect active assistant when set, otherwise fall back to latest
63
- // for backward compatibility with remote-only setups.
62
+ const hasExplicitUrl =
63
+ process.env.RUNTIME_URL ||
64
+ flagArgs.includes("--url") ||
65
+ flagArgs.includes("-u");
64
66
  const active = getActiveAssistant();
65
- const activeEntry = active ? findAssistantByName(active) : null;
66
- entry = activeEntry ?? loadLatestAssistant();
67
+ if (active) {
68
+ entry = findAssistantByName(active);
69
+ if (!entry && !hasExplicitUrl) {
70
+ console.error(
71
+ `Active assistant '${active}' not found in lockfile. Set an active assistant with 'vellum use <name>'.`,
72
+ );
73
+ process.exit(1);
74
+ }
75
+ }
76
+ if (!entry && hasExplicitUrl) {
77
+ // URL provided but active assistant missing or unset — use latest for remaining defaults
78
+ entry = loadLatestAssistant();
79
+ } else if (!entry) {
80
+ console.error(
81
+ "No active assistant set. Set one with 'vellum use <name>' or specify a name: 'vellum client <name>'.",
82
+ );
83
+ process.exit(1);
84
+ }
67
85
  }
68
86
 
69
87
  let runtimeUrl =
@@ -90,7 +108,7 @@ function parseArgs(): ParsedArgs {
90
108
  }
91
109
 
92
110
  return {
93
- runtimeUrl: runtimeUrl.replace(/\/+$/, ""),
111
+ runtimeUrl: maybeSwapToLocalhost(runtimeUrl.replace(/\/+$/, "")),
94
112
  assistantId,
95
113
  species,
96
114
  bearerToken,
@@ -99,6 +117,50 @@ function parseArgs(): ParsedArgs {
99
117
  };
100
118
  }
101
119
 
120
+ /**
121
+ * If the hostname in `url` matches this machine's local DNS name, LAN IP, or
122
+ * raw hostname, replace it with 127.0.0.1 so the client avoids mDNS round-trips
123
+ * when talking to an assistant running on the same machine.
124
+ */
125
+ function maybeSwapToLocalhost(url: string): string {
126
+ let parsed: URL;
127
+ try {
128
+ parsed = new URL(url);
129
+ } catch {
130
+ return url;
131
+ }
132
+
133
+ const urlHost = parsed.hostname.toLowerCase();
134
+
135
+ const localNames: string[] = [];
136
+
137
+ const host = hostname();
138
+ if (host) {
139
+ localNames.push(host.toLowerCase());
140
+ // Also consider the bare name without .local suffix
141
+ if (host.toLowerCase().endsWith(".local")) {
142
+ localNames.push(host.toLowerCase().slice(0, -".local".length));
143
+ }
144
+ }
145
+
146
+ const macHost = getMacLocalHostname();
147
+ if (macHost) {
148
+ localNames.push(macHost.toLowerCase());
149
+ }
150
+
151
+ const lanIp = getLocalLanIPv4();
152
+ if (lanIp) {
153
+ localNames.push(lanIp);
154
+ }
155
+
156
+ if (localNames.includes(urlHost)) {
157
+ parsed.hostname = "127.0.0.1";
158
+ return parsed.toString().replace(/\/+$/, "");
159
+ }
160
+
161
+ return url;
162
+ }
163
+
102
164
  function printUsage(): void {
103
165
  console.log(`${ANSI.bold}vellum client${ANSI.reset} - Connect to a hatched assistant
104
166
 
@@ -106,7 +168,7 @@ ${ANSI.bold}USAGE:${ANSI.reset}
106
168
  vellum client [name] [options]
107
169
 
108
170
  ${ANSI.bold}ARGUMENTS:${ANSI.reset}
109
- [name] Instance name (default: latest)
171
+ [name] Instance name (default: active)
110
172
 
111
173
  ${ANSI.bold}OPTIONS:${ANSI.reset}
112
174
  -u, --url <url> Runtime URL