@vellumai/cli 0.5.15 → 0.6.0

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.
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  afterAll,
3
+ afterEach,
3
4
  beforeEach,
4
5
  describe,
5
6
  expect,
@@ -33,6 +34,21 @@ const findAssistantByNameMock = spyOn(
33
34
  "findAssistantByName",
34
35
  ).mockReturnValue(null);
35
36
 
37
+ const saveAssistantEntryMock = spyOn(
38
+ assistantConfig,
39
+ "saveAssistantEntry",
40
+ ).mockImplementation(() => {});
41
+
42
+ const loadAllAssistantsMock = spyOn(
43
+ assistantConfig,
44
+ "loadAllAssistants",
45
+ ).mockReturnValue([]);
46
+
47
+ const removeAssistantEntryMock = spyOn(
48
+ assistantConfig,
49
+ "removeAssistantEntry",
50
+ ).mockImplementation(() => {});
51
+
36
52
  const loadGuardianTokenMock = mock((_id: string) => ({
37
53
  accessToken: "local-token",
38
54
  accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -49,6 +65,12 @@ mock.module("../lib/guardian-token.js", () => ({
49
65
 
50
66
  const readPlatformTokenMock = mock((): string | null => "platform-token");
51
67
  const fetchOrganizationIdMock = mock(async () => "org-123");
68
+ const getPlatformUrlMock = mock(() => "https://platform.vellum.ai");
69
+ const hatchAssistantMock = mock(async () => ({
70
+ id: "platform-new-id",
71
+ name: "platform-new",
72
+ status: "active",
73
+ }));
52
74
  const platformInitiateExportMock = mock(async () => ({
53
75
  jobId: "job-1",
54
76
  status: "pending",
@@ -86,18 +108,95 @@ const platformImportBundleMock = mock(async () => ({
86
108
  },
87
109
  } as Record<string, unknown>,
88
110
  }));
111
+ const platformRequestUploadUrlMock = mock(async () => ({
112
+ uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
113
+ bundleKey: "bundle-key-123",
114
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
115
+ }));
116
+ const platformUploadToSignedUrlMock = mock(async () => {});
117
+ const platformImportPreflightFromGcsMock = mock(async () => ({
118
+ statusCode: 200,
119
+ body: {
120
+ can_import: true,
121
+ summary: {
122
+ files_to_create: 2,
123
+ files_to_overwrite: 1,
124
+ files_unchanged: 0,
125
+ total_files: 3,
126
+ },
127
+ } as Record<string, unknown>,
128
+ }));
129
+ const platformImportBundleFromGcsMock = mock(async () => ({
130
+ statusCode: 200,
131
+ body: {
132
+ success: true,
133
+ summary: {
134
+ total_files: 3,
135
+ files_created: 2,
136
+ files_overwritten: 1,
137
+ files_skipped: 0,
138
+ backups_created: 1,
139
+ },
140
+ } as Record<string, unknown>,
141
+ }));
89
142
 
90
143
  mock.module("../lib/platform-client.js", () => ({
91
144
  readPlatformToken: readPlatformTokenMock,
92
145
  fetchOrganizationId: fetchOrganizationIdMock,
146
+ getPlatformUrl: getPlatformUrlMock,
147
+ hatchAssistant: hatchAssistantMock,
93
148
  platformInitiateExport: platformInitiateExportMock,
94
149
  platformPollExportStatus: platformPollExportStatusMock,
95
150
  platformDownloadExport: platformDownloadExportMock,
96
151
  platformImportPreflight: platformImportPreflightMock,
97
152
  platformImportBundle: platformImportBundleMock,
153
+ platformRequestUploadUrl: platformRequestUploadUrlMock,
154
+ platformUploadToSignedUrl: platformUploadToSignedUrlMock,
155
+ platformImportPreflightFromGcs: platformImportPreflightFromGcsMock,
156
+ platformImportBundleFromGcs: platformImportBundleFromGcsMock,
157
+ }));
158
+
159
+ const hatchLocalMock = mock(async () => {});
160
+
161
+ mock.module("../lib/hatch-local.js", () => ({
162
+ hatchLocal: hatchLocalMock,
163
+ }));
164
+
165
+ const hatchDockerMock = mock(async () => {});
166
+ const retireDockerMock = mock(async () => {});
167
+
168
+ const sleepContainersMock = mock(async () => {});
169
+ const dockerResourceNamesMock = mock((name: string) => ({
170
+ assistantContainer: `${name}-assistant`,
171
+ gatewayContainer: `${name}-gateway`,
172
+ cesContainer: `${name}-ces`,
173
+ network: `${name}-net`,
174
+ }));
175
+
176
+ mock.module("../lib/docker.js", () => ({
177
+ hatchDocker: hatchDockerMock,
178
+ retireDocker: retireDockerMock,
179
+ sleepContainers: sleepContainersMock,
180
+ dockerResourceNames: dockerResourceNamesMock,
181
+ }));
182
+
183
+ const stopProcessByPidFileMock = mock(async () => true);
184
+
185
+ mock.module("../lib/process.js", () => ({
186
+ stopProcessByPidFile: stopProcessByPidFileMock,
187
+ }));
188
+
189
+ const retireLocalMock = mock(async () => {});
190
+
191
+ mock.module("../lib/retire-local.js", () => ({
192
+ retireLocal: retireLocalMock,
98
193
  }));
99
194
 
100
- import { teleport } from "../commands/teleport.js";
195
+ import {
196
+ teleport,
197
+ parseArgs,
198
+ resolveOrHatchTarget,
199
+ } from "../commands/teleport.js";
101
200
  import type { AssistantEntry } from "../lib/assistant-config.js";
102
201
 
103
202
  // ---------------------------------------------------------------------------
@@ -106,6 +205,9 @@ import type { AssistantEntry } from "../lib/assistant-config.js";
106
205
 
107
206
  afterAll(() => {
108
207
  findAssistantByNameMock.mockRestore();
208
+ saveAssistantEntryMock.mockRestore();
209
+ loadAllAssistantsMock.mockRestore();
210
+ removeAssistantEntryMock.mockRestore();
109
211
  rmSync(testDir, { recursive: true, force: true });
110
212
  delete process.env.VELLUM_LOCKFILE_DIR;
111
213
  });
@@ -122,6 +224,12 @@ beforeEach(() => {
122
224
  // Reset all mocks
123
225
  findAssistantByNameMock.mockReset();
124
226
  findAssistantByNameMock.mockReturnValue(null);
227
+ saveAssistantEntryMock.mockReset();
228
+ saveAssistantEntryMock.mockImplementation(() => {});
229
+ loadAllAssistantsMock.mockReset();
230
+ loadAllAssistantsMock.mockReturnValue([]);
231
+ removeAssistantEntryMock.mockReset();
232
+ removeAssistantEntryMock.mockImplementation(() => {});
125
233
 
126
234
  loadGuardianTokenMock.mockReset();
127
235
  loadGuardianTokenMock.mockReturnValue({
@@ -134,6 +242,14 @@ beforeEach(() => {
134
242
  readPlatformTokenMock.mockReturnValue("platform-token");
135
243
  fetchOrganizationIdMock.mockReset();
136
244
  fetchOrganizationIdMock.mockResolvedValue("org-123");
245
+ getPlatformUrlMock.mockReset();
246
+ getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
247
+ hatchAssistantMock.mockReset();
248
+ hatchAssistantMock.mockResolvedValue({
249
+ id: "platform-new-id",
250
+ name: "platform-new",
251
+ status: "active",
252
+ });
137
253
  platformInitiateExportMock.mockReset();
138
254
  platformInitiateExportMock.mockResolvedValue({
139
255
  jobId: "job-1",
@@ -175,6 +291,54 @@ beforeEach(() => {
175
291
  },
176
292
  },
177
293
  });
294
+ platformRequestUploadUrlMock.mockReset();
295
+ platformRequestUploadUrlMock.mockResolvedValue({
296
+ uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
297
+ bundleKey: "bundle-key-123",
298
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
299
+ });
300
+ platformUploadToSignedUrlMock.mockReset();
301
+ platformUploadToSignedUrlMock.mockResolvedValue(undefined);
302
+ platformImportPreflightFromGcsMock.mockReset();
303
+ platformImportPreflightFromGcsMock.mockResolvedValue({
304
+ statusCode: 200,
305
+ body: {
306
+ can_import: true,
307
+ summary: {
308
+ files_to_create: 2,
309
+ files_to_overwrite: 1,
310
+ files_unchanged: 0,
311
+ total_files: 3,
312
+ },
313
+ },
314
+ });
315
+ platformImportBundleFromGcsMock.mockReset();
316
+ platformImportBundleFromGcsMock.mockResolvedValue({
317
+ statusCode: 200,
318
+ body: {
319
+ success: true,
320
+ summary: {
321
+ total_files: 3,
322
+ files_created: 2,
323
+ files_overwritten: 1,
324
+ files_skipped: 0,
325
+ backups_created: 1,
326
+ },
327
+ },
328
+ });
329
+
330
+ hatchLocalMock.mockReset();
331
+ hatchLocalMock.mockResolvedValue(undefined);
332
+ hatchDockerMock.mockReset();
333
+ hatchDockerMock.mockResolvedValue(undefined);
334
+ retireDockerMock.mockReset();
335
+ retireDockerMock.mockResolvedValue(undefined);
336
+ retireLocalMock.mockReset();
337
+ retireLocalMock.mockResolvedValue(undefined);
338
+ sleepContainersMock.mockReset();
339
+ sleepContainersMock.mockResolvedValue(undefined);
340
+ stopProcessByPidFileMock.mockReset();
341
+ stopProcessByPidFileMock.mockResolvedValue(true);
178
342
 
179
343
  // Mock process.exit to throw so we can catch it
180
344
  exitMock = mock((code?: number) => {
@@ -187,8 +351,6 @@ beforeEach(() => {
187
351
  consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
188
352
  });
189
353
 
190
- import { afterEach } from "bun:test";
191
-
192
354
  afterEach(() => {
193
355
  process.argv = originalArgv;
194
356
  process.exit = originalExit;
@@ -213,24 +375,44 @@ function makeEntry(
213
375
  };
214
376
  }
215
377
 
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
- );
378
+ /** Create a mock fetch that handles export and import endpoints. */
379
+ function createFetchMock() {
380
+ return mock(async (url: string | URL | Request) => {
381
+ const urlStr = typeof url === "string" ? url : url.toString();
382
+ if (urlStr.includes("/export")) {
383
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
384
+ }
385
+ if (urlStr.includes("/import-preflight")) {
386
+ return new Response(
387
+ JSON.stringify({
388
+ can_import: true,
389
+ summary: {
390
+ files_to_create: 1,
391
+ files_to_overwrite: 0,
392
+ files_unchanged: 0,
393
+ total_files: 1,
394
+ },
395
+ }),
396
+ { status: 200, headers: { "Content-Type": "application/json" } },
397
+ );
398
+ }
399
+ if (urlStr.includes("/import")) {
400
+ return new Response(
401
+ JSON.stringify({
402
+ success: true,
403
+ summary: {
404
+ total_files: 1,
405
+ files_created: 1,
406
+ files_overwritten: 0,
407
+ files_skipped: 0,
408
+ backups_created: 0,
409
+ },
410
+ }),
411
+ { status: 200, headers: { "Content-Type": "application/json" } },
412
+ );
413
+ }
414
+ return new Response("not found", { status: 404 });
415
+ });
234
416
  }
235
417
 
236
418
  // ---------------------------------------------------------------------------
@@ -254,7 +436,7 @@ describe("teleport arg parsing", () => {
254
436
  );
255
437
  });
256
438
 
257
- test("missing --from and --to prints help and exits 1", async () => {
439
+ test("missing --from and env flag prints help and exits 1", async () => {
258
440
  setArgv();
259
441
  await expect(teleport()).rejects.toThrow("process.exit:1");
260
442
  expect(consoleLogSpy).toHaveBeenCalledWith(
@@ -262,7 +444,7 @@ describe("teleport arg parsing", () => {
262
444
  );
263
445
  });
264
446
 
265
- test("missing --to prints help and exits 1", async () => {
447
+ test("missing env flag prints help and exits 1", async () => {
266
448
  setArgv("--from", "source");
267
449
  await expect(teleport()).rejects.toThrow("process.exit:1");
268
450
  expect(consoleLogSpy).toHaveBeenCalledWith(
@@ -270,126 +452,122 @@ describe("teleport arg parsing", () => {
270
452
  );
271
453
  });
272
454
 
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:"),
455
+ test("--local sets targetEnv to 'local' with no name", () => {
456
+ const result = parseArgs(["--from", "source", "--local"]);
457
+ expect(result.from).toBe("source");
458
+ expect(result.targetEnv).toBe("local");
459
+ expect(result.targetName).toBeUndefined();
460
+ });
461
+
462
+ test("--docker my-name sets targetEnv to 'docker' and targetName to 'my-name'", () => {
463
+ const result = parseArgs(["--from", "source", "--docker", "my-name"]);
464
+ expect(result.from).toBe("source");
465
+ expect(result.targetEnv).toBe("docker");
466
+ expect(result.targetName).toBe("my-name");
467
+ });
468
+
469
+ test("--platform sets targetEnv to 'platform'", () => {
470
+ const result = parseArgs(["--from", "source", "--platform"]);
471
+ expect(result.from).toBe("source");
472
+ expect(result.targetEnv).toBe("platform");
473
+ expect(result.targetName).toBeUndefined();
474
+ });
475
+
476
+ test("multiple env flags error", () => {
477
+ expect(() => parseArgs(["--from", "src", "--local", "--docker"])).toThrow(
478
+ "process.exit:1",
479
+ );
480
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
481
+ expect.stringContaining("Only one environment flag"),
278
482
  );
279
483
  });
280
484
 
281
- test("--from and --to are correctly parsed", async () => {
282
- setArgv("--from", "source", "--to", "target");
485
+ test("--keep-source is parsed", () => {
486
+ const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
487
+ expect(result.keepSource).toBe(true);
488
+ });
489
+
490
+ test("--dry-run is parsed", () => {
491
+ const result = parseArgs(["--from", "source", "--local", "--dry-run"]);
492
+ expect(result.dryRun).toBe(true);
493
+ });
494
+
495
+ test("target name after env flag is consumed but --flags are not", () => {
496
+ const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
497
+ expect(result.targetEnv).toBe("docker");
498
+ expect(result.targetName).toBeUndefined();
499
+ expect(result.keepSource).toBe(true);
500
+ });
501
+ });
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Same-environment rejection tests
505
+ // ---------------------------------------------------------------------------
506
+
507
+ describe("same-environment rejection", () => {
508
+ test("source local, target local -> error (after resolving target)", async () => {
509
+ setArgv("--from", "src", "--local", "dst");
510
+
511
+ const srcEntry = makeEntry("src", { cloud: "local" });
512
+ const dstEntry = makeEntry("dst", { cloud: "local" });
283
513
 
284
514
  findAssistantByNameMock.mockImplementation((name: string) => {
285
- if (name === "source") return makeEntry("source");
286
- if (name === "target") return makeEntry("target");
515
+ if (name === "src") return srcEntry;
516
+ if (name === "dst") return dstEntry;
287
517
  return null;
288
518
  });
289
519
 
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
520
  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;
521
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
315
522
 
316
523
  try {
317
- await teleport();
318
- // Should call findAssistantByName for both source and target
319
- expect(findAssistantByNameMock).toHaveBeenCalledWith("source");
320
- expect(findAssistantByNameMock).toHaveBeenCalledWith("target");
524
+ await expect(teleport()).rejects.toThrow("process.exit:1");
525
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
526
+ expect.stringContaining("Cannot teleport between two local assistants"),
527
+ );
321
528
  } finally {
322
529
  globalThis.fetch = originalFetch;
323
530
  }
324
531
  });
325
532
 
326
- test("--dry-run flag is detected", async () => {
327
- setArgv("--from", "source", "--to", "target", "--dry-run");
533
+ test("source docker, target docker -> error (after resolving target)", async () => {
534
+ setArgv("--from", "src", "--docker", "dst");
535
+
536
+ const srcEntry = makeEntry("src", { cloud: "docker" });
537
+ const dstEntry = makeEntry("dst", { cloud: "docker" });
328
538
 
329
539
  findAssistantByNameMock.mockImplementation((name: string) => {
330
- if (name === "source") return makeEntry("source");
331
- if (name === "target") return makeEntry("target");
540
+ if (name === "src") return srcEntry;
541
+ if (name === "dst") return dstEntry;
332
542
  return null;
333
543
  });
334
544
 
335
545
  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;
546
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
358
547
 
359
548
  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);
549
+ await expect(teleport()).rejects.toThrow("process.exit:1");
550
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
551
+ expect.stringContaining(
552
+ "Cannot teleport between two docker assistants",
553
+ ),
554
+ );
364
555
  } finally {
365
556
  globalThis.fetch = originalFetch;
366
557
  }
367
558
  });
368
559
 
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");
560
+ test("source vellum, target platform -> error (after resolving target)", async () => {
561
+ setArgv("--from", "src", "--platform", "dst");
387
562
 
388
563
  const srcEntry = makeEntry("src", {
389
564
  cloud: "vellum",
390
565
  runtimeUrl: "https://platform.vellum.ai",
391
566
  });
392
- const dstEntry = makeEntry("dst", { cloud: "local" });
567
+ const dstEntry = makeEntry("dst", {
568
+ cloud: "vellum",
569
+ runtimeUrl: "https://platform.vellum.ai",
570
+ });
393
571
 
394
572
  findAssistantByNameMock.mockImplementation((name: string) => {
395
573
  if (name === "src") return srcEntry;
@@ -397,397 +575,704 @@ describe("teleport cloud resolution", () => {
397
575
  return null;
398
576
  });
399
577
 
400
- // Platform export path: readPlatformToken → fetchOrganizationId → initiateExport → poll → download
401
- // then local import
402
578
  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;
579
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
419
580
 
420
581
  try {
421
- await teleport();
422
- // Should have used platform export functions
423
- expect(platformInitiateExportMock).toHaveBeenCalled();
424
- expect(platformPollExportStatusMock).toHaveBeenCalled();
425
- expect(platformDownloadExportMock).toHaveBeenCalled();
582
+ await expect(teleport()).rejects.toThrow("process.exit:1");
583
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
584
+ expect.stringContaining(
585
+ "Cannot teleport between two platform assistants",
586
+ ),
587
+ );
426
588
  } finally {
427
589
  globalThis.fetch = originalFetch;
428
590
  }
429
591
  });
430
592
 
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
- });
593
+ test("same-env rejection happens before hatching (no orphaned assistants)", async () => {
594
+ setArgv("--from", "my-local", "--local");
439
595
 
596
+ const localEntry = makeEntry("my-local", { cloud: "local" });
440
597
  findAssistantByNameMock.mockImplementation((name: string) => {
441
- if (name === "src") return srcEntry;
442
- if (name === "dst") return dstEntry;
598
+ if (name === "my-local") return localEntry;
443
599
  return null;
444
600
  });
445
601
 
446
- const originalFetch = globalThis.fetch;
447
- const fetchMock = mock(async () => {
448
- return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
602
+ await expect(teleport()).rejects.toThrow("process.exit:1");
603
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
604
+ expect.stringContaining("Cannot teleport between two local assistants"),
605
+ );
606
+ // Crucially: no hatch should have been called — the early guard fires first
607
+ expect(hatchLocalMock).not.toHaveBeenCalled();
608
+ expect(hatchDockerMock).not.toHaveBeenCalled();
609
+ expect(hatchAssistantMock).not.toHaveBeenCalled();
610
+ });
611
+
612
+ test("same-env rejection before hatching for docker", async () => {
613
+ setArgv("--from", "my-docker", "--docker");
614
+
615
+ const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
616
+ findAssistantByNameMock.mockImplementation((name: string) => {
617
+ if (name === "my-docker") return dockerEntry;
618
+ return null;
449
619
  });
450
- globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
451
620
 
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
- }
621
+ await expect(teleport()).rejects.toThrow("process.exit:1");
622
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
623
+ expect.stringContaining("Cannot teleport between two docker assistants"),
624
+ );
625
+ expect(hatchLocalMock).not.toHaveBeenCalled();
626
+ expect(hatchDockerMock).not.toHaveBeenCalled();
627
+ expect(hatchAssistantMock).not.toHaveBeenCalled();
462
628
  });
463
629
 
464
- test("entry with no cloud but project set resolves to gcp (unsupported)", async () => {
465
- setArgv("--from", "src", "--to", "dst");
630
+ test("same-env rejection before hatching for platform (vellum cloud)", async () => {
631
+ setArgv("--from", "my-cloud", "--platform");
466
632
 
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",
633
+ const platformEntry = makeEntry("my-cloud", {
634
+ cloud: "vellum",
635
+ runtimeUrl: "https://platform.vellum.ai",
471
636
  });
472
- const dstEntry = makeEntry("dst", { cloud: "local" });
473
-
474
637
  findAssistantByNameMock.mockImplementation((name: string) => {
475
- if (name === "src") return srcEntry;
476
- if (name === "dst") return dstEntry;
638
+ if (name === "my-cloud") return platformEntry;
477
639
  return null;
478
640
  });
479
641
 
480
- // GCP is unsupported, should print error and exit
481
642
  await expect(teleport()).rejects.toThrow("process.exit:1");
482
643
  expect(consoleErrorSpy).toHaveBeenCalledWith(
483
- expect.stringContaining("only supports local and platform"),
644
+ expect.stringContaining(
645
+ "Cannot teleport between two platform assistants",
646
+ ),
484
647
  );
648
+ expect(hatchLocalMock).not.toHaveBeenCalled();
649
+ expect(hatchDockerMock).not.toHaveBeenCalled();
650
+ expect(hatchAssistantMock).not.toHaveBeenCalled();
485
651
  });
486
652
 
487
- test("entry with no cloud and no project resolves to local", async () => {
488
- setArgv("--from", "src", "--to", "dst");
653
+ test("flag says docker but resolved target is local -> rejects cloud mismatch", async () => {
654
+ // User passes --docker but the named target is actually a local assistant
655
+ setArgv("--from", "src", "--docker", "misidentified");
489
656
 
490
- // cloud is falsy, no project — resolveCloud returns "local"
491
- const srcEntry = makeEntry("src", { cloud: "" as string });
492
- const dstEntry = makeEntry("dst", { cloud: "local" });
657
+ const srcEntry = makeEntry("src", { cloud: "vellum" });
658
+ // Target is actually local despite the --docker flag
659
+ const dstEntry = makeEntry("misidentified", { cloud: "local" });
493
660
 
494
661
  findAssistantByNameMock.mockImplementation((name: string) => {
495
662
  if (name === "src") return srcEntry;
496
- if (name === "dst") return dstEntry;
663
+ if (name === "misidentified") return dstEntry;
497
664
  return null;
498
665
  });
499
666
 
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
- }
667
+ await expect(teleport()).rejects.toThrow("process.exit:1");
668
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
669
+ expect.stringContaining("is a local assistant, not docker"),
670
+ );
531
671
  });
532
672
  });
533
673
 
534
674
  // ---------------------------------------------------------------------------
535
- // Transfer routing tests
675
+ // resolveOrHatchTarget tests
536
676
  // ---------------------------------------------------------------------------
537
677
 
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
-
678
+ describe("resolveOrHatchTarget", () => {
679
+ test("existing assistant is returned without hatching", async () => {
680
+ const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
542
681
  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
- });
682
+ if (name === "my-docker") return dockerEntry;
550
683
  return null;
551
684
  });
552
685
 
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;
686
+ const result = await resolveOrHatchTarget("docker", "my-docker");
687
+ expect(result).toBe(dockerEntry);
688
+ expect(hatchDockerMock).not.toHaveBeenCalled();
689
+ expect(consoleLogSpy).toHaveBeenCalledWith(
690
+ expect.stringContaining("Target: my-docker (docker)"),
691
+ );
692
+ });
693
+
694
+ test("name not found -> hatch docker", async () => {
695
+ const newEntry = makeEntry("new-one", { cloud: "docker" });
696
+ findAssistantByNameMock.mockImplementation((name: string) => {
697
+ // First call: lookup by name -> not found
698
+ // Second call: after hatch -> found
699
+ if (name === "new-one" && hatchDockerMock.mock.calls.length > 0) {
700
+ return newEntry;
701
+ }
702
+ return null;
703
+ });
704
+
705
+ const result = await resolveOrHatchTarget("docker", "new-one");
706
+ expect(hatchDockerMock).toHaveBeenCalledWith(
707
+ "vellum",
708
+ false,
709
+ "new-one",
710
+ false,
711
+ {},
712
+ );
713
+ expect(result).toBe(newEntry);
714
+ });
715
+
716
+ test("no name -> hatch local with null name, discovers via diff", async () => {
717
+ const existingEntry = makeEntry("existing-local", { cloud: "local" });
718
+ const newEntry = makeEntry("auto-generated", { cloud: "local" });
719
+
720
+ // Before hatch: only the existing entry
721
+ // After hatch: existing + new entry
722
+ loadAllAssistantsMock.mockImplementation(() => {
723
+ if (hatchLocalMock.mock.calls.length > 0) {
724
+ return [existingEntry, newEntry];
725
+ }
726
+ return [existingEntry];
727
+ });
728
+
729
+ const result = await resolveOrHatchTarget("local");
730
+ expect(hatchLocalMock).toHaveBeenCalledWith(
731
+ "vellum",
732
+ null,
733
+ false,
734
+ false,
735
+ false,
736
+ {},
737
+ );
738
+ expect(result).toBe(newEntry);
739
+ });
740
+
741
+ test("platform with existing ID -> returns existing without hatching", async () => {
742
+ const platformEntry = makeEntry("uuid-123", {
743
+ cloud: "vellum",
744
+ runtimeUrl: "https://platform.vellum.ai",
745
+ });
746
+ findAssistantByNameMock.mockImplementation((name: string) => {
747
+ if (name === "uuid-123") return platformEntry;
748
+ return null;
749
+ });
750
+
751
+ const result = await resolveOrHatchTarget("platform", "uuid-123");
752
+ expect(result).toBe(platformEntry);
753
+ expect(hatchAssistantMock).not.toHaveBeenCalled();
754
+ });
755
+
756
+ test("platform with unknown name -> hatches via hatchAssistant", async () => {
757
+ findAssistantByNameMock.mockReturnValue(null);
758
+
759
+ const result = await resolveOrHatchTarget("platform", "nonexistent");
760
+ expect(hatchAssistantMock).toHaveBeenCalledWith(
761
+ "platform-token",
762
+ "org-123",
763
+ );
764
+ expect(saveAssistantEntryMock).toHaveBeenCalledWith(
765
+ expect.objectContaining({
766
+ assistantId: "platform-new-id",
767
+ cloud: "vellum",
768
+ }),
769
+ );
770
+ expect(result.assistantId).toBe("platform-new-id");
771
+ });
772
+
773
+ test("existing assistant with wrong cloud -> rejects", async () => {
774
+ const localEntry = makeEntry("my-local", { cloud: "local" });
775
+ findAssistantByNameMock.mockImplementation((name: string) => {
776
+ if (name === "my-local") return localEntry;
777
+ return null;
778
+ });
779
+
780
+ await expect(resolveOrHatchTarget("docker", "my-local")).rejects.toThrow(
781
+ "process.exit:1",
782
+ );
783
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
784
+ expect.stringContaining("is a local assistant, not docker"),
785
+ );
786
+ });
787
+
788
+ test("name with path traversal -> rejects before hatching", async () => {
789
+ findAssistantByNameMock.mockReturnValue(null);
790
+
791
+ await expect(
792
+ resolveOrHatchTarget("docker", "../../../etc/passwd"),
793
+ ).rejects.toThrow("process.exit:1");
794
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
795
+ expect.stringContaining("invalid characters"),
796
+ );
797
+ expect(hatchDockerMock).not.toHaveBeenCalled();
798
+ });
799
+ });
800
+
801
+ // ---------------------------------------------------------------------------
802
+ // Auto-retire tests
803
+ // ---------------------------------------------------------------------------
804
+
805
+ describe("auto-retire", () => {
806
+ test("local -> docker: stops source before hatch, retires after import", async () => {
807
+ setArgv("--from", "my-local", "--docker");
808
+
809
+ const localEntry = makeEntry("my-local", {
810
+ cloud: "local",
811
+ resources: {
812
+ instanceDir: "/home/test",
813
+ pidFile: "/home/test/.vellum/assistant.pid",
814
+ signingKey: "key",
815
+ daemonPort: 7821,
816
+ gatewayPort: 7830,
817
+ qdrantPort: 6333,
818
+ cesPort: 8090,
819
+ },
820
+ });
821
+ const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
822
+
823
+ findAssistantByNameMock.mockImplementation((name: string) => {
824
+ if (name === "my-local") return localEntry;
825
+ return null;
826
+ });
827
+
828
+ // Simulate hatch creating a new docker entry
829
+ loadAllAssistantsMock.mockImplementation(() => {
830
+ if (hatchDockerMock.mock.calls.length > 0) {
831
+ return [localEntry, dockerEntry];
832
+ }
833
+ return [localEntry];
834
+ });
835
+
836
+ const originalFetch = globalThis.fetch;
837
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
558
838
 
559
839
  try {
560
840
  await teleport();
841
+ // Source should be stopped (slept) before hatch
842
+ expect(stopProcessByPidFileMock).toHaveBeenCalled();
843
+ // Retire happens after successful import
844
+ expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
845
+ expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-local");
846
+ } finally {
847
+ globalThis.fetch = originalFetch;
848
+ }
849
+ });
561
850
 
562
- // Local export: should call fetch to /v1/migrations/export
563
- const exportCalls = filterFetchCalls(fetchMock, "/v1/migrations/export");
564
- expect(exportCalls.length).toBe(1);
851
+ test("docker -> local: sleeps containers before hatch, retires after import", async () => {
852
+ setArgv("--from", "my-docker", "--local");
565
853
 
566
- // Platform import: should call platformImportBundle
567
- expect(platformImportBundleMock).toHaveBeenCalled();
854
+ const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
855
+ const localEntry = makeEntry("new-local", { cloud: "local" });
856
+
857
+ findAssistantByNameMock.mockImplementation((name: string) => {
858
+ if (name === "my-docker") return dockerEntry;
859
+ return null;
860
+ });
861
+
862
+ loadAllAssistantsMock.mockImplementation(() => {
863
+ if (hatchLocalMock.mock.calls.length > 0) {
864
+ return [dockerEntry, localEntry];
865
+ }
866
+ return [dockerEntry];
867
+ });
568
868
 
569
- // Should NOT call platform export functions
570
- expect(platformInitiateExportMock).not.toHaveBeenCalled();
869
+ const originalFetch = globalThis.fetch;
870
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
871
+
872
+ try {
873
+ await teleport();
874
+ // Docker source should be slept (containers stopped) before hatch
875
+ expect(sleepContainersMock).toHaveBeenCalled();
876
+ // Retire happens after successful import
877
+ expect(retireDockerMock).toHaveBeenCalledWith("my-docker");
878
+ expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-docker");
571
879
  } finally {
572
880
  globalThis.fetch = originalFetch;
573
881
  }
574
882
  });
575
883
 
576
- test("platform local calls platform export functions and local import endpoint", async () => {
577
- setArgv("--from", "platform-src", "--to", "local-dst");
884
+ test("--keep-source skips retire and removeAssistantEntry", async () => {
885
+ setArgv("--from", "my-local", "--docker", "--keep-source");
886
+
887
+ const localEntry = makeEntry("my-local", { cloud: "local" });
888
+ const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
578
889
 
579
890
  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" });
891
+ if (name === "my-local") return localEntry;
587
892
  return null;
588
893
  });
589
894
 
895
+ loadAllAssistantsMock.mockImplementation(() => {
896
+ if (hatchDockerMock.mock.calls.length > 0) {
897
+ return [localEntry, dockerEntry];
898
+ }
899
+ return [localEntry];
900
+ });
901
+
590
902
  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" } },
903
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
904
+
905
+ try {
906
+ await teleport();
907
+ expect(retireLocalMock).not.toHaveBeenCalled();
908
+ expect(retireDockerMock).not.toHaveBeenCalled();
909
+ expect(removeAssistantEntryMock).not.toHaveBeenCalled();
910
+ expect(consoleLogSpy).toHaveBeenCalledWith(
911
+ expect.stringContaining("kept (--keep-source)"),
604
912
  );
913
+ } finally {
914
+ globalThis.fetch = originalFetch;
915
+ }
916
+ });
917
+
918
+ test("platform transfers skip retire", async () => {
919
+ setArgv("--from", "my-local", "--platform");
920
+
921
+ const localEntry = makeEntry("my-local", { cloud: "local" });
922
+
923
+ findAssistantByNameMock.mockImplementation((name: string) => {
924
+ if (name === "my-local") return localEntry;
925
+ return null;
605
926
  });
606
- globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
927
+
928
+ const originalFetch = globalThis.fetch;
929
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
607
930
 
608
931
  try {
609
932
  await teleport();
933
+ expect(retireLocalMock).not.toHaveBeenCalled();
934
+ expect(retireDockerMock).not.toHaveBeenCalled();
935
+ expect(removeAssistantEntryMock).not.toHaveBeenCalled();
936
+ } finally {
937
+ globalThis.fetch = originalFetch;
938
+ }
939
+ });
610
940
 
611
- // Platform export: should call all three platform export functions
612
- expect(platformInitiateExportMock).toHaveBeenCalled();
613
- expect(platformPollExportStatusMock).toHaveBeenCalled();
614
- expect(platformDownloadExportMock).toHaveBeenCalled();
941
+ test("dry-run without existing target does not hatch or export", async () => {
942
+ setArgv("--from", "my-local", "--docker", "--dry-run");
615
943
 
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);
944
+ const localEntry = makeEntry("my-local", { cloud: "local" });
622
945
 
623
- // Should NOT call platformImportBundle
624
- expect(platformImportBundleMock).not.toHaveBeenCalled();
946
+ findAssistantByNameMock.mockImplementation((name: string) => {
947
+ if (name === "my-local") return localEntry;
948
+ return null;
949
+ });
950
+
951
+ await teleport();
952
+
953
+ // Should NOT hatch, export, import, or retire
954
+ expect(hatchDockerMock).not.toHaveBeenCalled();
955
+ expect(hatchLocalMock).not.toHaveBeenCalled();
956
+ expect(hatchAssistantMock).not.toHaveBeenCalled();
957
+ expect(retireLocalMock).not.toHaveBeenCalled();
958
+ expect(retireDockerMock).not.toHaveBeenCalled();
959
+ expect(removeAssistantEntryMock).not.toHaveBeenCalled();
960
+ });
961
+
962
+ test("dry-run with existing target runs preflight without hatching", async () => {
963
+ setArgv("--from", "my-local", "--docker", "my-docker", "--dry-run");
964
+
965
+ const localEntry = makeEntry("my-local", { cloud: "local" });
966
+ const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
967
+
968
+ findAssistantByNameMock.mockImplementation((name: string) => {
969
+ if (name === "my-local") return localEntry;
970
+ if (name === "my-docker") return dockerEntry;
971
+ return null;
972
+ });
973
+
974
+ const originalFetch = globalThis.fetch;
975
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
976
+
977
+ try {
978
+ await teleport();
979
+
980
+ // Should NOT hatch or retire
981
+ expect(hatchDockerMock).not.toHaveBeenCalled();
982
+ expect(hatchLocalMock).not.toHaveBeenCalled();
983
+ expect(retireLocalMock).not.toHaveBeenCalled();
984
+ expect(retireDockerMock).not.toHaveBeenCalled();
985
+ expect(removeAssistantEntryMock).not.toHaveBeenCalled();
625
986
  } finally {
626
987
  globalThis.fetch = originalFetch;
627
988
  }
628
989
  });
990
+ });
991
+
992
+ // ---------------------------------------------------------------------------
993
+ // Full flow tests
994
+ // ---------------------------------------------------------------------------
995
+
996
+ describe("teleport full flow", () => {
997
+ test("hatch and import: --from my-local --docker", async () => {
998
+ setArgv("--from", "my-local", "--docker");
629
999
 
630
- test("--dry-run calls preflight instead of import (local target)", async () => {
631
- setArgv("--from", "src", "--to", "dst", "--dry-run");
1000
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1001
+ const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
632
1002
 
633
1003
  findAssistantByNameMock.mockImplementation((name: string) => {
634
- if (name === "src") return makeEntry("src", { cloud: "local" });
635
- if (name === "dst") return makeEntry("dst", { cloud: "local" });
1004
+ if (name === "my-local") return localEntry;
636
1005
  return null;
637
1006
  });
638
1007
 
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 });
1008
+ loadAllAssistantsMock.mockImplementation(() => {
1009
+ if (hatchDockerMock.mock.calls.length > 0) {
1010
+ return [localEntry, dockerEntry];
644
1011
  }
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 });
1012
+ return [localEntry];
660
1013
  });
1014
+
1015
+ const originalFetch = globalThis.fetch;
1016
+ const fetchMock = createFetchMock();
661
1017
  globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
662
1018
 
663
1019
  try {
664
1020
  await teleport();
665
1021
 
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"),
1022
+ // Verify sequence: export, hatch, import, retire
1023
+ expect(hatchDockerMock).toHaveBeenCalled();
1024
+ expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
1025
+ expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-local");
1026
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1027
+ expect.stringContaining("Teleport complete"),
673
1028
  );
674
- expect(importCalls.length).toBe(0);
675
1029
  } finally {
676
1030
  globalThis.fetch = originalFetch;
677
1031
  }
678
1032
  });
679
1033
 
680
- test("--dry-run calls platformImportPreflight instead of platformImportBundle (platform target)", async () => {
681
- setArgv("--from", "src", "--to", "dst", "--dry-run");
1034
+ test("existing target overwrite: --from my-local --docker my-existing", async () => {
1035
+ setArgv("--from", "my-local", "--docker", "my-existing");
1036
+
1037
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1038
+ const dockerEntry = makeEntry("my-existing", { cloud: "docker" });
682
1039
 
683
1040
  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
- });
1041
+ if (name === "my-local") return localEntry;
1042
+ if (name === "my-existing") return dockerEntry;
690
1043
  return null;
691
1044
  });
692
1045
 
693
1046
  const originalFetch = globalThis.fetch;
694
- const fetchMock = mock(async () => {
695
- return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
696
- });
1047
+ const fetchMock = createFetchMock();
697
1048
  globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
698
1049
 
699
1050
  try {
700
1051
  await teleport();
701
1052
 
702
- // Should call platformImportPreflight
703
- expect(platformImportPreflightMock).toHaveBeenCalled();
704
-
705
- // Should NOT call platformImportBundle
706
- expect(platformImportBundleMock).not.toHaveBeenCalled();
1053
+ // No hatch should happen — existing target is used
1054
+ expect(hatchDockerMock).not.toHaveBeenCalled();
1055
+ // Source should still be retired
1056
+ expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
1057
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1058
+ expect.stringContaining("Teleport complete"),
1059
+ );
707
1060
  } finally {
708
1061
  globalThis.fetch = originalFetch;
709
1062
  }
710
1063
  });
711
1064
 
712
- test("platform platform calls platform export and platform import", async () => {
713
- setArgv("--from", "platform-src", "--to", "platform-dst");
1065
+ test("legacy --to flag shows deprecation message", async () => {
1066
+ setArgv("--from", "source", "--to", "target");
1067
+
1068
+ await expect(teleport()).rejects.toThrow("process.exit:1");
1069
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1070
+ expect.stringContaining("--to is deprecated"),
1071
+ );
1072
+ });
1073
+ });
1074
+
1075
+ // ---------------------------------------------------------------------------
1076
+ // Signed-URL upload tests
1077
+ // ---------------------------------------------------------------------------
1078
+
1079
+ describe("signed-URL upload flow", () => {
1080
+ test("happy path: signed URL upload succeeds → GCS-based import used", async () => {
1081
+ setArgv("--from", "my-local", "--platform");
1082
+
1083
+ const localEntry = makeEntry("my-local", { cloud: "local" });
714
1084
 
715
1085
  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
- });
1086
+ if (name === "my-local") return localEntry;
726
1087
  return null;
727
1088
  });
728
1089
 
729
- await teleport();
1090
+ const originalFetch = globalThis.fetch;
1091
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
730
1092
 
731
- // Platform export: should call all three platform export functions
732
- expect(platformInitiateExportMock).toHaveBeenCalled();
733
- expect(platformPollExportStatusMock).toHaveBeenCalled();
734
- expect(platformDownloadExportMock).toHaveBeenCalled();
1093
+ try {
1094
+ await teleport();
735
1095
 
736
- // Platform import: should call platformImportBundle
737
- expect(platformImportBundleMock).toHaveBeenCalled();
1096
+ // Signed-URL flow should be used
1097
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1098
+ expect(platformUploadToSignedUrlMock).toHaveBeenCalled();
1099
+ expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
1100
+ "bundle-key-123",
1101
+ "platform-token",
1102
+ "org-123",
1103
+ "https://platform.vellum.ai",
1104
+ );
1105
+ // Inline import should NOT be called
1106
+ expect(platformImportBundleMock).not.toHaveBeenCalled();
1107
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1108
+ expect.stringContaining("Teleport complete"),
1109
+ );
1110
+ } finally {
1111
+ globalThis.fetch = originalFetch;
1112
+ }
738
1113
  });
739
- });
740
1114
 
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 () => {
1115
+ test("happy path dry-run: signed URL upload succeeds → GCS-based preflight used", async () => {
747
1116
  setArgv(
748
1117
  "--from",
749
- "src",
750
- "--to",
751
- "dst",
752
- "--bogus-flag",
753
- "--another-unknown",
1118
+ "my-local",
1119
+ "--platform",
1120
+ "existing-platform",
1121
+ "--dry-run",
754
1122
  );
755
1123
 
1124
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1125
+ const platformEntry = makeEntry("existing-platform", {
1126
+ cloud: "vellum",
1127
+ runtimeUrl: "https://platform.vellum.ai",
1128
+ });
1129
+
756
1130
  findAssistantByNameMock.mockImplementation((name: string) => {
757
- if (name === "src") return makeEntry("src", { cloud: "local" });
758
- if (name === "dst") return makeEntry("dst", { cloud: "local" });
1131
+ if (name === "my-local") return localEntry;
1132
+ if (name === "existing-platform") return platformEntry;
759
1133
  return null;
760
1134
  });
761
1135
 
762
1136
  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" } },
1137
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1138
+
1139
+ try {
1140
+ await teleport();
1141
+
1142
+ // Signed-URL flow should be used for preflight
1143
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1144
+ expect(platformUploadToSignedUrlMock).toHaveBeenCalled();
1145
+ expect(platformImportPreflightFromGcsMock).toHaveBeenCalledWith(
1146
+ "bundle-key-123",
1147
+ "platform-token",
1148
+ "org-123",
1149
+ "https://platform.vellum.ai",
780
1150
  );
1151
+ // Inline preflight should NOT be called
1152
+ expect(platformImportPreflightMock).not.toHaveBeenCalled();
1153
+ } finally {
1154
+ globalThis.fetch = originalFetch;
1155
+ }
1156
+ });
1157
+
1158
+ test("fallback: platformRequestUploadUrl throws 503 → falls back to inline import", async () => {
1159
+ setArgv("--from", "my-local", "--platform");
1160
+
1161
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1162
+
1163
+ findAssistantByNameMock.mockImplementation((name: string) => {
1164
+ if (name === "my-local") return localEntry;
1165
+ return null;
781
1166
  });
782
- globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
1167
+
1168
+ // Simulate 503 — "not available" in the error message
1169
+ platformRequestUploadUrlMock.mockRejectedValue(
1170
+ new Error("Signed uploads are not available on this platform instance"),
1171
+ );
1172
+
1173
+ const originalFetch = globalThis.fetch;
1174
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1175
+
1176
+ try {
1177
+ await teleport();
1178
+
1179
+ // Should fall back to inline import
1180
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1181
+ expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
1182
+ expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
1183
+ expect(platformImportBundleMock).toHaveBeenCalled();
1184
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1185
+ expect.stringContaining("Teleport complete"),
1186
+ );
1187
+ } finally {
1188
+ globalThis.fetch = originalFetch;
1189
+ }
1190
+ });
1191
+
1192
+ test("fallback: platformRequestUploadUrl throws 404 → falls back to inline import", async () => {
1193
+ setArgv("--from", "my-local", "--platform");
1194
+
1195
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1196
+
1197
+ findAssistantByNameMock.mockImplementation((name: string) => {
1198
+ if (name === "my-local") return localEntry;
1199
+ return null;
1200
+ });
1201
+
1202
+ // Simulate 404 — endpoint doesn't exist on older platform versions
1203
+ platformRequestUploadUrlMock.mockRejectedValue(
1204
+ new Error("Signed uploads are not available on this platform instance"),
1205
+ );
1206
+
1207
+ const originalFetch = globalThis.fetch;
1208
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
783
1209
 
784
1210
  try {
785
1211
  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);
1212
+
1213
+ // Should fall back to inline import
1214
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1215
+ expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
1216
+ expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
1217
+ expect(platformImportBundleMock).toHaveBeenCalled();
1218
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1219
+ expect.stringContaining("Teleport complete"),
1220
+ );
1221
+ } finally {
1222
+ globalThis.fetch = originalFetch;
1223
+ }
1224
+ });
1225
+
1226
+ test("upload error: platformUploadToSignedUrl throws → error propagates", async () => {
1227
+ setArgv("--from", "my-local", "--platform");
1228
+
1229
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1230
+
1231
+ findAssistantByNameMock.mockImplementation((name: string) => {
1232
+ if (name === "my-local") return localEntry;
1233
+ return null;
1234
+ });
1235
+
1236
+ // Upload succeeds at getting URL but fails during PUT
1237
+ platformUploadToSignedUrlMock.mockRejectedValue(
1238
+ new Error("Upload to signed URL failed: 500 Internal Server Error"),
1239
+ );
1240
+
1241
+ const originalFetch = globalThis.fetch;
1242
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1243
+
1244
+ try {
1245
+ await expect(teleport()).rejects.toThrow(
1246
+ "Upload to signed URL failed: 500 Internal Server Error",
1247
+ );
1248
+ // Should NOT fall back to inline import
1249
+ expect(platformImportBundleMock).not.toHaveBeenCalled();
1250
+ expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
1251
+ } finally {
1252
+ globalThis.fetch = originalFetch;
1253
+ }
1254
+ });
1255
+
1256
+ test("413 from GCS import: error message includes 'too large'", async () => {
1257
+ setArgv("--from", "my-local", "--platform");
1258
+
1259
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1260
+
1261
+ findAssistantByNameMock.mockImplementation((name: string) => {
1262
+ if (name === "my-local") return localEntry;
1263
+ return null;
1264
+ });
1265
+
1266
+ // GCS import returns 413
1267
+ platformImportBundleFromGcsMock.mockRejectedValue(
1268
+ new Error("Bundle too large to import"),
1269
+ );
1270
+
1271
+ const originalFetch = globalThis.fetch;
1272
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1273
+
1274
+ try {
1275
+ await expect(teleport()).rejects.toThrow("too large");
791
1276
  } finally {
792
1277
  globalThis.fetch = originalFetch;
793
1278
  }
@@ -795,35 +1280,164 @@ describe("teleport extra arguments", () => {
795
1280
  });
796
1281
 
797
1282
  // ---------------------------------------------------------------------------
798
- // Edge case: --from or --to without a following value
1283
+ // Platform teleport org ID and reordered flow tests
799
1284
  // ---------------------------------------------------------------------------
800
1285
 
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
- );
1286
+ describe("platform teleport org ID and reordered flow", () => {
1287
+ test("hatchAssistant is called with the org ID from fetchOrganizationId", async () => {
1288
+ setArgv("--from", "my-local", "--platform");
1289
+
1290
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1291
+
1292
+ findAssistantByNameMock.mockImplementation((name: string) => {
1293
+ if (name === "my-local") return localEntry;
1294
+ return null;
1295
+ });
1296
+
1297
+ fetchOrganizationIdMock.mockResolvedValue("test-org-456");
1298
+
1299
+ const originalFetch = globalThis.fetch;
1300
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1301
+
1302
+ try {
1303
+ await teleport();
1304
+
1305
+ // fetchOrganizationId should have been called
1306
+ expect(fetchOrganizationIdMock).toHaveBeenCalled();
1307
+ // hatchAssistant must be called with (token, orgId)
1308
+ expect(hatchAssistantMock).toHaveBeenCalledWith(
1309
+ "platform-token",
1310
+ "test-org-456",
1311
+ );
1312
+ } finally {
1313
+ globalThis.fetch = originalFetch;
1314
+ }
810
1315
  });
811
1316
 
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
- );
1317
+ test("upload to GCS happens before hatchAssistant for platform targets", async () => {
1318
+ setArgv("--from", "my-local", "--platform");
1319
+
1320
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1321
+
1322
+ findAssistantByNameMock.mockImplementation((name: string) => {
1323
+ if (name === "my-local") return localEntry;
1324
+ return null;
1325
+ });
1326
+
1327
+ const callOrder: string[] = [];
1328
+
1329
+ platformRequestUploadUrlMock.mockImplementation(async () => {
1330
+ callOrder.push("platformRequestUploadUrl");
1331
+ return {
1332
+ uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
1333
+ bundleKey: "bundle-key-123",
1334
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
1335
+ };
1336
+ });
1337
+
1338
+ platformUploadToSignedUrlMock.mockImplementation(async () => {
1339
+ callOrder.push("platformUploadToSignedUrl");
1340
+ });
1341
+
1342
+ hatchAssistantMock.mockImplementation(async () => {
1343
+ callOrder.push("hatchAssistant");
1344
+ return { id: "platform-new-id", name: "platform-new", status: "active" };
1345
+ });
1346
+
1347
+ const originalFetch = globalThis.fetch;
1348
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1349
+
1350
+ try {
1351
+ await teleport();
1352
+
1353
+ // Verify ordering: upload steps come before hatch
1354
+ const uploadUrlIdx = callOrder.indexOf("platformRequestUploadUrl");
1355
+ const uploadIdx = callOrder.indexOf("platformUploadToSignedUrl");
1356
+ const hatchIdx = callOrder.indexOf("hatchAssistant");
1357
+
1358
+ expect(uploadUrlIdx).toBeGreaterThanOrEqual(0);
1359
+ expect(uploadIdx).toBeGreaterThanOrEqual(0);
1360
+ expect(hatchIdx).toBeGreaterThanOrEqual(0);
1361
+ expect(uploadUrlIdx).toBeLessThan(hatchIdx);
1362
+ expect(uploadIdx).toBeLessThan(hatchIdx);
1363
+ } finally {
1364
+ globalThis.fetch = originalFetch;
1365
+ }
818
1366
  });
819
1367
 
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:"),
1368
+ test("signed-URL fallback: when platformRequestUploadUrl throws 'not available', falls back to inline upload via importToAssistant", async () => {
1369
+ setArgv("--from", "my-local", "--platform");
1370
+
1371
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1372
+
1373
+ findAssistantByNameMock.mockImplementation((name: string) => {
1374
+ if (name === "my-local") return localEntry;
1375
+ return null;
1376
+ });
1377
+
1378
+ // Simulate 503 — signed uploads not available
1379
+ platformRequestUploadUrlMock.mockRejectedValue(
1380
+ new Error("Signed uploads are not available on this platform instance"),
827
1381
  );
1382
+
1383
+ const originalFetch = globalThis.fetch;
1384
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1385
+
1386
+ try {
1387
+ await teleport();
1388
+
1389
+ // Upload URL was attempted but failed
1390
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1391
+ // No signed URL upload should have happened
1392
+ expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
1393
+ // Should NOT use GCS-based import
1394
+ expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
1395
+ // Should fall back to inline import
1396
+ expect(platformImportBundleMock).toHaveBeenCalled();
1397
+ // Hatch should still succeed
1398
+ expect(hatchAssistantMock).toHaveBeenCalled();
1399
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1400
+ expect.stringContaining("Teleport complete"),
1401
+ );
1402
+ } finally {
1403
+ globalThis.fetch = originalFetch;
1404
+ }
1405
+ });
1406
+
1407
+ test("bundleKey from pre-upload is forwarded to platformImportBundleFromGcs", async () => {
1408
+ setArgv("--from", "my-local", "--platform");
1409
+
1410
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1411
+
1412
+ findAssistantByNameMock.mockImplementation((name: string) => {
1413
+ if (name === "my-local") return localEntry;
1414
+ return null;
1415
+ });
1416
+
1417
+ // Return a specific bundle key from the pre-upload step
1418
+ platformRequestUploadUrlMock.mockResolvedValue({
1419
+ uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
1420
+ bundleKey: "pre-uploaded-key-789",
1421
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
1422
+ });
1423
+
1424
+ const originalFetch = globalThis.fetch;
1425
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1426
+
1427
+ try {
1428
+ await teleport();
1429
+
1430
+ // The bundle key from the pre-upload step should be forwarded to GCS import
1431
+ expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
1432
+ "pre-uploaded-key-789",
1433
+ "platform-token",
1434
+ expect.any(String),
1435
+ expect.any(String),
1436
+ );
1437
+ // Inline import should NOT be used since signed upload succeeded
1438
+ expect(platformImportBundleMock).not.toHaveBeenCalled();
1439
+ } finally {
1440
+ globalThis.fetch = originalFetch;
1441
+ }
828
1442
  });
829
1443
  });