@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- spyOn(guardianToken, "loadGuardianToken").mockReturnValue({
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(guardianToken, "leaseGuardianToken").mockResolvedValue({
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
+ });
@@ -0,0 +1,287 @@
1
+ import { describe, test, expect, beforeEach, afterAll } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Point lockfile operations at a temp directory before importing anything that
7
+ // would otherwise resolve real on-host paths.
8
+ const testDir = mkdtempSync(join(tmpdir(), "cli-orphan-detection-test-"));
9
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
10
+
11
+ import {
12
+ detectOrphanedProcesses,
13
+ getKnownPidsFromAssistants,
14
+ } from "../lib/orphan-detection.js";
15
+ import {
16
+ loadAllAssistantsAcrossEnvs,
17
+ type AssistantEntry,
18
+ } from "../lib/assistant-config.js";
19
+ import type { EnvironmentDefinition } from "../lib/environments/types.js";
20
+
21
+ afterAll(() => {
22
+ rmSync(testDir, { recursive: true, force: true });
23
+ delete process.env.VELLUM_LOCKFILE_DIR;
24
+ });
25
+
26
+ function makeLocalEntry(
27
+ id: string,
28
+ instanceDir: string,
29
+ pids: {
30
+ daemon?: string;
31
+ gateway?: string;
32
+ qdrant?: string;
33
+ embed?: string;
34
+ } = {},
35
+ ): AssistantEntry {
36
+ const vellumDir = join(instanceDir, ".vellum");
37
+ mkdirSync(join(vellumDir, "workspace", "data", "qdrant"), {
38
+ recursive: true,
39
+ });
40
+ if (pids.daemon !== undefined) {
41
+ writeFileSync(join(vellumDir, "workspace", "vellum.pid"), pids.daemon);
42
+ }
43
+ if (pids.gateway !== undefined) {
44
+ writeFileSync(join(vellumDir, "gateway.pid"), pids.gateway);
45
+ }
46
+ if (pids.qdrant !== undefined) {
47
+ writeFileSync(
48
+ join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid"),
49
+ pids.qdrant,
50
+ );
51
+ }
52
+ if (pids.embed !== undefined) {
53
+ writeFileSync(join(vellumDir, "workspace", "embed-worker.pid"), pids.embed);
54
+ }
55
+ return {
56
+ assistantId: id,
57
+ runtimeUrl: "http://localhost:7821",
58
+ cloud: "local",
59
+ resources: {
60
+ instanceDir,
61
+ daemonPort: 7821,
62
+ gatewayPort: 7830,
63
+ qdrantPort: 6333,
64
+ cesPort: 8090,
65
+ },
66
+ };
67
+ }
68
+
69
+ describe("getKnownPidsFromAssistants", () => {
70
+ let perTestDir: string;
71
+
72
+ beforeEach(() => {
73
+ perTestDir = mkdtempSync(join(testDir, "case-"));
74
+ });
75
+
76
+ test("collects daemon, gateway, qdrant, and embed-worker PIDs", () => {
77
+ const entry = makeLocalEntry(
78
+ "alpha",
79
+ join(perTestDir, "alpha"),
80
+ { daemon: "100", gateway: "200", qdrant: "300", embed: "400" },
81
+ );
82
+ const pids = getKnownPidsFromAssistants([entry]);
83
+ expect(pids).toEqual(new Set(["100", "200", "300", "400"]));
84
+ });
85
+
86
+ test("skips missing PID files without throwing", () => {
87
+ const entry = makeLocalEntry("beta", join(perTestDir, "beta"), {
88
+ daemon: "100",
89
+ });
90
+ const pids = getKnownPidsFromAssistants([entry]);
91
+ expect(pids).toEqual(new Set(["100"]));
92
+ });
93
+
94
+ test("includes docker watcherPid when present", () => {
95
+ const entry: AssistantEntry = {
96
+ assistantId: "docker-1",
97
+ runtimeUrl: "http://localhost:18100",
98
+ cloud: "docker",
99
+ watcherPid: 555,
100
+ };
101
+ const pids = getKnownPidsFromAssistants([entry]);
102
+ expect(pids).toEqual(new Set(["555"]));
103
+ });
104
+
105
+ test("ignores non-local entries without watcherPid", () => {
106
+ const entry: AssistantEntry = {
107
+ assistantId: "managed-1",
108
+ runtimeUrl: "https://platform.vellum.ai/foo",
109
+ cloud: "vellum",
110
+ };
111
+ const pids = getKnownPidsFromAssistants([entry]);
112
+ expect(pids.size).toBe(0);
113
+ });
114
+
115
+ test("local entry without resources contributes no PIDs", () => {
116
+ const entry: AssistantEntry = {
117
+ assistantId: "legacy",
118
+ runtimeUrl: "http://localhost:7821",
119
+ cloud: "local",
120
+ };
121
+ const pids = getKnownPidsFromAssistants([entry]);
122
+ expect(pids.size).toBe(0);
123
+ });
124
+
125
+ test("aggregates PIDs across multiple assistants", () => {
126
+ const a = makeLocalEntry("a", join(perTestDir, "a"), {
127
+ daemon: "100",
128
+ gateway: "200",
129
+ });
130
+ const b = makeLocalEntry("b", join(perTestDir, "b"), {
131
+ daemon: "101",
132
+ gateway: "201",
133
+ });
134
+ const pids = getKnownPidsFromAssistants([a, b]);
135
+ expect(pids).toEqual(new Set(["100", "200", "101", "201"]));
136
+ });
137
+ });
138
+
139
+ describe("loadAllAssistantsAcrossEnvs", () => {
140
+ function makeEnv(name: string, lockfileDir: string): EnvironmentDefinition {
141
+ return {
142
+ name,
143
+ platformUrl: "https://example.invalid",
144
+ webUrl: "https://example.invalid",
145
+ lockfileDirOverride: lockfileDir,
146
+ };
147
+ }
148
+
149
+ test("aggregates entries from every provided environment's lockfile", () => {
150
+ const envADir = mkdtempSync(join(testDir, "envA-"));
151
+ const envBDir = mkdtempSync(join(testDir, "envB-"));
152
+
153
+ writeFileSync(
154
+ join(envADir, "lockfile.json"),
155
+ JSON.stringify({
156
+ assistants: [
157
+ {
158
+ assistantId: "alpha",
159
+ runtimeUrl: "http://localhost:7821",
160
+ cloud: "local",
161
+ },
162
+ ],
163
+ }),
164
+ );
165
+ writeFileSync(
166
+ join(envBDir, "lockfile.json"),
167
+ JSON.stringify({
168
+ assistants: [
169
+ {
170
+ assistantId: "beta",
171
+ runtimeUrl: "http://localhost:18100",
172
+ cloud: "docker",
173
+ watcherPid: 777,
174
+ },
175
+ ],
176
+ }),
177
+ );
178
+
179
+ const all = loadAllAssistantsAcrossEnvs([
180
+ makeEnv("envA", envADir),
181
+ makeEnv("envB", envBDir),
182
+ ]);
183
+ const ids = all.map((e) => e.assistantId).sort();
184
+ expect(ids).toEqual(["alpha", "beta"]);
185
+ });
186
+
187
+ test("returns empty list when no envs have lockfiles", () => {
188
+ const envDir = mkdtempSync(join(testDir, "empty-"));
189
+ const all = loadAllAssistantsAcrossEnvs([makeEnv("missing", envDir)]);
190
+ expect(all).toEqual([]);
191
+ });
192
+
193
+ test("skips malformed JSON without throwing", () => {
194
+ const envDir = mkdtempSync(join(testDir, "malformed-"));
195
+ writeFileSync(join(envDir, "lockfile.json"), "{not json");
196
+ const all = loadAllAssistantsAcrossEnvs([makeEnv("bad", envDir)]);
197
+ expect(all).toEqual([]);
198
+ });
199
+
200
+ test("skips entries missing required fields", () => {
201
+ const envDir = mkdtempSync(join(testDir, "partial-"));
202
+ writeFileSync(
203
+ join(envDir, "lockfile.json"),
204
+ JSON.stringify({
205
+ assistants: [
206
+ { assistantId: "no-url" }, // missing runtimeUrl
207
+ { runtimeUrl: "http://x" }, // missing assistantId
208
+ {
209
+ assistantId: "good",
210
+ runtimeUrl: "http://localhost:7821",
211
+ cloud: "local",
212
+ },
213
+ ],
214
+ }),
215
+ );
216
+ const all = loadAllAssistantsAcrossEnvs([makeEnv("partial", envDir)]);
217
+ expect(all.map((e) => e.assistantId)).toEqual(["good"]);
218
+ });
219
+
220
+ test("end-to-end: dev env's daemon is not flagged as orphan from local env", () => {
221
+ // Simulate Vargas's reported bug: `local` env has no assistants, but a
222
+ // `dev` env assistant is running with a recorded daemon PID. The orphan
223
+ // filter must treat that PID as known.
224
+ const devDir = mkdtempSync(join(testDir, "dev-"));
225
+ const instanceDir = join(devDir, "instances", "quiet-finch");
226
+ makeLocalEntry("quiet-finch", instanceDir, {
227
+ daemon: "19067",
228
+ gateway: "19087",
229
+ qdrant: "19167",
230
+ });
231
+
232
+ writeFileSync(
233
+ join(devDir, "lockfile.json"),
234
+ JSON.stringify({
235
+ assistants: [
236
+ {
237
+ assistantId: "quiet-finch",
238
+ runtimeUrl: "http://127.0.0.1:18100",
239
+ cloud: "local",
240
+ resources: {
241
+ instanceDir,
242
+ daemonPort: 18000,
243
+ gatewayPort: 18100,
244
+ qdrantPort: 18200,
245
+ cesPort: 18300,
246
+ },
247
+ },
248
+ ],
249
+ }),
250
+ );
251
+
252
+ const devEntries = loadAllAssistantsAcrossEnvs([makeEnv("dev", devDir)]);
253
+ expect(devEntries).toHaveLength(1);
254
+
255
+ const knownPids = getKnownPidsFromAssistants(devEntries);
256
+ expect(knownPids).toEqual(new Set(["19067", "19087", "19167"]));
257
+ });
258
+ });
259
+
260
+ describe("detectOrphanedProcesses", () => {
261
+ test("excludes PIDs passed via excludePids", async () => {
262
+ // The orphan detector calls `ps ax` and filters by regex. The process
263
+ // running this test (bun) is itself a node-family process whose pid will
264
+ // not match the vellum/qdrant/openclaw regex, so the natural result of
265
+ // the scan is "no rows". To assert exclusion semantics deterministically,
266
+ // we just confirm the function accepts an excludePids option and returns
267
+ // an array — the meaningful behavior assertion lives in the integration
268
+ // path (the function's `knownPids.has(p.pid)` short-circuit), which we
269
+ // exercise indirectly by passing our own PID (guaranteed to never be
270
+ // double-counted).
271
+ const ownPid = String(process.pid);
272
+ const result = await detectOrphanedProcesses({
273
+ excludePids: new Set([ownPid]),
274
+ });
275
+ expect(Array.isArray(result)).toBe(true);
276
+ for (const orphan of result) {
277
+ expect(orphan.pid).not.toBe(ownPid);
278
+ }
279
+ });
280
+
281
+ test("returns an array (smoke)", async () => {
282
+ const result = await detectOrphanedProcesses({
283
+ excludePids: new Set(),
284
+ });
285
+ expect(Array.isArray(result)).toBe(true);
286
+ });
287
+ });
@@ -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
  });