@vellumai/cli 0.6.6 → 0.7.1

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.
Files changed (61) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +49 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-config.test.ts +1 -7
  5. package/src/__tests__/backup.test.ts +475 -0
  6. package/src/__tests__/config-utils.test.ts +146 -0
  7. package/src/__tests__/env-drift.test.ts +10 -32
  8. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  9. package/src/__tests__/multi-local.test.ts +0 -5
  10. package/src/__tests__/sleep.test.ts +1 -2
  11. package/src/__tests__/teleport.test.ts +988 -1266
  12. package/src/commands/backup.ts +117 -71
  13. package/src/commands/client.ts +10 -9
  14. package/src/commands/env.ts +93 -0
  15. package/src/commands/events.ts +2 -0
  16. package/src/commands/exec.ts +58 -13
  17. package/src/commands/login.ts +77 -12
  18. package/src/commands/logs.ts +2 -7
  19. package/src/commands/ps.ts +144 -25
  20. package/src/commands/restore.ts +26 -47
  21. package/src/commands/sleep.ts +5 -2
  22. package/src/commands/ssh.ts +17 -7
  23. package/src/commands/teleport.ts +462 -584
  24. package/src/commands/terminal.ts +9 -221
  25. package/src/commands/tunnel.ts +2 -7
  26. package/src/commands/upgrade.ts +108 -7
  27. package/src/commands/wake.ts +2 -1
  28. package/src/components/DefaultMainScreen.tsx +328 -154
  29. package/src/index.ts +5 -7
  30. package/src/lib/__tests__/docker.test.ts +50 -74
  31. package/src/lib/__tests__/job-polling.test.ts +278 -0
  32. package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
  33. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  34. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  35. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  36. package/src/lib/assistant-client.ts +5 -21
  37. package/src/lib/assistant-config.ts +46 -24
  38. package/src/lib/cli-error.ts +1 -0
  39. package/src/lib/client-identity.ts +67 -0
  40. package/src/lib/docker.ts +75 -77
  41. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  42. package/src/lib/environments/resolve.ts +89 -7
  43. package/src/lib/environments/seeds.ts +8 -5
  44. package/src/lib/environments/types.ts +10 -0
  45. package/src/lib/hatch-local.ts +15 -120
  46. package/src/lib/health-check.ts +98 -0
  47. package/src/lib/job-polling.ts +195 -0
  48. package/src/lib/local-runtime-client.ts +231 -0
  49. package/src/lib/local.ts +165 -72
  50. package/src/lib/orphan-detection.ts +2 -35
  51. package/src/lib/platform-client.ts +190 -194
  52. package/src/lib/platform-releases.ts +23 -0
  53. package/src/lib/retire-local.ts +6 -2
  54. package/src/lib/runtime-url.ts +30 -0
  55. package/src/lib/sync-cloud-assistants.ts +126 -0
  56. package/src/lib/terminal-client.ts +6 -1
  57. package/src/lib/terminal-session.ts +536 -0
  58. package/src/lib/tui-log.ts +60 -0
  59. package/src/lib/xdg-log.ts +10 -4
  60. package/src/shared/provider-env-vars.ts +2 -3
  61. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -0,0 +1,480 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { AssistantEntry } from "../assistant-config.js";
4
+ import {
5
+ MigrationInProgressError,
6
+ localRuntimeExportToGcs,
7
+ localRuntimeImportFromGcs,
8
+ localRuntimePollJobStatus,
9
+ } from "../local-runtime-client.js";
10
+
11
+ const RUNTIME_URL = "http://127.0.0.1:8765";
12
+ const TOKEN = "local-bearer-token";
13
+
14
+ // All tests in this file exercise the local/docker code path (cloud="local"),
15
+ // which builds `{runtimeUrl}/v1/migrations/<subpath>` URLs and uses
16
+ // guardian-token bearer auth. The platform path (cloud="vellum") is covered
17
+ // by `runtime-url.test.ts` (URL construction) and the teleport tests
18
+ // (call-site wiring).
19
+ const ENTRY: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId"> = {
20
+ cloud: "local",
21
+ runtimeUrl: RUNTIME_URL,
22
+ assistantId: "ast-test-1",
23
+ };
24
+
25
+ interface CapturedCall {
26
+ url: string;
27
+ method: string;
28
+ headers: Record<string, string>;
29
+ body: unknown;
30
+ }
31
+
32
+ function captureFetch(
33
+ responder: (call: CapturedCall) => Response | Promise<Response>,
34
+ ): {
35
+ calls: CapturedCall[];
36
+ fetchMock: typeof globalThis.fetch;
37
+ } {
38
+ const calls: CapturedCall[] = [];
39
+ const fetchMock = mock(
40
+ async (url: string | URL | Request, init?: RequestInit) => {
41
+ const urlStr = typeof url === "string" ? url : url.toString();
42
+ const rawHeaders = (init?.headers ?? {}) as
43
+ | Record<string, string>
44
+ | Headers;
45
+ const headers: Record<string, string> = {};
46
+ if (rawHeaders instanceof Headers) {
47
+ rawHeaders.forEach((v, k) => {
48
+ headers[k] = v;
49
+ });
50
+ } else {
51
+ Object.assign(headers, rawHeaders);
52
+ }
53
+ let parsedBody: unknown = undefined;
54
+ const b = init?.body;
55
+ if (typeof b === "string") {
56
+ try {
57
+ parsedBody = JSON.parse(b);
58
+ } catch {
59
+ parsedBody = b;
60
+ }
61
+ }
62
+ const call: CapturedCall = {
63
+ url: urlStr,
64
+ method: init?.method ?? "GET",
65
+ headers,
66
+ body: parsedBody,
67
+ };
68
+ calls.push(call);
69
+ return responder(call);
70
+ },
71
+ );
72
+ return { calls, fetchMock: fetchMock as unknown as typeof globalThis.fetch };
73
+ }
74
+
75
+ let originalFetch: typeof globalThis.fetch;
76
+ beforeEach(() => {
77
+ originalFetch = globalThis.fetch;
78
+ });
79
+ afterEach(() => {
80
+ globalThis.fetch = originalFetch;
81
+ });
82
+
83
+ describe("localRuntimeExportToGcs", () => {
84
+ test("POSTs {upload_url, description} with Bearer auth and returns job_id on 202", async () => {
85
+ const { calls, fetchMock } = captureFetch(() => {
86
+ return new Response(
87
+ JSON.stringify({
88
+ job_id: "export-job-1",
89
+ status: "pending",
90
+ type: "export",
91
+ }),
92
+ { status: 202, headers: { "Content-Type": "application/json" } },
93
+ );
94
+ });
95
+ globalThis.fetch = fetchMock;
96
+
97
+ const result = await localRuntimeExportToGcs(ENTRY, TOKEN, {
98
+ uploadUrl: "https://storage.example/signed/abc",
99
+ description: "teleport export",
100
+ });
101
+
102
+ expect(result.jobId).toBe("export-job-1");
103
+ expect(calls).toHaveLength(1);
104
+ expect(calls[0]!.url).toBe(`${RUNTIME_URL}/v1/migrations/export-to-gcs`);
105
+ expect(calls[0]!.method).toBe("POST");
106
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${TOKEN}`);
107
+ expect(calls[0]!.headers["Content-Type"]).toBe("application/json");
108
+ expect(calls[0]!.body).toEqual({
109
+ upload_url: "https://storage.example/signed/abc",
110
+ description: "teleport export",
111
+ });
112
+ });
113
+
114
+ test("omits description when not provided", async () => {
115
+ const { calls, fetchMock } = captureFetch(() => {
116
+ return new Response(
117
+ JSON.stringify({ job_id: "j", status: "pending", type: "export" }),
118
+ { status: 202 },
119
+ );
120
+ });
121
+ globalThis.fetch = fetchMock;
122
+
123
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
124
+ uploadUrl: "https://storage.example/signed/abc",
125
+ });
126
+
127
+ expect(calls[0]!.body).toEqual({
128
+ upload_url: "https://storage.example/signed/abc",
129
+ });
130
+ });
131
+
132
+ test("409 export_in_progress (nested {error:{code,job_id}}) throws MigrationInProgressError carrying existing job_id", async () => {
133
+ const { fetchMock } = captureFetch(() => {
134
+ return new Response(
135
+ JSON.stringify({
136
+ error: {
137
+ code: "export_in_progress",
138
+ job_id: "existing-export-42",
139
+ },
140
+ }),
141
+ { status: 409 },
142
+ );
143
+ });
144
+ globalThis.fetch = fetchMock;
145
+
146
+ try {
147
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
148
+ uploadUrl: "https://storage.example/signed/abc",
149
+ });
150
+ throw new Error("expected to throw");
151
+ } catch (err) {
152
+ expect(err).toBeInstanceOf(MigrationInProgressError);
153
+ const mip = err as MigrationInProgressError;
154
+ expect(mip.kind).toBe("export_in_progress");
155
+ expect(mip.existingJobId).toBe("existing-export-42");
156
+ }
157
+ });
158
+
159
+ test("409 export_in_progress regression: nested job_id 'abc-123' is surfaced (not empty)", async () => {
160
+ const { fetchMock } = captureFetch(() => {
161
+ return new Response(
162
+ JSON.stringify({
163
+ error: { code: "export_in_progress", job_id: "abc-123" },
164
+ }),
165
+ { status: 409 },
166
+ );
167
+ });
168
+ globalThis.fetch = fetchMock;
169
+
170
+ try {
171
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
172
+ uploadUrl: "https://storage.example/signed/abc",
173
+ });
174
+ throw new Error("expected to throw");
175
+ } catch (err) {
176
+ expect(err).toBeInstanceOf(MigrationInProgressError);
177
+ const mip = err as MigrationInProgressError;
178
+ expect(mip.existingJobId).toBe("abc-123");
179
+ expect(mip.existingJobId).not.toBe("");
180
+ expect(mip.kind).toBe("export_in_progress");
181
+ }
182
+ });
183
+
184
+ test("409 export_in_progress with legacy flat shape is still parsed", async () => {
185
+ const { fetchMock } = captureFetch(() => {
186
+ return new Response(
187
+ JSON.stringify({
188
+ code: "export_in_progress",
189
+ job_id: "legacy-export-9",
190
+ }),
191
+ { status: 409 },
192
+ );
193
+ });
194
+ globalThis.fetch = fetchMock;
195
+
196
+ try {
197
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
198
+ uploadUrl: "https://storage.example/signed/abc",
199
+ });
200
+ throw new Error("expected to throw");
201
+ } catch (err) {
202
+ expect(err).toBeInstanceOf(MigrationInProgressError);
203
+ const mip = err as MigrationInProgressError;
204
+ expect(mip.kind).toBe("export_in_progress");
205
+ expect(mip.existingJobId).toBe("legacy-export-9");
206
+ }
207
+ });
208
+
209
+ test("non-202 non-409 responses throw with status + body", async () => {
210
+ const { fetchMock } = captureFetch(() => {
211
+ return new Response("boom", { status: 500 });
212
+ });
213
+ globalThis.fetch = fetchMock;
214
+
215
+ await expect(
216
+ localRuntimeExportToGcs(ENTRY, TOKEN, {
217
+ uploadUrl: "https://storage.example/signed/abc",
218
+ }),
219
+ ).rejects.toThrow(/500/);
220
+ });
221
+ });
222
+
223
+ describe("localRuntimeImportFromGcs", () => {
224
+ test("POSTs {bundle_url} with Bearer auth and returns job_id on 202", async () => {
225
+ const { calls, fetchMock } = captureFetch(() => {
226
+ return new Response(
227
+ JSON.stringify({
228
+ job_id: "import-job-1",
229
+ status: "pending",
230
+ type: "import",
231
+ }),
232
+ { status: 202 },
233
+ );
234
+ });
235
+ globalThis.fetch = fetchMock;
236
+
237
+ const result = await localRuntimeImportFromGcs(ENTRY, TOKEN, {
238
+ bundleUrl: "https://storage.example/signed/dl-xyz",
239
+ });
240
+
241
+ expect(result.jobId).toBe("import-job-1");
242
+ expect(calls[0]!.url).toBe(`${RUNTIME_URL}/v1/migrations/import-from-gcs`);
243
+ expect(calls[0]!.method).toBe("POST");
244
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${TOKEN}`);
245
+ expect(calls[0]!.body).toEqual({
246
+ bundle_url: "https://storage.example/signed/dl-xyz",
247
+ });
248
+ });
249
+
250
+ test("409 import_in_progress (nested {error:{code,job_id}}) throws MigrationInProgressError carrying existing job_id", async () => {
251
+ const { fetchMock } = captureFetch(() => {
252
+ return new Response(
253
+ JSON.stringify({
254
+ error: {
255
+ code: "import_in_progress",
256
+ job_id: "existing-import-7",
257
+ },
258
+ }),
259
+ { status: 409 },
260
+ );
261
+ });
262
+ globalThis.fetch = fetchMock;
263
+
264
+ try {
265
+ await localRuntimeImportFromGcs(ENTRY, TOKEN, {
266
+ bundleUrl: "https://storage.example/signed/dl-xyz",
267
+ });
268
+ throw new Error("expected to throw");
269
+ } catch (err) {
270
+ expect(err).toBeInstanceOf(MigrationInProgressError);
271
+ const mip = err as MigrationInProgressError;
272
+ expect(mip.kind).toBe("import_in_progress");
273
+ expect(mip.existingJobId).toBe("existing-import-7");
274
+ }
275
+ });
276
+
277
+ test("409 import_in_progress with legacy flat shape is still parsed", async () => {
278
+ const { fetchMock } = captureFetch(() => {
279
+ return new Response(
280
+ JSON.stringify({
281
+ code: "import_in_progress",
282
+ job_id: "legacy-import-2",
283
+ }),
284
+ { status: 409 },
285
+ );
286
+ });
287
+ globalThis.fetch = fetchMock;
288
+
289
+ try {
290
+ await localRuntimeImportFromGcs(ENTRY, TOKEN, {
291
+ bundleUrl: "https://storage.example/signed/dl-xyz",
292
+ });
293
+ throw new Error("expected to throw");
294
+ } catch (err) {
295
+ expect(err).toBeInstanceOf(MigrationInProgressError);
296
+ const mip = err as MigrationInProgressError;
297
+ expect(mip.kind).toBe("import_in_progress");
298
+ expect(mip.existingJobId).toBe("legacy-import-2");
299
+ }
300
+ });
301
+ });
302
+
303
+ describe("localRuntimePollJobStatus", () => {
304
+ test("GETs /v1/migrations/jobs/{jobId} with Bearer auth and parses processing", async () => {
305
+ const { calls, fetchMock } = captureFetch(() => {
306
+ return new Response(
307
+ JSON.stringify({
308
+ job_id: "poll-1",
309
+ type: "export",
310
+ status: "processing",
311
+ }),
312
+ { status: 200 },
313
+ );
314
+ });
315
+ globalThis.fetch = fetchMock;
316
+
317
+ const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-1");
318
+
319
+ expect(status).toEqual({
320
+ jobId: "poll-1",
321
+ type: "export",
322
+ status: "processing",
323
+ });
324
+ expect(calls[0]!.url).toBe(`${RUNTIME_URL}/v1/migrations/jobs/poll-1`);
325
+ expect(calls[0]!.method).toBe("GET");
326
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${TOKEN}`);
327
+ });
328
+
329
+ test("parses complete with bundle_key", async () => {
330
+ const { fetchMock } = captureFetch(() => {
331
+ return new Response(
332
+ JSON.stringify({
333
+ job_id: "poll-2",
334
+ type: "export",
335
+ status: "complete",
336
+ bundle_key: "bundles/x.tar.gz",
337
+ }),
338
+ { status: 200 },
339
+ );
340
+ });
341
+ globalThis.fetch = fetchMock;
342
+
343
+ const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-2");
344
+
345
+ expect(status.status).toBe("complete");
346
+ if (status.status === "complete") {
347
+ expect(status.bundleKey).toBe("bundles/x.tar.gz");
348
+ }
349
+ });
350
+
351
+ test("parses failed with error", async () => {
352
+ const { fetchMock } = captureFetch(() => {
353
+ return new Response(
354
+ JSON.stringify({
355
+ job_id: "poll-3",
356
+ type: "import",
357
+ status: "failed",
358
+ error: "corrupted bundle",
359
+ }),
360
+ { status: 200 },
361
+ );
362
+ });
363
+ globalThis.fetch = fetchMock;
364
+
365
+ const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-3");
366
+
367
+ expect(status.status).toBe("failed");
368
+ if (status.status === "failed") {
369
+ expect(status.error).toBe("corrupted bundle");
370
+ }
371
+ });
372
+
373
+ test("404 → throws 'Migration job not found'", async () => {
374
+ const { fetchMock } = captureFetch(() => {
375
+ return new Response("{}", { status: 404 });
376
+ });
377
+ globalThis.fetch = fetchMock;
378
+
379
+ await expect(
380
+ localRuntimePollJobStatus(ENTRY, TOKEN, "missing"),
381
+ ).rejects.toThrow(/Migration job not found/);
382
+ });
383
+ });
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Platform-managed assistants (cloud="vellum") route through the platform's
387
+ // wildcard runtime proxy at `/v1/assistants/<id>/migrations/...` with
388
+ // platform-token auth (NOT guardian-token bearer). This block asserts the
389
+ // actual URL and headers built by the helpers — not mocked, not abstracted.
390
+ // Regression guard for the routing bug fixed in this PR.
391
+ // ---------------------------------------------------------------------------
392
+ const VELLUM_ENTRY: Pick<
393
+ AssistantEntry,
394
+ "cloud" | "runtimeUrl" | "assistantId"
395
+ > = {
396
+ cloud: "vellum",
397
+ runtimeUrl: "https://platform.vellum.ai",
398
+ assistantId: "11111111-2222-3333-4444-555555555555",
399
+ };
400
+ // `vak_` prefix bypasses `fetchOrganizationId` (org-scoped API keys); the
401
+ // auth header collapses to a single `Authorization: Bearer vak_...` so this
402
+ // test stays free of network mocks.
403
+ const VAK_TOKEN = "vak_platform-token";
404
+
405
+ describe("vellum-cloud routing through wildcard proxy", () => {
406
+ test("export-to-gcs URL has /v1/assistants/<id>/migrations/ prefix and uses platform-token bearer (no guardian)", async () => {
407
+ const { calls, fetchMock } = captureFetch(() => {
408
+ return new Response(
409
+ JSON.stringify({ job_id: "wp-export-1", status: "pending" }),
410
+ { status: 202, headers: { "Content-Type": "application/json" } },
411
+ );
412
+ });
413
+ globalThis.fetch = fetchMock;
414
+
415
+ const result = await localRuntimeExportToGcs(VELLUM_ENTRY, VAK_TOKEN, {
416
+ uploadUrl: "https://storage.example/signed/x",
417
+ description: "teleport export",
418
+ });
419
+
420
+ expect(result.jobId).toBe("wp-export-1");
421
+ expect(calls[0]!.url).toBe(
422
+ `https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/export-to-gcs`,
423
+ );
424
+ expect(calls[0]!.method).toBe("POST");
425
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
426
+ expect(calls[0]!.body).toEqual({
427
+ upload_url: "https://storage.example/signed/x",
428
+ description: "teleport export",
429
+ });
430
+ });
431
+
432
+ test("import-from-gcs URL has /v1/assistants/<id>/migrations/ prefix", async () => {
433
+ const { calls, fetchMock } = captureFetch(() => {
434
+ return new Response(
435
+ JSON.stringify({ job_id: "wp-import-1", status: "pending" }),
436
+ { status: 202 },
437
+ );
438
+ });
439
+ globalThis.fetch = fetchMock;
440
+
441
+ await localRuntimeImportFromGcs(VELLUM_ENTRY, VAK_TOKEN, {
442
+ bundleUrl: "https://storage.example/download/y",
443
+ });
444
+
445
+ expect(calls[0]!.url).toBe(
446
+ `https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/import-from-gcs`,
447
+ );
448
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
449
+ });
450
+
451
+ test("jobs/<id> URL has /v1/assistants/<id>/migrations/ prefix (NOT the dedicated platform endpoint)", async () => {
452
+ const { calls, fetchMock } = captureFetch(() => {
453
+ return new Response(
454
+ JSON.stringify({
455
+ job_id: "wp-export-1",
456
+ status: "complete",
457
+ type: "export",
458
+ bundle_key: "exports/org-1/x.vbundle",
459
+ }),
460
+ { status: 200 },
461
+ );
462
+ });
463
+ globalThis.fetch = fetchMock;
464
+
465
+ const status = await localRuntimePollJobStatus(
466
+ VELLUM_ENTRY,
467
+ VAK_TOKEN,
468
+ "wp-export-1",
469
+ );
470
+
471
+ expect(calls[0]!.url).toBe(
472
+ `https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/jobs/wp-export-1`,
473
+ );
474
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
475
+ expect(status.status).toBe("complete");
476
+ if (status.status === "complete") {
477
+ expect(status.bundleKey).toBe("exports/org-1/x.vbundle");
478
+ }
479
+ });
480
+ });