@vellumai/cli 0.8.6 → 0.8.7-dev.202606052135.3e62c5a
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 +22 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -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/src/wake.ts +78 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/assistant-client-refresh.test.ts +182 -0
- package/src/__tests__/clean.test.ts +179 -0
- package/src/__tests__/client-token.test.ts +87 -0
- package/src/__tests__/client-tui-refresh.test.ts +170 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
- package/src/__tests__/connect-import.test.ts +317 -0
- package/src/__tests__/devices.test.ts +272 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/guardian-token.test.ts +126 -2
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/pair.test.ts +271 -0
- package/src/__tests__/paired-lifecycle.test.ts +116 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
- package/src/__tests__/unpair.test.ts +163 -0
- package/src/commands/client.ts +511 -11
- package/src/commands/connect/import.ts +217 -0
- package/src/commands/connect.ts +31 -0
- package/src/commands/devices.ts +247 -0
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +89 -17
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +16 -0
- package/src/commands/retire.ts +20 -47
- package/src/commands/sleep.ts +7 -0
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/wake.ts +7 -0
- package/src/components/DefaultMainScreen.tsx +100 -14
- package/src/index.ts +16 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +15 -3
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +25 -1
- 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 +11 -35
- package/src/lib/guardian-token.ts +132 -9
- package/src/lib/hatch-local.ts +73 -33
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +20 -6
- 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
|
+
});
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
utimesSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
3
11
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
5
13
|
|
|
6
14
|
import {
|
|
7
15
|
getOrCreatePersistedDeviceId,
|
|
8
16
|
loadGuardianToken,
|
|
17
|
+
refreshGuardianToken,
|
|
9
18
|
saveGuardianToken,
|
|
10
19
|
seedGuardianTokenFromSiblingEnv,
|
|
11
20
|
type GuardianTokenData,
|
|
@@ -223,3 +232,118 @@ describe("guardian-token paths are env-scoped", () => {
|
|
|
223
232
|
);
|
|
224
233
|
});
|
|
225
234
|
});
|
|
235
|
+
|
|
236
|
+
describe("refreshGuardianToken", () => {
|
|
237
|
+
let tempHome: string;
|
|
238
|
+
let savedXdg: string | undefined;
|
|
239
|
+
let savedEnv: string | undefined;
|
|
240
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
241
|
+
|
|
242
|
+
const future = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
243
|
+
|
|
244
|
+
function lockPath(id: string): string {
|
|
245
|
+
return join(tempHome, "vellum", "assistants", id, "refresh.lock");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function seed(refreshExpiresAt: string | number): void {
|
|
249
|
+
saveGuardianToken("px", {
|
|
250
|
+
guardianPrincipalId: "imported",
|
|
251
|
+
accessToken: "old-acc",
|
|
252
|
+
accessTokenExpiresAt: future(),
|
|
253
|
+
refreshToken: "old-ref",
|
|
254
|
+
refreshTokenExpiresAt: refreshExpiresAt,
|
|
255
|
+
refreshAfter: "",
|
|
256
|
+
isNew: false,
|
|
257
|
+
deviceId: "dev",
|
|
258
|
+
leasedAt: new Date().toISOString(),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
savedXdg = process.env.XDG_CONFIG_HOME;
|
|
264
|
+
savedEnv = process.env.VELLUM_ENVIRONMENT;
|
|
265
|
+
tempHome = mkdtempSync(join(tmpdir(), "cli-refresh-test-"));
|
|
266
|
+
process.env.XDG_CONFIG_HOME = tempHome;
|
|
267
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
afterEach(() => {
|
|
271
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
272
|
+
if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
273
|
+
else process.env.XDG_CONFIG_HOME = savedXdg;
|
|
274
|
+
if (savedEnv === undefined) delete process.env.VELLUM_ENVIRONMENT;
|
|
275
|
+
else process.env.VELLUM_ENVIRONMENT = savedEnv;
|
|
276
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("refreshes, persists the rotated token, sends an abort signal, releases the lock", async () => {
|
|
280
|
+
seed(future());
|
|
281
|
+
let sawSignal = false;
|
|
282
|
+
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
|
|
283
|
+
sawSignal = init?.signal instanceof AbortSignal;
|
|
284
|
+
return new Response(
|
|
285
|
+
JSON.stringify({
|
|
286
|
+
accessToken: "new-acc",
|
|
287
|
+
refreshToken: "new-ref",
|
|
288
|
+
accessTokenExpiresAt: future(),
|
|
289
|
+
refreshTokenExpiresAt: future(),
|
|
290
|
+
refreshAfter: "",
|
|
291
|
+
}),
|
|
292
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
293
|
+
);
|
|
294
|
+
}) as typeof fetch;
|
|
295
|
+
|
|
296
|
+
const result = await refreshGuardianToken("http://10.0.0.9:7830", "px");
|
|
297
|
+
|
|
298
|
+
expect(result?.accessToken).toBe("new-acc");
|
|
299
|
+
expect(loadGuardianToken("px")?.accessToken).toBe("new-acc");
|
|
300
|
+
expect(sawSignal).toBe(true); // fetch carries a timeout AbortSignal
|
|
301
|
+
expect(existsSync(lockPath("px"))).toBe(false); // lock released
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("returns null without calling the gateway when the refresh token is expired", async () => {
|
|
305
|
+
seed(new Date(Date.now() - 1000).toISOString());
|
|
306
|
+
let called = false;
|
|
307
|
+
globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
|
|
308
|
+
called = true;
|
|
309
|
+
return new Response("", { status: 200 });
|
|
310
|
+
}) as typeof fetch;
|
|
311
|
+
|
|
312
|
+
expect(await refreshGuardianToken("http://10.0.0.9:7830", "px")).toBeNull();
|
|
313
|
+
expect(called).toBe(false);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("returns null when there is no stored token", async () => {
|
|
317
|
+
let called = false;
|
|
318
|
+
globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
|
|
319
|
+
called = true;
|
|
320
|
+
return new Response("", { status: 200 });
|
|
321
|
+
}) as typeof fetch;
|
|
322
|
+
|
|
323
|
+
expect(
|
|
324
|
+
await refreshGuardianToken("http://10.0.0.9:7830", "missing"),
|
|
325
|
+
).toBeNull();
|
|
326
|
+
expect(called).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("steals a stale lock and still refreshes", async () => {
|
|
330
|
+
seed(future());
|
|
331
|
+
// Pre-create a stale lock (mtime well in the past) as if a crashed holder
|
|
332
|
+
// left it behind; the refresh must steal it rather than block.
|
|
333
|
+
const lp = lockPath("px");
|
|
334
|
+
mkdirSync(dirname(lp), { recursive: true });
|
|
335
|
+
writeFileSync(lp, "99999");
|
|
336
|
+
const old = new Date(Date.now() - 60_000);
|
|
337
|
+
utimesSync(lp, old, old);
|
|
338
|
+
|
|
339
|
+
globalThis.fetch = (async (_url: unknown, _init?: RequestInit) =>
|
|
340
|
+
new Response(JSON.stringify({ accessToken: "new-acc" }), {
|
|
341
|
+
status: 200,
|
|
342
|
+
headers: { "content-type": "application/json" },
|
|
343
|
+
})) as typeof fetch;
|
|
344
|
+
|
|
345
|
+
const result = await refreshGuardianToken("http://10.0.0.9:7830", "px");
|
|
346
|
+
expect(result?.accessToken).toBe("new-acc");
|
|
347
|
+
expect(existsSync(lp)).toBe(false); // stolen lock cleaned up after release
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -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,271 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
// Real assistant-config reads the lockfile from VELLUM_LOCKFILE_DIR.
|
|
15
|
+
const testDir = mkdtempSync(join(tmpdir(), "pair-command-test-"));
|
|
16
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
17
|
+
|
|
18
|
+
import { pair } from "../commands/pair.js";
|
|
19
|
+
|
|
20
|
+
// Distinct loopback (mint) vs reachable (advertised) URLs to verify the split.
|
|
21
|
+
const LOCAL_URL = "http://127.0.0.1:7830";
|
|
22
|
+
const RUNTIME_URL = "http://192.168.1.50:7830";
|
|
23
|
+
|
|
24
|
+
function writeLockfile(): void {
|
|
25
|
+
writeFileSync(
|
|
26
|
+
join(testDir, ".vellum.lock.json"),
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
assistants: [
|
|
29
|
+
{
|
|
30
|
+
assistantId: "pair-test",
|
|
31
|
+
runtimeUrl: RUNTIME_URL,
|
|
32
|
+
localUrl: LOCAL_URL,
|
|
33
|
+
cloud: "local",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
activeAssistant: "pair-test",
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Capture the real argv ONCE, before any test mutates it, and restore after
|
|
42
|
+
// every test — so a `['bun','vellum','pair',...]` argv can't leak into other
|
|
43
|
+
// test files in the same Bun run.
|
|
44
|
+
const ORIGINAL_ARGV = [...process.argv];
|
|
45
|
+
|
|
46
|
+
describe("pair command", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
writeLockfile();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
process.argv = [...ORIGINAL_ARGV];
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
57
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("POSTs a cli device-bound pair request and prints a decodable bundle", async () => {
|
|
61
|
+
const calls: Array<[string, RequestInit]> = [];
|
|
62
|
+
const origFetch = globalThis.fetch;
|
|
63
|
+
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
|
64
|
+
calls.push([url, init]);
|
|
65
|
+
return new Response(
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
token: "test-access-token",
|
|
68
|
+
expiresAt: "2026-06-04T00:00:00.000Z",
|
|
69
|
+
guardianId: "guardian-001",
|
|
70
|
+
assistantId: "self",
|
|
71
|
+
}),
|
|
72
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
73
|
+
);
|
|
74
|
+
}) as unknown as typeof fetch;
|
|
75
|
+
|
|
76
|
+
const logs: string[] = [];
|
|
77
|
+
const logSpy = spyOn(console, "log").mockImplementation(
|
|
78
|
+
(...a: unknown[]) => {
|
|
79
|
+
logs.push(a.join(" "));
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
process.argv = ["bun", "vellum", "pair", "--json"];
|
|
84
|
+
try {
|
|
85
|
+
await pair();
|
|
86
|
+
} finally {
|
|
87
|
+
logSpy.mockRestore();
|
|
88
|
+
globalThis.fetch = origFetch;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Mint over the loopback (localUrl), not the reachable runtime URL.
|
|
92
|
+
expect(calls).toHaveLength(1);
|
|
93
|
+
const [url, init] = calls[0];
|
|
94
|
+
expect(url).toBe(`${LOCAL_URL}/v1/pair`);
|
|
95
|
+
expect(init.method).toBe("POST");
|
|
96
|
+
const headers = Object.fromEntries(
|
|
97
|
+
Object.entries(init.headers as Record<string, string>).map(([k, v]) => [
|
|
98
|
+
k.toLowerCase(),
|
|
99
|
+
v,
|
|
100
|
+
]),
|
|
101
|
+
);
|
|
102
|
+
expect(headers["x-vellum-interface-id"]).toBe("cli");
|
|
103
|
+
const body = JSON.parse(init.body as string);
|
|
104
|
+
expect(typeof body.deviceId).toBe("string");
|
|
105
|
+
expect(body.deviceId.length).toBeGreaterThan(0);
|
|
106
|
+
expect(body.platform).toBe("cli");
|
|
107
|
+
|
|
108
|
+
// The bundle advertises the REACHABLE runtime URL, not loopback.
|
|
109
|
+
const out = JSON.parse(logs.join("\n"));
|
|
110
|
+
expect(out.gatewayUrl).toBe(RUNTIME_URL);
|
|
111
|
+
expect(out.assistantId).toBe("self");
|
|
112
|
+
expect(out.token).toBe("test-access-token");
|
|
113
|
+
expect(out.deviceId).toBe(body.deviceId);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("resolves an unquoted multi-word display name", async () => {
|
|
117
|
+
// Assistant whose display name has a space; passed as separate argv tokens.
|
|
118
|
+
writeFileSync(
|
|
119
|
+
join(testDir, ".vellum.lock.json"),
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
assistants: [
|
|
122
|
+
{
|
|
123
|
+
assistantId: "pair-test",
|
|
124
|
+
name: "My Assistant",
|
|
125
|
+
runtimeUrl: RUNTIME_URL,
|
|
126
|
+
localUrl: LOCAL_URL,
|
|
127
|
+
cloud: "local",
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
activeAssistant: "pair-test",
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
let fetchCalled = false;
|
|
135
|
+
const origFetch = globalThis.fetch;
|
|
136
|
+
globalThis.fetch = (async () => {
|
|
137
|
+
fetchCalled = true;
|
|
138
|
+
return new Response(
|
|
139
|
+
JSON.stringify({
|
|
140
|
+
token: "t",
|
|
141
|
+
expiresAt: "2026-06-04T00:00:00.000Z",
|
|
142
|
+
guardianId: "g",
|
|
143
|
+
assistantId: "self",
|
|
144
|
+
}),
|
|
145
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
146
|
+
);
|
|
147
|
+
}) as unknown as typeof fetch;
|
|
148
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
149
|
+
|
|
150
|
+
process.argv = ["bun", "vellum", "pair", "My", "Assistant", "--json"];
|
|
151
|
+
try {
|
|
152
|
+
await pair();
|
|
153
|
+
} finally {
|
|
154
|
+
logSpy.mockRestore();
|
|
155
|
+
globalThis.fetch = origFetch;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Resolution succeeded (no exit), so the mint request was made.
|
|
159
|
+
expect(fetchCalled).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("refuses to advertise a loopback URL without --url (suggests the assistant's own port)", async () => {
|
|
163
|
+
// Local hatch on a NON-default gateway port (e.g. a 2nd instance).
|
|
164
|
+
const LOOPBACK_CUSTOM = "http://127.0.0.1:7842";
|
|
165
|
+
writeFileSync(
|
|
166
|
+
join(testDir, ".vellum.lock.json"),
|
|
167
|
+
JSON.stringify({
|
|
168
|
+
assistants: [
|
|
169
|
+
{
|
|
170
|
+
assistantId: "pair-test",
|
|
171
|
+
runtimeUrl: LOOPBACK_CUSTOM,
|
|
172
|
+
localUrl: LOOPBACK_CUSTOM,
|
|
173
|
+
cloud: "local",
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
activeAssistant: "pair-test",
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
let fetchCalled = false;
|
|
181
|
+
const origFetch = globalThis.fetch;
|
|
182
|
+
globalThis.fetch = (async () => {
|
|
183
|
+
fetchCalled = true;
|
|
184
|
+
return new Response("{}", { status: 200 });
|
|
185
|
+
}) as unknown as typeof fetch;
|
|
186
|
+
const errors: string[] = [];
|
|
187
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
188
|
+
(...a: unknown[]) => {
|
|
189
|
+
errors.push(a.join(" "));
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
193
|
+
code?: number,
|
|
194
|
+
) => {
|
|
195
|
+
throw new Error(`exit:${code}`);
|
|
196
|
+
}) as never);
|
|
197
|
+
|
|
198
|
+
process.argv = ["bun", "vellum", "pair"];
|
|
199
|
+
let exited = false;
|
|
200
|
+
try {
|
|
201
|
+
await pair();
|
|
202
|
+
} catch (e) {
|
|
203
|
+
exited = (e as Error).message === "exit:1";
|
|
204
|
+
} finally {
|
|
205
|
+
errSpy.mockRestore();
|
|
206
|
+
exitSpy.mockRestore();
|
|
207
|
+
globalThis.fetch = origFetch;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The suggested --url uses the assistant's actual port, not the default.
|
|
211
|
+
expect(errors.join("\n")).toContain(":7842");
|
|
212
|
+
expect(errors.join("\n")).not.toContain(":7830");
|
|
213
|
+
|
|
214
|
+
// Exited with an error before minting — no token created for a dead URL.
|
|
215
|
+
expect(exited).toBe(true);
|
|
216
|
+
expect(fetchCalled).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("--url override allows pairing even when the runtime URL is loopback", async () => {
|
|
220
|
+
writeFileSync(
|
|
221
|
+
join(testDir, ".vellum.lock.json"),
|
|
222
|
+
JSON.stringify({
|
|
223
|
+
assistants: [
|
|
224
|
+
{
|
|
225
|
+
assistantId: "pair-test",
|
|
226
|
+
runtimeUrl: LOCAL_URL,
|
|
227
|
+
localUrl: LOCAL_URL,
|
|
228
|
+
cloud: "local",
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
activeAssistant: "pair-test",
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const calls: Array<[string, RequestInit]> = [];
|
|
236
|
+
const origFetch = globalThis.fetch;
|
|
237
|
+
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
|
238
|
+
calls.push([url, init]);
|
|
239
|
+
return new Response(
|
|
240
|
+
JSON.stringify({
|
|
241
|
+
token: "t",
|
|
242
|
+
expiresAt: "2026-06-04T00:00:00.000Z",
|
|
243
|
+
guardianId: "g",
|
|
244
|
+
assistantId: "self",
|
|
245
|
+
}),
|
|
246
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
247
|
+
);
|
|
248
|
+
}) as unknown as typeof fetch;
|
|
249
|
+
const logs: string[] = [];
|
|
250
|
+
const logSpy = spyOn(console, "log").mockImplementation(
|
|
251
|
+
(...a: unknown[]) => {
|
|
252
|
+
logs.push(a.join(" "));
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const OVERRIDE = "https://abc123.ngrok.app";
|
|
257
|
+
process.argv = ["bun", "vellum", "pair", "--url", OVERRIDE, "--json"];
|
|
258
|
+
try {
|
|
259
|
+
await pair();
|
|
260
|
+
} finally {
|
|
261
|
+
logSpy.mockRestore();
|
|
262
|
+
globalThis.fetch = origFetch;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Mint still over loopback; bundle advertises the override.
|
|
266
|
+
expect(calls).toHaveLength(1);
|
|
267
|
+
expect(calls[0][0]).toBe(`${LOCAL_URL}/v1/pair`);
|
|
268
|
+
const out = JSON.parse(logs.join("\n"));
|
|
269
|
+
expect(out.gatewayUrl).toBe(OVERRIDE);
|
|
270
|
+
});
|
|
271
|
+
});
|