@vellumai/cli 0.8.6 → 0.8.7
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/bun.lock +8 -0
- package/knip.json +5 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +21 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/commands/client.ts +413 -2
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +89 -17
- package/src/components/DefaultMainScreen.tsx +16 -1
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/assistant-config.ts +3 -3
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +2 -5
- package/src/lib/guardian-token.ts +12 -5
- package/src/lib/hatch-local.ts +73 -33
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
|
@@ -0,0 +1,248 @@
|
|
|
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 { type AssistantEntry } from "../lib/assistant-config.js";
|
|
15
|
+
import { flags } from "../commands/flags.js";
|
|
16
|
+
|
|
17
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-flags-test-"));
|
|
18
|
+
const originalArgv = [...process.argv];
|
|
19
|
+
const originalExit = process.exit;
|
|
20
|
+
const originalFetch = globalThis.fetch;
|
|
21
|
+
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
22
|
+
|
|
23
|
+
let consoleLogSpy: ReturnType<typeof spyOn>;
|
|
24
|
+
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
|
25
|
+
let fetchCalls: Array<{ url: string; method: string }>;
|
|
26
|
+
|
|
27
|
+
function makeEntry(
|
|
28
|
+
assistantId: string,
|
|
29
|
+
extra: Partial<AssistantEntry> = {},
|
|
30
|
+
): AssistantEntry {
|
|
31
|
+
return {
|
|
32
|
+
assistantId,
|
|
33
|
+
runtimeUrl: `http://127.0.0.1:${7800 + assistantId.length}`,
|
|
34
|
+
cloud: "local",
|
|
35
|
+
...extra,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeLockfile(
|
|
40
|
+
entries: AssistantEntry[],
|
|
41
|
+
activeAssistant?: string,
|
|
42
|
+
): void {
|
|
43
|
+
mkdirSync(testDir, { recursive: true });
|
|
44
|
+
writeFileSync(
|
|
45
|
+
join(testDir, ".vellum.lock.json"),
|
|
46
|
+
JSON.stringify(
|
|
47
|
+
{
|
|
48
|
+
assistants: entries,
|
|
49
|
+
...(activeAssistant ? { activeAssistant } : {}),
|
|
50
|
+
},
|
|
51
|
+
null,
|
|
52
|
+
2,
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a Response stub that callers shape per subcommand. `setFlag` needs
|
|
59
|
+
* a 200 OK with the gateway's updated flag payload; `getFlag`/`listFlags`
|
|
60
|
+
* need a flag list. Body content is the minimal valid shape — the tests
|
|
61
|
+
* exercise URL routing, not response parsing.
|
|
62
|
+
*/
|
|
63
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
64
|
+
return new Response(JSON.stringify(body), {
|
|
65
|
+
status,
|
|
66
|
+
headers: { "content-type": "application/json" },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("vellum flags --assistant routing", () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
73
|
+
rmSync(join(testDir, ".vellum.lock.json"), { force: true });
|
|
74
|
+
fetchCalls = [];
|
|
75
|
+
// Capture every outgoing fetch and respond with a stub matching the
|
|
76
|
+
// subcommand's expected shape. The URL is what the test asserts on.
|
|
77
|
+
globalThis.fetch = (async (
|
|
78
|
+
input: RequestInfo | URL,
|
|
79
|
+
init?: RequestInit,
|
|
80
|
+
) => {
|
|
81
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
82
|
+
const method = init?.method ?? "GET";
|
|
83
|
+
fetchCalls.push({ url, method });
|
|
84
|
+
if (method === "PATCH") {
|
|
85
|
+
return jsonResponse({
|
|
86
|
+
key: "external-plugins",
|
|
87
|
+
enabled: true,
|
|
88
|
+
defaultEnabled: false,
|
|
89
|
+
label: "External Plugins",
|
|
90
|
+
description: "test",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return jsonResponse({ flags: [] });
|
|
94
|
+
}) as typeof globalThis.fetch;
|
|
95
|
+
process.exit = ((code?: number) => {
|
|
96
|
+
throw new Error(`process.exit:${code}`);
|
|
97
|
+
}) as typeof process.exit;
|
|
98
|
+
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
99
|
+
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
process.argv = originalArgv;
|
|
104
|
+
process.exit = originalExit;
|
|
105
|
+
globalThis.fetch = originalFetch;
|
|
106
|
+
consoleLogSpy.mockRestore();
|
|
107
|
+
consoleErrorSpy.mockRestore();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterAll(() => {
|
|
111
|
+
if (originalLockfileDir === undefined) {
|
|
112
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
113
|
+
} else {
|
|
114
|
+
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
115
|
+
}
|
|
116
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("set --assistant <id> routes to the explicit instance's runtime URL, not the active one", async () => {
|
|
120
|
+
// Two assistants on different ports. The active one is "alice"; the
|
|
121
|
+
// explicit --assistant target is "bob". A correct routing impl hits
|
|
122
|
+
// bob's URL — a regression that silently uses the active assistant
|
|
123
|
+
// would hit alice's URL.
|
|
124
|
+
writeLockfile(
|
|
125
|
+
[
|
|
126
|
+
makeEntry("alice-1", { name: "Alice" }),
|
|
127
|
+
makeEntry("bob-2", { name: "Bob" }),
|
|
128
|
+
],
|
|
129
|
+
"alice-1",
|
|
130
|
+
);
|
|
131
|
+
process.argv = [
|
|
132
|
+
"bun",
|
|
133
|
+
"vellum",
|
|
134
|
+
"flags",
|
|
135
|
+
"set",
|
|
136
|
+
"external-plugins",
|
|
137
|
+
"true",
|
|
138
|
+
"--assistant",
|
|
139
|
+
"Bob",
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
await flags();
|
|
143
|
+
|
|
144
|
+
expect(fetchCalls.length).toBe(1);
|
|
145
|
+
expect(fetchCalls[0].method).toBe("PATCH");
|
|
146
|
+
// bob-2 has assistantId.length === 5, so port = 7800 + 5 = 7805.
|
|
147
|
+
expect(fetchCalls[0].url).toContain("http://127.0.0.1:7805");
|
|
148
|
+
expect(fetchCalls[0].url).toContain(
|
|
149
|
+
"/v1/assistants/bob-2/feature-flags/external-plugins",
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("set --assistant <id> placed BEFORE positional args still parses correctly", async () => {
|
|
154
|
+
// Eval harness composes `vellum flags set <key> <value> --assistant <id>`
|
|
155
|
+
// but human users might write `--assistant <id> set <key> <value>`.
|
|
156
|
+
// The extractor strips --assistant from anywhere in argv so positional
|
|
157
|
+
// parsing downstream sees the same shape either way.
|
|
158
|
+
writeLockfile([
|
|
159
|
+
makeEntry("alice-1", { name: "Alice" }),
|
|
160
|
+
makeEntry("bob-2", { name: "Bob" }),
|
|
161
|
+
]);
|
|
162
|
+
process.argv = [
|
|
163
|
+
"bun",
|
|
164
|
+
"vellum",
|
|
165
|
+
"flags",
|
|
166
|
+
"--assistant",
|
|
167
|
+
"Bob",
|
|
168
|
+
"set",
|
|
169
|
+
"external-plugins",
|
|
170
|
+
"true",
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
await flags();
|
|
174
|
+
|
|
175
|
+
expect(fetchCalls.length).toBe(1);
|
|
176
|
+
expect(fetchCalls[0].url).toContain(
|
|
177
|
+
"/v1/assistants/bob-2/feature-flags/external-plugins",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("set without --assistant uses the active assistant", async () => {
|
|
182
|
+
// Backwards-compat: behavior unchanged for invocations that don't
|
|
183
|
+
// pass --assistant. The active assistant ("alice-1") wins.
|
|
184
|
+
writeLockfile(
|
|
185
|
+
[
|
|
186
|
+
makeEntry("alice-1", { name: "Alice" }),
|
|
187
|
+
makeEntry("bob-2", { name: "Bob" }),
|
|
188
|
+
],
|
|
189
|
+
"alice-1",
|
|
190
|
+
);
|
|
191
|
+
process.argv = [
|
|
192
|
+
"bun",
|
|
193
|
+
"vellum",
|
|
194
|
+
"flags",
|
|
195
|
+
"set",
|
|
196
|
+
"external-plugins",
|
|
197
|
+
"true",
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
await flags();
|
|
201
|
+
|
|
202
|
+
expect(fetchCalls.length).toBe(1);
|
|
203
|
+
// alice-1 has assistantId.length === 7, so port = 7800 + 7 = 7807.
|
|
204
|
+
expect(fetchCalls[0].url).toContain("http://127.0.0.1:7807");
|
|
205
|
+
expect(fetchCalls[0].url).toContain(
|
|
206
|
+
"/v1/assistants/alice-1/feature-flags/external-plugins",
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("set --assistant <name> exits with a lookup error when no assistant matches", async () => {
|
|
211
|
+
writeLockfile([makeEntry("alice-1", { name: "Alice" })]);
|
|
212
|
+
process.argv = [
|
|
213
|
+
"bun",
|
|
214
|
+
"vellum",
|
|
215
|
+
"flags",
|
|
216
|
+
"set",
|
|
217
|
+
"external-plugins",
|
|
218
|
+
"true",
|
|
219
|
+
"--assistant",
|
|
220
|
+
"Ghost",
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
// The Error thrown by createClient propagates out of flags().
|
|
224
|
+
// No fetch should ever fire because lookup fails before the
|
|
225
|
+
// AssistantClient is constructed.
|
|
226
|
+
await expect(flags()).rejects.toThrow(/Ghost/);
|
|
227
|
+
expect(fetchCalls.length).toBe(0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("--assistant without a value exits via the explicit missing-value branch", async () => {
|
|
231
|
+
writeLockfile([makeEntry("alice-1", { name: "Alice" })]);
|
|
232
|
+
process.argv = [
|
|
233
|
+
"bun",
|
|
234
|
+
"vellum",
|
|
235
|
+
"flags",
|
|
236
|
+
"set",
|
|
237
|
+
"external-plugins",
|
|
238
|
+
"true",
|
|
239
|
+
"--assistant",
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
await expect(flags()).rejects.toThrow(/process\.exit:1/);
|
|
243
|
+
expect(consoleErrorSpy.mock.calls.flat().join("\n")).toContain(
|
|
244
|
+
"Missing value for --assistant <name>",
|
|
245
|
+
);
|
|
246
|
+
expect(fetchCalls.length).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -156,7 +156,7 @@ describe("multi-local", () => {
|
|
|
156
156
|
|
|
157
157
|
test("allocation picks env-specific port bases for non-prod envs", async () => {
|
|
158
158
|
// Each non-prod env sits in its own 1000-port window (see
|
|
159
|
-
// environments
|
|
159
|
+
// @vellumai/environments seeds). Hatching under VELLUM_ENVIRONMENT=dev should
|
|
160
160
|
// produce ports in the dev block (18000+), not the production defaults.
|
|
161
161
|
const prevEnv = process.env.VELLUM_ENVIRONMENT;
|
|
162
162
|
const prevXdg = process.env.XDG_DATA_HOME;
|
|
@@ -3,6 +3,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
|
+
import type { EnvironmentDefinition } from "@vellumai/environments";
|
|
7
|
+
|
|
6
8
|
// Point lockfile operations at a temp directory before importing anything that
|
|
7
9
|
// would otherwise resolve real on-host paths.
|
|
8
10
|
const testDir = mkdtempSync(join(tmpdir(), "cli-orphan-detection-test-"));
|
|
@@ -16,7 +18,6 @@ import {
|
|
|
16
18
|
loadAllAssistantsAcrossEnvs,
|
|
17
19
|
type AssistantEntry,
|
|
18
20
|
} from "../lib/assistant-config.js";
|
|
19
|
-
import type { EnvironmentDefinition } from "../lib/environments/types.js";
|
|
20
21
|
|
|
21
22
|
afterAll(() => {
|
|
22
23
|
rmSync(testDir, { recursive: true, force: true });
|
|
@@ -74,11 +75,12 @@ describe("getKnownPidsFromAssistants", () => {
|
|
|
74
75
|
});
|
|
75
76
|
|
|
76
77
|
test("collects daemon, gateway, qdrant, and embed-worker PIDs", () => {
|
|
77
|
-
const entry = makeLocalEntry(
|
|
78
|
-
"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
const entry = makeLocalEntry("alpha", join(perTestDir, "alpha"), {
|
|
79
|
+
daemon: "100",
|
|
80
|
+
gateway: "200",
|
|
81
|
+
qdrant: "300",
|
|
82
|
+
embed: "400",
|
|
83
|
+
});
|
|
82
84
|
const pids = getKnownPidsFromAssistants([entry]);
|
|
83
85
|
expect(pids).toEqual(new Set(["100", "200", "300", "400"]));
|
|
84
86
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { segmentsToPlainText } from "../lib/segments-to-plain-text.js";
|
|
3
|
+
|
|
4
|
+
describe("segmentsToPlainText", () => {
|
|
5
|
+
test("returns empty string for missing or empty segments", () => {
|
|
6
|
+
// GIVEN a message whose history payload carries no text segments
|
|
7
|
+
// WHEN deriving its flat body
|
|
8
|
+
// THEN it is the empty string (matching the daemon's old empty `content`)
|
|
9
|
+
expect(segmentsToPlainText(undefined)).toBe("");
|
|
10
|
+
expect(segmentsToPlainText([])).toBe("");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("returns a single segment unchanged", () => {
|
|
14
|
+
// GIVEN a plain-text message with one segment
|
|
15
|
+
// WHEN deriving its flat body
|
|
16
|
+
// THEN the segment is returned verbatim
|
|
17
|
+
expect(segmentsToPlainText(["Real reply."])).toBe("Real reply.");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("joins adjacent segments with a single inserted space", () => {
|
|
21
|
+
// GIVEN segments split at a tool_use boundary with no surrounding whitespace
|
|
22
|
+
// WHEN deriving the flat body
|
|
23
|
+
// THEN a single space is inserted between them
|
|
24
|
+
expect(segmentsToPlainText(["before", "after"])).toBe("before after");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("does not double-space when either side already has whitespace", () => {
|
|
28
|
+
// GIVEN segments where one side already ends/starts with whitespace
|
|
29
|
+
// WHEN deriving the flat body
|
|
30
|
+
// THEN no extra space is inserted (mirrors daemon joinWithSpacing)
|
|
31
|
+
expect(segmentsToPlainText(["before ", "after"])).toBe("before after");
|
|
32
|
+
expect(segmentsToPlainText(["before", " after"])).toBe("before after");
|
|
33
|
+
expect(segmentsToPlainText(["line one\n", "line two"])).toBe(
|
|
34
|
+
"line one\nline two",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
});
|