@vellumai/cli 0.5.13 → 0.5.15

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.
@@ -0,0 +1,829 @@
1
+ import {
2
+ afterAll,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ mock,
7
+ spyOn,
8
+ test,
9
+ } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Temp directory for lockfile isolation (same pattern as assistant-config.test.ts)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const testDir = mkdtempSync(join(tmpdir(), "cli-teleport-test-"));
19
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Mocks — must be set up before importing the module under test
23
+ // ---------------------------------------------------------------------------
24
+
25
+ // Import the real assistant-config module — do NOT mock it with mock.module()
26
+ // because Bun's mock.module() replaces the module globally and leaks into
27
+ // other test files (e.g. multi-local.test.ts) running in the same process.
28
+ // Instead, we use spyOn to mock findAssistantByName on the imported module object.
29
+ import * as assistantConfig from "../lib/assistant-config.js";
30
+
31
+ const findAssistantByNameMock = spyOn(
32
+ assistantConfig,
33
+ "findAssistantByName",
34
+ ).mockReturnValue(null);
35
+
36
+ const loadGuardianTokenMock = mock((_id: string) => ({
37
+ accessToken: "local-token",
38
+ accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
39
+ }));
40
+ const leaseGuardianTokenMock = mock(async () => ({
41
+ accessToken: "leased-token",
42
+ accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
43
+ }));
44
+
45
+ mock.module("../lib/guardian-token.js", () => ({
46
+ loadGuardianToken: loadGuardianTokenMock,
47
+ leaseGuardianToken: leaseGuardianTokenMock,
48
+ }));
49
+
50
+ const readPlatformTokenMock = mock((): string | null => "platform-token");
51
+ const fetchOrganizationIdMock = mock(async () => "org-123");
52
+ const platformInitiateExportMock = mock(async () => ({
53
+ jobId: "job-1",
54
+ status: "pending",
55
+ }));
56
+ const platformPollExportStatusMock = mock(async () => ({
57
+ status: "complete" as string,
58
+ downloadUrl: "https://cdn.example.com/bundle.tar.gz",
59
+ }));
60
+ const platformDownloadExportMock = mock(async () => {
61
+ const data = new Uint8Array([10, 20, 30]);
62
+ return new Response(data, { status: 200 });
63
+ });
64
+ const platformImportPreflightMock = mock(async () => ({
65
+ statusCode: 200,
66
+ body: {
67
+ can_import: true,
68
+ summary: {
69
+ files_to_create: 2,
70
+ files_to_overwrite: 1,
71
+ files_unchanged: 0,
72
+ total_files: 3,
73
+ },
74
+ } as Record<string, unknown>,
75
+ }));
76
+ const platformImportBundleMock = mock(async () => ({
77
+ statusCode: 200,
78
+ body: {
79
+ success: true,
80
+ summary: {
81
+ total_files: 3,
82
+ files_created: 2,
83
+ files_overwritten: 1,
84
+ files_skipped: 0,
85
+ backups_created: 1,
86
+ },
87
+ } as Record<string, unknown>,
88
+ }));
89
+
90
+ mock.module("../lib/platform-client.js", () => ({
91
+ readPlatformToken: readPlatformTokenMock,
92
+ fetchOrganizationId: fetchOrganizationIdMock,
93
+ platformInitiateExport: platformInitiateExportMock,
94
+ platformPollExportStatus: platformPollExportStatusMock,
95
+ platformDownloadExport: platformDownloadExportMock,
96
+ platformImportPreflight: platformImportPreflightMock,
97
+ platformImportBundle: platformImportBundleMock,
98
+ }));
99
+
100
+ import { teleport } from "../commands/teleport.js";
101
+ import type { AssistantEntry } from "../lib/assistant-config.js";
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Helpers
105
+ // ---------------------------------------------------------------------------
106
+
107
+ afterAll(() => {
108
+ findAssistantByNameMock.mockRestore();
109
+ rmSync(testDir, { recursive: true, force: true });
110
+ delete process.env.VELLUM_LOCKFILE_DIR;
111
+ });
112
+
113
+ let originalArgv: string[];
114
+ let exitMock: ReturnType<typeof mock>;
115
+ let originalExit: typeof process.exit;
116
+ let consoleLogSpy: ReturnType<typeof spyOn>;
117
+ let consoleErrorSpy: ReturnType<typeof spyOn>;
118
+
119
+ beforeEach(() => {
120
+ originalArgv = [...process.argv];
121
+
122
+ // Reset all mocks
123
+ findAssistantByNameMock.mockReset();
124
+ findAssistantByNameMock.mockReturnValue(null);
125
+
126
+ loadGuardianTokenMock.mockReset();
127
+ loadGuardianTokenMock.mockReturnValue({
128
+ accessToken: "local-token",
129
+ accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
130
+ });
131
+ leaseGuardianTokenMock.mockReset();
132
+
133
+ readPlatformTokenMock.mockReset();
134
+ readPlatformTokenMock.mockReturnValue("platform-token");
135
+ fetchOrganizationIdMock.mockReset();
136
+ fetchOrganizationIdMock.mockResolvedValue("org-123");
137
+ platformInitiateExportMock.mockReset();
138
+ platformInitiateExportMock.mockResolvedValue({
139
+ jobId: "job-1",
140
+ status: "pending",
141
+ });
142
+ platformPollExportStatusMock.mockReset();
143
+ platformPollExportStatusMock.mockResolvedValue({
144
+ status: "complete",
145
+ downloadUrl: "https://cdn.example.com/bundle.tar.gz",
146
+ });
147
+ platformDownloadExportMock.mockReset();
148
+ platformDownloadExportMock.mockResolvedValue(
149
+ new Response(new Uint8Array([10, 20, 30]), { status: 200 }),
150
+ );
151
+ platformImportPreflightMock.mockReset();
152
+ platformImportPreflightMock.mockResolvedValue({
153
+ statusCode: 200,
154
+ body: {
155
+ can_import: true,
156
+ summary: {
157
+ files_to_create: 2,
158
+ files_to_overwrite: 1,
159
+ files_unchanged: 0,
160
+ total_files: 3,
161
+ },
162
+ },
163
+ });
164
+ platformImportBundleMock.mockReset();
165
+ platformImportBundleMock.mockResolvedValue({
166
+ statusCode: 200,
167
+ body: {
168
+ success: true,
169
+ summary: {
170
+ total_files: 3,
171
+ files_created: 2,
172
+ files_overwritten: 1,
173
+ files_skipped: 0,
174
+ backups_created: 1,
175
+ },
176
+ },
177
+ });
178
+
179
+ // Mock process.exit to throw so we can catch it
180
+ exitMock = mock((code?: number) => {
181
+ throw new Error(`process.exit:${code}`);
182
+ });
183
+ originalExit = process.exit;
184
+ process.exit = exitMock as unknown as typeof process.exit;
185
+
186
+ consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
187
+ consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
188
+ });
189
+
190
+ import { afterEach } from "bun:test";
191
+
192
+ afterEach(() => {
193
+ process.argv = originalArgv;
194
+ process.exit = originalExit;
195
+ consoleLogSpy.mockRestore();
196
+ consoleErrorSpy.mockRestore();
197
+ });
198
+
199
+ function setArgv(...args: string[]): void {
200
+ // teleport reads process.argv.slice(3)
201
+ process.argv = ["bun", "vellum", "teleport", ...args];
202
+ }
203
+
204
+ function makeEntry(
205
+ id: string,
206
+ overrides?: Partial<AssistantEntry>,
207
+ ): AssistantEntry {
208
+ return {
209
+ assistantId: id,
210
+ runtimeUrl: "http://localhost:7821",
211
+ cloud: "local",
212
+ ...overrides,
213
+ };
214
+ }
215
+
216
+ /** Extract the URL string from a fetch mock call argument. */
217
+ function extractUrl(arg: unknown): string {
218
+ if (typeof arg === "string") return arg;
219
+ if (arg && typeof arg === "object" && "url" in arg) {
220
+ return (arg as { url: string }).url;
221
+ }
222
+ return String(arg);
223
+ }
224
+
225
+ /** Filter fetch mock calls by URL substring. */
226
+ function filterFetchCalls(
227
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
228
+ fetchMock: { mock: { calls: any[][] } },
229
+ substring: string,
230
+ ): unknown[][] {
231
+ return fetchMock.mock.calls.filter((call: unknown[]) =>
232
+ extractUrl(call[0]).includes(substring),
233
+ );
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Arg parsing tests
238
+ // ---------------------------------------------------------------------------
239
+
240
+ describe("teleport arg parsing", () => {
241
+ test("--help prints usage and exits 0", async () => {
242
+ setArgv("--help");
243
+ await expect(teleport()).rejects.toThrow("process.exit:0");
244
+ expect(consoleLogSpy).toHaveBeenCalledWith(
245
+ expect.stringContaining("Usage:"),
246
+ );
247
+ });
248
+
249
+ test("-h prints usage and exits 0", async () => {
250
+ setArgv("-h");
251
+ await expect(teleport()).rejects.toThrow("process.exit:0");
252
+ expect(consoleLogSpy).toHaveBeenCalledWith(
253
+ expect.stringContaining("Usage:"),
254
+ );
255
+ });
256
+
257
+ test("missing --from and --to prints help and exits 1", async () => {
258
+ setArgv();
259
+ await expect(teleport()).rejects.toThrow("process.exit:1");
260
+ expect(consoleLogSpy).toHaveBeenCalledWith(
261
+ expect.stringContaining("Usage:"),
262
+ );
263
+ });
264
+
265
+ test("missing --to prints help and exits 1", async () => {
266
+ setArgv("--from", "source");
267
+ await expect(teleport()).rejects.toThrow("process.exit:1");
268
+ expect(consoleLogSpy).toHaveBeenCalledWith(
269
+ expect.stringContaining("Usage:"),
270
+ );
271
+ });
272
+
273
+ test("missing --from prints help and exits 1", async () => {
274
+ setArgv("--to", "target");
275
+ await expect(teleport()).rejects.toThrow("process.exit:1");
276
+ expect(consoleLogSpy).toHaveBeenCalledWith(
277
+ expect.stringContaining("Usage:"),
278
+ );
279
+ });
280
+
281
+ test("--from and --to are correctly parsed", async () => {
282
+ setArgv("--from", "source", "--to", "target");
283
+
284
+ findAssistantByNameMock.mockImplementation((name: string) => {
285
+ if (name === "source") return makeEntry("source");
286
+ if (name === "target") return makeEntry("target");
287
+ return null;
288
+ });
289
+
290
+ // This will attempt a fetch to the local export endpoint, which will fail.
291
+ // We just want to confirm parsing worked (i.e. it gets past the arg check).
292
+ // Mock global fetch to avoid network calls.
293
+ const originalFetch = globalThis.fetch;
294
+ const fetchMock = mock(async (url: string | URL | Request) => {
295
+ const urlStr = typeof url === "string" ? url : url.toString();
296
+ if (urlStr.includes("/export")) {
297
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
298
+ }
299
+ // import endpoint — return valid JSON
300
+ return new Response(
301
+ JSON.stringify({
302
+ success: true,
303
+ summary: {
304
+ total_files: 1,
305
+ files_created: 1,
306
+ files_overwritten: 0,
307
+ files_skipped: 0,
308
+ backups_created: 0,
309
+ },
310
+ }),
311
+ { status: 200, headers: { "Content-Type": "application/json" } },
312
+ );
313
+ });
314
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
315
+
316
+ try {
317
+ await teleport();
318
+ // Should call findAssistantByName for both source and target
319
+ expect(findAssistantByNameMock).toHaveBeenCalledWith("source");
320
+ expect(findAssistantByNameMock).toHaveBeenCalledWith("target");
321
+ } finally {
322
+ globalThis.fetch = originalFetch;
323
+ }
324
+ });
325
+
326
+ test("--dry-run flag is detected", async () => {
327
+ setArgv("--from", "source", "--to", "target", "--dry-run");
328
+
329
+ findAssistantByNameMock.mockImplementation((name: string) => {
330
+ if (name === "source") return makeEntry("source");
331
+ if (name === "target") return makeEntry("target");
332
+ return null;
333
+ });
334
+
335
+ const originalFetch = globalThis.fetch;
336
+ const fetchMock = mock(async (url: string | URL | Request) => {
337
+ const urlStr = typeof url === "string" ? url : url.toString();
338
+ if (urlStr.includes("/export")) {
339
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
340
+ }
341
+ if (urlStr.includes("/import-preflight")) {
342
+ return new Response(
343
+ JSON.stringify({
344
+ can_import: true,
345
+ summary: {
346
+ files_to_create: 1,
347
+ files_to_overwrite: 0,
348
+ files_unchanged: 0,
349
+ total_files: 1,
350
+ },
351
+ }),
352
+ { status: 200, headers: { "Content-Type": "application/json" } },
353
+ );
354
+ }
355
+ return new Response("not found", { status: 404 });
356
+ });
357
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
358
+
359
+ try {
360
+ await teleport();
361
+ // In dry-run mode for local target, it should call the preflight endpoint
362
+ const preflightCalls = filterFetchCalls(fetchMock, "import-preflight");
363
+ expect(preflightCalls.length).toBe(1);
364
+ } finally {
365
+ globalThis.fetch = originalFetch;
366
+ }
367
+ });
368
+
369
+ test("unknown assistant name causes exit with error", async () => {
370
+ setArgv("--from", "nonexistent", "--to", "target");
371
+ findAssistantByNameMock.mockReturnValue(null);
372
+
373
+ await expect(teleport()).rejects.toThrow("process.exit:1");
374
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
375
+ expect.stringContaining("not found in lockfile"),
376
+ );
377
+ });
378
+ });
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Cloud resolution tests
382
+ // ---------------------------------------------------------------------------
383
+
384
+ describe("teleport cloud resolution", () => {
385
+ test("entry with cloud: 'vellum' resolves to vellum", async () => {
386
+ setArgv("--from", "src", "--to", "dst");
387
+
388
+ const srcEntry = makeEntry("src", {
389
+ cloud: "vellum",
390
+ runtimeUrl: "https://platform.vellum.ai",
391
+ });
392
+ const dstEntry = makeEntry("dst", { cloud: "local" });
393
+
394
+ findAssistantByNameMock.mockImplementation((name: string) => {
395
+ if (name === "src") return srcEntry;
396
+ if (name === "dst") return dstEntry;
397
+ return null;
398
+ });
399
+
400
+ // Platform export path: readPlatformToken → fetchOrganizationId → initiateExport → poll → download
401
+ // then local import
402
+ const originalFetch = globalThis.fetch;
403
+ const fetchMock = mock(async () => {
404
+ return new Response(
405
+ JSON.stringify({
406
+ success: true,
407
+ summary: {
408
+ total_files: 1,
409
+ files_created: 1,
410
+ files_overwritten: 0,
411
+ files_skipped: 0,
412
+ backups_created: 0,
413
+ },
414
+ }),
415
+ { status: 200, headers: { "Content-Type": "application/json" } },
416
+ );
417
+ });
418
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
419
+
420
+ try {
421
+ await teleport();
422
+ // Should have used platform export functions
423
+ expect(platformInitiateExportMock).toHaveBeenCalled();
424
+ expect(platformPollExportStatusMock).toHaveBeenCalled();
425
+ expect(platformDownloadExportMock).toHaveBeenCalled();
426
+ } finally {
427
+ globalThis.fetch = originalFetch;
428
+ }
429
+ });
430
+
431
+ test("entry with cloud: 'local' resolves to local", async () => {
432
+ setArgv("--from", "src", "--to", "dst");
433
+
434
+ const srcEntry = makeEntry("src", { cloud: "local" });
435
+ const dstEntry = makeEntry("dst", {
436
+ cloud: "vellum",
437
+ runtimeUrl: "https://platform.vellum.ai",
438
+ });
439
+
440
+ findAssistantByNameMock.mockImplementation((name: string) => {
441
+ if (name === "src") return srcEntry;
442
+ if (name === "dst") return dstEntry;
443
+ return null;
444
+ });
445
+
446
+ const originalFetch = globalThis.fetch;
447
+ const fetchMock = mock(async () => {
448
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
449
+ });
450
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
451
+
452
+ try {
453
+ await teleport();
454
+ // Local export uses fetch to /v1/migrations/export
455
+ const exportCalls = filterFetchCalls(fetchMock, "/v1/migrations/export");
456
+ expect(exportCalls.length).toBe(1);
457
+ // Platform import should be called
458
+ expect(platformImportBundleMock).toHaveBeenCalled();
459
+ } finally {
460
+ globalThis.fetch = originalFetch;
461
+ }
462
+ });
463
+
464
+ test("entry with no cloud but project set resolves to gcp (unsupported)", async () => {
465
+ setArgv("--from", "src", "--to", "dst");
466
+
467
+ // cloud is empty string or undefined — resolveCloud checks entry.project
468
+ const srcEntry = makeEntry("src", {
469
+ cloud: "" as string,
470
+ project: "my-gcp-project",
471
+ });
472
+ const dstEntry = makeEntry("dst", { cloud: "local" });
473
+
474
+ findAssistantByNameMock.mockImplementation((name: string) => {
475
+ if (name === "src") return srcEntry;
476
+ if (name === "dst") return dstEntry;
477
+ return null;
478
+ });
479
+
480
+ // GCP is unsupported, should print error and exit
481
+ await expect(teleport()).rejects.toThrow("process.exit:1");
482
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
483
+ expect.stringContaining("only supports local and platform"),
484
+ );
485
+ });
486
+
487
+ test("entry with no cloud and no project resolves to local", async () => {
488
+ setArgv("--from", "src", "--to", "dst");
489
+
490
+ // cloud is falsy, no project — resolveCloud returns "local"
491
+ const srcEntry = makeEntry("src", { cloud: "" as string });
492
+ const dstEntry = makeEntry("dst", { cloud: "local" });
493
+
494
+ findAssistantByNameMock.mockImplementation((name: string) => {
495
+ if (name === "src") return srcEntry;
496
+ if (name === "dst") return dstEntry;
497
+ return null;
498
+ });
499
+
500
+ const originalFetch = globalThis.fetch;
501
+ const fetchMock = mock(async (url: string | URL | Request) => {
502
+ const urlStr = typeof url === "string" ? url : url.toString();
503
+ if (urlStr.includes("/export")) {
504
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
505
+ }
506
+ // import endpoint
507
+ return new Response(
508
+ JSON.stringify({
509
+ success: true,
510
+ summary: {
511
+ total_files: 1,
512
+ files_created: 1,
513
+ files_overwritten: 0,
514
+ files_skipped: 0,
515
+ backups_created: 0,
516
+ },
517
+ }),
518
+ { status: 200, headers: { "Content-Type": "application/json" } },
519
+ );
520
+ });
521
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
522
+
523
+ try {
524
+ await teleport();
525
+ // Should use local export (fetch to /v1/migrations/export)
526
+ const exportCalls = filterFetchCalls(fetchMock, "/v1/migrations/export");
527
+ expect(exportCalls.length).toBe(1);
528
+ } finally {
529
+ globalThis.fetch = originalFetch;
530
+ }
531
+ });
532
+ });
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // Transfer routing tests
536
+ // ---------------------------------------------------------------------------
537
+
538
+ describe("teleport transfer routing", () => {
539
+ test("local → platform calls local export endpoint and platform import", async () => {
540
+ setArgv("--from", "local-src", "--to", "platform-dst");
541
+
542
+ findAssistantByNameMock.mockImplementation((name: string) => {
543
+ if (name === "local-src")
544
+ return makeEntry("local-src", { cloud: "local" });
545
+ if (name === "platform-dst")
546
+ return makeEntry("platform-dst", {
547
+ cloud: "vellum",
548
+ runtimeUrl: "https://platform.vellum.ai",
549
+ });
550
+ return null;
551
+ });
552
+
553
+ const originalFetch = globalThis.fetch;
554
+ const fetchMock = mock(async () => {
555
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
556
+ });
557
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
558
+
559
+ try {
560
+ await teleport();
561
+
562
+ // Local export: should call fetch to /v1/migrations/export
563
+ const exportCalls = filterFetchCalls(fetchMock, "/v1/migrations/export");
564
+ expect(exportCalls.length).toBe(1);
565
+
566
+ // Platform import: should call platformImportBundle
567
+ expect(platformImportBundleMock).toHaveBeenCalled();
568
+
569
+ // Should NOT call platform export functions
570
+ expect(platformInitiateExportMock).not.toHaveBeenCalled();
571
+ } finally {
572
+ globalThis.fetch = originalFetch;
573
+ }
574
+ });
575
+
576
+ test("platform → local calls platform export functions and local import endpoint", async () => {
577
+ setArgv("--from", "platform-src", "--to", "local-dst");
578
+
579
+ findAssistantByNameMock.mockImplementation((name: string) => {
580
+ if (name === "platform-src")
581
+ return makeEntry("platform-src", {
582
+ cloud: "vellum",
583
+ runtimeUrl: "https://platform.vellum.ai",
584
+ });
585
+ if (name === "local-dst")
586
+ return makeEntry("local-dst", { cloud: "local" });
587
+ return null;
588
+ });
589
+
590
+ const originalFetch = globalThis.fetch;
591
+ const fetchMock = mock(async () => {
592
+ return new Response(
593
+ JSON.stringify({
594
+ success: true,
595
+ summary: {
596
+ total_files: 3,
597
+ files_created: 2,
598
+ files_overwritten: 1,
599
+ files_skipped: 0,
600
+ backups_created: 1,
601
+ },
602
+ }),
603
+ { status: 200, headers: { "Content-Type": "application/json" } },
604
+ );
605
+ });
606
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
607
+
608
+ try {
609
+ await teleport();
610
+
611
+ // Platform export: should call all three platform export functions
612
+ expect(platformInitiateExportMock).toHaveBeenCalled();
613
+ expect(platformPollExportStatusMock).toHaveBeenCalled();
614
+ expect(platformDownloadExportMock).toHaveBeenCalled();
615
+
616
+ // Local import: should call fetch to /v1/migrations/import (but not /import-preflight)
617
+ const importCalls = filterFetchCalls(
618
+ fetchMock,
619
+ "/v1/migrations/import",
620
+ ).filter((call) => !extractUrl(call[0]).includes("/import-preflight"));
621
+ expect(importCalls.length).toBe(1);
622
+
623
+ // Should NOT call platformImportBundle
624
+ expect(platformImportBundleMock).not.toHaveBeenCalled();
625
+ } finally {
626
+ globalThis.fetch = originalFetch;
627
+ }
628
+ });
629
+
630
+ test("--dry-run calls preflight instead of import (local target)", async () => {
631
+ setArgv("--from", "src", "--to", "dst", "--dry-run");
632
+
633
+ findAssistantByNameMock.mockImplementation((name: string) => {
634
+ if (name === "src") return makeEntry("src", { cloud: "local" });
635
+ if (name === "dst") return makeEntry("dst", { cloud: "local" });
636
+ return null;
637
+ });
638
+
639
+ const originalFetch = globalThis.fetch;
640
+ const fetchMock = mock(async (url: string | URL | Request) => {
641
+ const urlStr = typeof url === "string" ? url : url.toString();
642
+ if (urlStr.includes("/export")) {
643
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
644
+ }
645
+ if (urlStr.includes("/import-preflight")) {
646
+ return new Response(
647
+ JSON.stringify({
648
+ can_import: true,
649
+ summary: {
650
+ files_to_create: 1,
651
+ files_to_overwrite: 0,
652
+ files_unchanged: 0,
653
+ total_files: 1,
654
+ },
655
+ }),
656
+ { status: 200, headers: { "Content-Type": "application/json" } },
657
+ );
658
+ }
659
+ return new Response("not found", { status: 404 });
660
+ });
661
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
662
+
663
+ try {
664
+ await teleport();
665
+
666
+ // Should call preflight endpoint
667
+ const preflightCalls = filterFetchCalls(fetchMock, "import-preflight");
668
+ expect(preflightCalls.length).toBe(1);
669
+
670
+ // Should NOT call the actual import endpoint
671
+ const importCalls = filterFetchCalls(fetchMock, "/import").filter(
672
+ (call) => !extractUrl(call[0]).includes("preflight"),
673
+ );
674
+ expect(importCalls.length).toBe(0);
675
+ } finally {
676
+ globalThis.fetch = originalFetch;
677
+ }
678
+ });
679
+
680
+ test("--dry-run calls platformImportPreflight instead of platformImportBundle (platform target)", async () => {
681
+ setArgv("--from", "src", "--to", "dst", "--dry-run");
682
+
683
+ findAssistantByNameMock.mockImplementation((name: string) => {
684
+ if (name === "src") return makeEntry("src", { cloud: "local" });
685
+ if (name === "dst")
686
+ return makeEntry("dst", {
687
+ cloud: "vellum",
688
+ runtimeUrl: "https://platform.vellum.ai",
689
+ });
690
+ return null;
691
+ });
692
+
693
+ const originalFetch = globalThis.fetch;
694
+ const fetchMock = mock(async () => {
695
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
696
+ });
697
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
698
+
699
+ try {
700
+ await teleport();
701
+
702
+ // Should call platformImportPreflight
703
+ expect(platformImportPreflightMock).toHaveBeenCalled();
704
+
705
+ // Should NOT call platformImportBundle
706
+ expect(platformImportBundleMock).not.toHaveBeenCalled();
707
+ } finally {
708
+ globalThis.fetch = originalFetch;
709
+ }
710
+ });
711
+
712
+ test("platform → platform calls platform export and platform import", async () => {
713
+ setArgv("--from", "platform-src", "--to", "platform-dst");
714
+
715
+ findAssistantByNameMock.mockImplementation((name: string) => {
716
+ if (name === "platform-src")
717
+ return makeEntry("platform-src", {
718
+ cloud: "vellum",
719
+ runtimeUrl: "https://platform.vellum.ai",
720
+ });
721
+ if (name === "platform-dst")
722
+ return makeEntry("platform-dst", {
723
+ cloud: "vellum",
724
+ runtimeUrl: "https://platform2.vellum.ai",
725
+ });
726
+ return null;
727
+ });
728
+
729
+ await teleport();
730
+
731
+ // Platform export: should call all three platform export functions
732
+ expect(platformInitiateExportMock).toHaveBeenCalled();
733
+ expect(platformPollExportStatusMock).toHaveBeenCalled();
734
+ expect(platformDownloadExportMock).toHaveBeenCalled();
735
+
736
+ // Platform import: should call platformImportBundle
737
+ expect(platformImportBundleMock).toHaveBeenCalled();
738
+ });
739
+ });
740
+
741
+ // ---------------------------------------------------------------------------
742
+ // Edge case: extra/unrecognized arguments
743
+ // ---------------------------------------------------------------------------
744
+
745
+ describe("teleport extra arguments", () => {
746
+ test("extra unrecognized flags are ignored and command works normally", async () => {
747
+ setArgv(
748
+ "--from",
749
+ "src",
750
+ "--to",
751
+ "dst",
752
+ "--bogus-flag",
753
+ "--another-unknown",
754
+ );
755
+
756
+ findAssistantByNameMock.mockImplementation((name: string) => {
757
+ if (name === "src") return makeEntry("src", { cloud: "local" });
758
+ if (name === "dst") return makeEntry("dst", { cloud: "local" });
759
+ return null;
760
+ });
761
+
762
+ const originalFetch = globalThis.fetch;
763
+ const fetchMock = mock(async (url: string | URL | Request) => {
764
+ const urlStr = typeof url === "string" ? url : url.toString();
765
+ if (urlStr.includes("/export")) {
766
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
767
+ }
768
+ return new Response(
769
+ JSON.stringify({
770
+ success: true,
771
+ summary: {
772
+ total_files: 1,
773
+ files_created: 1,
774
+ files_overwritten: 0,
775
+ files_skipped: 0,
776
+ backups_created: 0,
777
+ },
778
+ }),
779
+ { status: 200, headers: { "Content-Type": "application/json" } },
780
+ );
781
+ });
782
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
783
+
784
+ try {
785
+ await teleport();
786
+ // Should have proceeded normally despite extra flags
787
+ expect(findAssistantByNameMock).toHaveBeenCalledWith("src");
788
+ expect(findAssistantByNameMock).toHaveBeenCalledWith("dst");
789
+ const exportCalls = filterFetchCalls(fetchMock, "/v1/migrations/export");
790
+ expect(exportCalls.length).toBe(1);
791
+ } finally {
792
+ globalThis.fetch = originalFetch;
793
+ }
794
+ });
795
+ });
796
+
797
+ // ---------------------------------------------------------------------------
798
+ // Edge case: --from or --to without a following value
799
+ // ---------------------------------------------------------------------------
800
+
801
+ describe("teleport malformed flag usage", () => {
802
+ test("--from as the last argument (no value) prints help and exits 1", async () => {
803
+ setArgv("--to", "target", "--from");
804
+ // --from is the last arg so parseArgs won't assign a value to `from`
805
+ // This should result in missing --from and trigger help + exit 1
806
+ await expect(teleport()).rejects.toThrow("process.exit:1");
807
+ expect(consoleLogSpy).toHaveBeenCalledWith(
808
+ expect.stringContaining("Usage:"),
809
+ );
810
+ });
811
+
812
+ test("--to as the last argument (no value) prints help and exits 1", async () => {
813
+ setArgv("--from", "source", "--to");
814
+ await expect(teleport()).rejects.toThrow("process.exit:1");
815
+ expect(consoleLogSpy).toHaveBeenCalledWith(
816
+ expect.stringContaining("Usage:"),
817
+ );
818
+ });
819
+
820
+ test("--from --to target rejects flag-like value for --from, leaving from undefined", async () => {
821
+ setArgv("--from", "--to", "target");
822
+ // parseArgs sees --from then skips "--to" (starts with --) so from stays undefined.
823
+ // --to then correctly consumes "target". from is undefined → prints help and exits 1.
824
+ await expect(teleport()).rejects.toThrow("process.exit:1");
825
+ expect(consoleLogSpy).toHaveBeenCalledWith(
826
+ expect.stringContaining("Usage:"),
827
+ );
828
+ });
829
+ });