@vellumai/cli 0.10.3 → 0.10.4-dev.202607010119.ad15dbb
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/node_modules/@vellumai/local-mode/src/__tests__/retire.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +12 -3
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +11 -2
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +6 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +29 -1
- package/node_modules/@vellumai/local-mode/src/retire.ts +27 -5
- package/package.json +1 -1
- package/src/__tests__/flags.test.ts +10 -10
- package/src/__tests__/recover.test.ts +34 -27
- package/src/__tests__/retire.test.ts +353 -0
- package/src/__tests__/teleport.test.ts +50 -17
- package/src/commands/flags.ts +1 -1
- package/src/commands/login.ts +10 -2
- package/src/commands/retire.ts +197 -24
- package/src/commands/teleport.ts +51 -18
- package/src/lib/local-runtime-client.ts +38 -0
- package/src/lib/platform-client.ts +14 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
|
|
4
|
+
import type { CliInvocation } from "../util";
|
|
5
|
+
|
|
6
|
+
class FakeChild extends EventEmitter {
|
|
7
|
+
stdout = new EventEmitter();
|
|
8
|
+
stderr = new EventEmitter();
|
|
9
|
+
kill = mock(() => true);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let lastChild: FakeChild;
|
|
13
|
+
const spawnArgs: Array<
|
|
14
|
+
[string, string[], { env?: NodeJS.ProcessEnv; stdio?: unknown }]
|
|
15
|
+
> = [];
|
|
16
|
+
const spawnMock = mock(
|
|
17
|
+
(
|
|
18
|
+
command: string,
|
|
19
|
+
args: string[],
|
|
20
|
+
options: { env?: NodeJS.ProcessEnv; stdio?: unknown },
|
|
21
|
+
) => {
|
|
22
|
+
spawnArgs.push([command, args, options]);
|
|
23
|
+
lastChild = new FakeChild();
|
|
24
|
+
return lastChild;
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
mock.module("node:child_process", () => ({ spawn: spawnMock }));
|
|
29
|
+
|
|
30
|
+
let runRetire: typeof import("../retire").runRetire;
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
({ runRetire } = await import("../retire"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
spawnArgs.length = 0;
|
|
38
|
+
spawnMock.mockClear();
|
|
39
|
+
delete process.env.VELLUM_PLATFORM_TOKEN;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const invocation: CliInvocation = { command: "bun", baseArgs: ["run", "cli"] };
|
|
43
|
+
|
|
44
|
+
describe("runRetire", () => {
|
|
45
|
+
test("spawns the CLI retire command", async () => {
|
|
46
|
+
const pending = runRetire(invocation, "asst-42");
|
|
47
|
+
lastChild.emit("close", 0);
|
|
48
|
+
|
|
49
|
+
expect(await pending).toEqual({ ok: true });
|
|
50
|
+
expect(spawnArgs[0]).toEqual([
|
|
51
|
+
"bun",
|
|
52
|
+
["run", "cli", "retire", "asst-42", "--yes"],
|
|
53
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("passes a host platform token to the CLI subprocess", async () => {
|
|
58
|
+
const pending = runRetire(invocation, "asst-42", {
|
|
59
|
+
platformToken: "session-token",
|
|
60
|
+
});
|
|
61
|
+
lastChild.emit("close", 0);
|
|
62
|
+
|
|
63
|
+
expect(await pending).toEqual({ ok: true });
|
|
64
|
+
expect(spawnArgs[0]?.[2].env?.VELLUM_PLATFORM_TOKEN).toBe("session-token");
|
|
65
|
+
expect(process.env.VELLUM_PLATFORM_TOKEN).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a non-zero exit resolves to a failure carrying the CLI output", async () => {
|
|
69
|
+
const pending = runRetire(invocation, "asst-42");
|
|
70
|
+
lastChild.stderr.emit("data", Buffer.from("retire failed"));
|
|
71
|
+
lastChild.emit("close", 1);
|
|
72
|
+
|
|
73
|
+
expect(await pending).toEqual({
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 500,
|
|
76
|
+
error: "retire failed",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -14,9 +14,18 @@ export {
|
|
|
14
14
|
resolveDevCliInvocation,
|
|
15
15
|
} from "./util";
|
|
16
16
|
export type { CliInvocation } from "./util";
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
resolveLocalConfigFromEnv,
|
|
19
|
+
resolveLockfilePaths,
|
|
20
|
+
resolveConfigDir,
|
|
21
|
+
guardianTokenPath,
|
|
22
|
+
} from "./config";
|
|
18
23
|
export type { LocalEndpointConfig } from "./config";
|
|
19
|
-
export {
|
|
24
|
+
export {
|
|
25
|
+
defaultEnvironmentFilePath,
|
|
26
|
+
readDefaultEnvironment,
|
|
27
|
+
resolveEnvironmentName,
|
|
28
|
+
} from "./environment";
|
|
20
29
|
export {
|
|
21
30
|
getLockfileData,
|
|
22
31
|
upsertLockfileAssistant,
|
|
@@ -34,7 +43,7 @@ export type {
|
|
|
34
43
|
export { runHatch } from "./hatch";
|
|
35
44
|
export type { HatchResult } from "./hatch";
|
|
36
45
|
export { runRetire } from "./retire";
|
|
37
|
-
export type { RetireResult } from "./retire";
|
|
46
|
+
export type { RetireOptions, RetireResult } from "./retire";
|
|
38
47
|
export { runSleep } from "./sleep";
|
|
39
48
|
export type { SleepResult } from "./sleep";
|
|
40
49
|
export { runWake } from "./wake";
|
|
@@ -21,6 +21,9 @@ describe("parseLockfile", () => {
|
|
|
21
21
|
species: "vellum",
|
|
22
22
|
hatchedAt: "2026-01-01T00:00:00.000Z",
|
|
23
23
|
organizationId: "org_1",
|
|
24
|
+
platformAssistantId: "platform-assistant-1",
|
|
25
|
+
platformBaseUrl: "https://platform.example.com",
|
|
26
|
+
platformOrganizationId: "org_1",
|
|
24
27
|
resources: { gatewayPort: 7777, daemonPort: 7778 },
|
|
25
28
|
},
|
|
26
29
|
],
|
|
@@ -274,13 +277,19 @@ describe("parseLockfile", () => {
|
|
|
274
277
|
expect(parseLockfile(raw).assistants).toEqual([
|
|
275
278
|
{ assistantId: "gcp_1", cloud: "gcp", runtimeUrl: "https://a" },
|
|
276
279
|
{ assistantId: "ssh_1", cloud: "custom", runtimeUrl: "https://b" },
|
|
277
|
-
{
|
|
280
|
+
{
|
|
281
|
+
assistantId: "local_1",
|
|
282
|
+
cloud: "local",
|
|
283
|
+
runtimeUrl: "http://localhost:7830",
|
|
284
|
+
},
|
|
278
285
|
]);
|
|
279
286
|
});
|
|
280
287
|
|
|
281
288
|
test("prefers an explicit cloud over legacy remote markers", () => {
|
|
282
289
|
const raw = {
|
|
283
|
-
assistants: [
|
|
290
|
+
assistants: [
|
|
291
|
+
{ assistantId: "a", cloud: "vellum", project: "stale-proj" },
|
|
292
|
+
],
|
|
284
293
|
activeAssistant: null,
|
|
285
294
|
};
|
|
286
295
|
expect(parseLockfile(raw).assistants).toEqual([
|
|
@@ -109,6 +109,12 @@ export const LockfileAssistantSchema = z.object({
|
|
|
109
109
|
hatchedAt: z.string().optional(),
|
|
110
110
|
/** Owning org for platform assistants; absent for local ones. */
|
|
111
111
|
organizationId: z.string().optional(),
|
|
112
|
+
/** Platform assistant UUID for a self-hosted local assistant registration. */
|
|
113
|
+
platformAssistantId: z.string().optional(),
|
|
114
|
+
/** Platform base URL used for a self-hosted local assistant registration. */
|
|
115
|
+
platformBaseUrl: z.string().optional(),
|
|
116
|
+
/** Platform organization UUID used for a self-hosted local assistant registration. */
|
|
117
|
+
platformOrganizationId: z.string().optional(),
|
|
112
118
|
resources: LocalAssistantResourcesSchema.optional(),
|
|
113
119
|
});
|
|
114
120
|
|
|
@@ -171,6 +171,32 @@ describe("upsertLockfileAssistant", () => {
|
|
|
171
171
|
name: "Renamed",
|
|
172
172
|
});
|
|
173
173
|
});
|
|
174
|
+
|
|
175
|
+
test("preserves activeAssistant when no active id is provided", () => {
|
|
176
|
+
writeOnDisk({
|
|
177
|
+
activeAssistant: "asst_active",
|
|
178
|
+
assistants: [
|
|
179
|
+
{
|
|
180
|
+
assistantId: "asst_active",
|
|
181
|
+
cloud: "local",
|
|
182
|
+
runtimeUrl: "http://active",
|
|
183
|
+
},
|
|
184
|
+
{ assistantId: "asst_1", cloud: "local", runtimeUrl: "http://a" },
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const result = upsertLockfileAssistant(
|
|
189
|
+
[lockfilePath],
|
|
190
|
+
{ assistantId: "asst_1", name: "Renamed" },
|
|
191
|
+
undefined,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(result.ok).toBe(true);
|
|
195
|
+
expect(readOnDisk().activeAssistant).toBe("asst_active");
|
|
196
|
+
if (result.ok) {
|
|
197
|
+
expect(result.lockfile.activeAssistant).toBe("asst_active");
|
|
198
|
+
}
|
|
199
|
+
});
|
|
174
200
|
});
|
|
175
201
|
|
|
176
202
|
describe("replacePlatformAssistants", () => {
|
|
@@ -270,7 +296,9 @@ describe("replacePlatformAssistants", () => {
|
|
|
270
296
|
"org_a",
|
|
271
297
|
);
|
|
272
298
|
|
|
273
|
-
const assistants = readOnDisk().assistants as Array<
|
|
299
|
+
const assistants = readOnDisk().assistants as Array<
|
|
300
|
+
Record<string, unknown>
|
|
301
|
+
>;
|
|
274
302
|
expect(assistants).toHaveLength(1);
|
|
275
303
|
expect(assistants[0]).toMatchObject({
|
|
276
304
|
assistantId: "asst_dup",
|
|
@@ -5,18 +5,32 @@ import type { CliInvocation } from "./util";
|
|
|
5
5
|
const RETIRE_TIMEOUT_MS = 60_000;
|
|
6
6
|
|
|
7
7
|
export type RetireResult =
|
|
8
|
-
| { ok:
|
|
9
|
-
|
|
8
|
+
{ ok: true } | { ok: false; status: number; error: string };
|
|
9
|
+
|
|
10
|
+
export interface RetireOptions {
|
|
11
|
+
platformToken?: string;
|
|
12
|
+
}
|
|
10
13
|
|
|
11
14
|
export function runRetire(
|
|
12
15
|
invocation: CliInvocation,
|
|
13
16
|
assistantId: string,
|
|
17
|
+
options: RetireOptions = {},
|
|
14
18
|
): Promise<RetireResult> {
|
|
15
19
|
return new Promise((resolve) => {
|
|
16
20
|
const child = spawn(
|
|
17
21
|
invocation.command,
|
|
18
22
|
[...invocation.baseArgs, "retire", assistantId, "--yes"],
|
|
19
|
-
{
|
|
23
|
+
{
|
|
24
|
+
...(options.platformToken
|
|
25
|
+
? {
|
|
26
|
+
env: {
|
|
27
|
+
...process.env,
|
|
28
|
+
VELLUM_PLATFORM_TOKEN: options.platformToken,
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
: {}),
|
|
32
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
33
|
+
},
|
|
20
34
|
);
|
|
21
35
|
|
|
22
36
|
let stdout = "";
|
|
@@ -32,7 +46,11 @@ export function runRetire(
|
|
|
32
46
|
|
|
33
47
|
const timeout = setTimeout(() => {
|
|
34
48
|
child.kill("SIGTERM");
|
|
35
|
-
finish({
|
|
49
|
+
finish({
|
|
50
|
+
ok: false,
|
|
51
|
+
status: 500,
|
|
52
|
+
error: "Retire timed out after 60 seconds",
|
|
53
|
+
});
|
|
36
54
|
}, RETIRE_TIMEOUT_MS);
|
|
37
55
|
|
|
38
56
|
child.stdout.on("data", (data: Buffer) => {
|
|
@@ -52,7 +70,11 @@ export function runRetire(
|
|
|
52
70
|
});
|
|
53
71
|
|
|
54
72
|
child.on("error", (err) => {
|
|
55
|
-
finish({
|
|
73
|
+
finish({
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 500,
|
|
76
|
+
error: `Failed to spawn CLI: ${err.message}`,
|
|
77
|
+
});
|
|
56
78
|
});
|
|
57
79
|
});
|
|
58
80
|
}
|
package/package.json
CHANGED
|
@@ -83,10 +83,10 @@ describe("vellum flags --assistant routing", () => {
|
|
|
83
83
|
fetchCalls.push({ url, method });
|
|
84
84
|
if (method === "PATCH") {
|
|
85
85
|
return jsonResponse({
|
|
86
|
-
key: "
|
|
86
|
+
key: "voice-mode",
|
|
87
87
|
enabled: true,
|
|
88
88
|
defaultEnabled: false,
|
|
89
|
-
label: "
|
|
89
|
+
label: "Voice Mode",
|
|
90
90
|
description: "test",
|
|
91
91
|
});
|
|
92
92
|
}
|
|
@@ -133,7 +133,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
133
133
|
"vellum",
|
|
134
134
|
"flags",
|
|
135
135
|
"set",
|
|
136
|
-
"
|
|
136
|
+
"voice-mode",
|
|
137
137
|
"true",
|
|
138
138
|
"--assistant",
|
|
139
139
|
"Bob",
|
|
@@ -146,7 +146,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
146
146
|
// bob-2 has assistantId.length === 5, so port = 7800 + 5 = 7805.
|
|
147
147
|
expect(fetchCalls[0].url).toContain("http://127.0.0.1:7805");
|
|
148
148
|
expect(fetchCalls[0].url).toContain(
|
|
149
|
-
"/v1/assistants/bob-2/feature-flags/
|
|
149
|
+
"/v1/assistants/bob-2/feature-flags/voice-mode",
|
|
150
150
|
);
|
|
151
151
|
});
|
|
152
152
|
|
|
@@ -166,7 +166,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
166
166
|
"--assistant",
|
|
167
167
|
"Bob",
|
|
168
168
|
"set",
|
|
169
|
-
"
|
|
169
|
+
"voice-mode",
|
|
170
170
|
"true",
|
|
171
171
|
];
|
|
172
172
|
|
|
@@ -174,7 +174,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
174
174
|
|
|
175
175
|
expect(fetchCalls.length).toBe(1);
|
|
176
176
|
expect(fetchCalls[0].url).toContain(
|
|
177
|
-
"/v1/assistants/bob-2/feature-flags/
|
|
177
|
+
"/v1/assistants/bob-2/feature-flags/voice-mode",
|
|
178
178
|
);
|
|
179
179
|
});
|
|
180
180
|
|
|
@@ -193,7 +193,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
193
193
|
"vellum",
|
|
194
194
|
"flags",
|
|
195
195
|
"set",
|
|
196
|
-
"
|
|
196
|
+
"voice-mode",
|
|
197
197
|
"true",
|
|
198
198
|
];
|
|
199
199
|
|
|
@@ -203,7 +203,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
203
203
|
// alice-1 has assistantId.length === 7, so port = 7800 + 7 = 7807.
|
|
204
204
|
expect(fetchCalls[0].url).toContain("http://127.0.0.1:7807");
|
|
205
205
|
expect(fetchCalls[0].url).toContain(
|
|
206
|
-
"/v1/assistants/alice-1/feature-flags/
|
|
206
|
+
"/v1/assistants/alice-1/feature-flags/voice-mode",
|
|
207
207
|
);
|
|
208
208
|
});
|
|
209
209
|
|
|
@@ -214,7 +214,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
214
214
|
"vellum",
|
|
215
215
|
"flags",
|
|
216
216
|
"set",
|
|
217
|
-
"
|
|
217
|
+
"voice-mode",
|
|
218
218
|
"true",
|
|
219
219
|
"--assistant",
|
|
220
220
|
"Ghost",
|
|
@@ -234,7 +234,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
234
234
|
"vellum",
|
|
235
235
|
"flags",
|
|
236
236
|
"set",
|
|
237
|
-
"
|
|
237
|
+
"voice-mode",
|
|
238
238
|
"true",
|
|
239
239
|
"--assistant",
|
|
240
240
|
];
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
existsSync,
|
|
14
14
|
mkdirSync,
|
|
15
15
|
mkdtempSync,
|
|
16
|
+
renameSync,
|
|
16
17
|
rmSync,
|
|
17
18
|
writeFileSync,
|
|
18
19
|
} from "node:fs";
|
|
@@ -220,37 +221,43 @@ describe("recover error cases", () => {
|
|
|
220
221
|
describe("recover extraction path — default instance (instanceDir === homedir())", () => {
|
|
221
222
|
test("extracts to retiredDir and renames staging dir to instanceDir/.vellum", async () => {
|
|
222
223
|
const name = "default-instance";
|
|
223
|
-
// Default instance: instanceDir is the real home directory
|
|
224
224
|
const entry = makeEntry(name, homedir());
|
|
225
225
|
const { archivePath, extractedPath } = writeArchiveFixtures(name, entry);
|
|
226
226
|
|
|
227
227
|
const expectedTargetDir = join(homedir(), ".vellum");
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
228
|
+
// If a real ~/.vellum exists (e.g. the machine runs a live assistant),
|
|
229
|
+
// temporarily move it aside so the collision guard doesn't fire.
|
|
230
|
+
const backupDir = join(homedir(), ".vellum-recover-test-bak");
|
|
231
|
+
const hadExisting = existsSync(expectedTargetDir);
|
|
232
|
+
if (hadExisting) renameSync(expectedTargetDir, backupDir);
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
process.argv = ["bun", "vellum", "recover", name];
|
|
236
|
+
await recover();
|
|
237
|
+
|
|
238
|
+
// exec must have been called with -C retiredDir, NOT -C homedir()
|
|
239
|
+
expect(execMock).toHaveBeenCalledTimes(1);
|
|
240
|
+
const [cmd, args] = execMock.mock.calls[0] as [string, string[]];
|
|
241
|
+
expect(cmd).toBe("tar");
|
|
242
|
+
expect(args).toContain("-C");
|
|
243
|
+
const cIndex = args.indexOf("-C");
|
|
244
|
+
expect(args[cIndex + 1]).toBe(retiredDir);
|
|
245
|
+
expect(args[cIndex + 1]).not.toBe(homedir());
|
|
246
|
+
|
|
247
|
+
// Staging dir was renamed to the correct target
|
|
248
|
+
expect(existsSync(extractedPath)).toBe(false);
|
|
249
|
+
expect(existsSync(expectedTargetDir)).toBe(true);
|
|
250
|
+
|
|
251
|
+
// Archive and metadata were cleaned up
|
|
252
|
+
expect(existsSync(archivePath)).toBe(false);
|
|
253
|
+
|
|
254
|
+
// Daemon and gateway were started
|
|
255
|
+
expect(startLocalDaemonMock).toHaveBeenCalledTimes(1);
|
|
256
|
+
expect(startGatewayMock).toHaveBeenCalledTimes(1);
|
|
257
|
+
} finally {
|
|
258
|
+
rmSync(expectedTargetDir, { recursive: true, force: true });
|
|
259
|
+
if (hadExisting) renameSync(backupDir, expectedTargetDir);
|
|
260
|
+
}
|
|
254
261
|
});
|
|
255
262
|
});
|
|
256
263
|
|