@vellumai/cli 0.5.14 → 0.5.16

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",
@@ -90,6 +112,8 @@ const platformImportBundleMock = mock(async () => ({
90
112
  mock.module("../lib/platform-client.js", () => ({
91
113
  readPlatformToken: readPlatformTokenMock,
92
114
  fetchOrganizationId: fetchOrganizationIdMock,
115
+ getPlatformUrl: getPlatformUrlMock,
116
+ hatchAssistant: hatchAssistantMock,
93
117
  platformInitiateExport: platformInitiateExportMock,
94
118
  platformPollExportStatus: platformPollExportStatusMock,
95
119
  platformDownloadExport: platformDownloadExportMock,
@@ -97,7 +121,47 @@ mock.module("../lib/platform-client.js", () => ({
97
121
  platformImportBundle: platformImportBundleMock,
98
122
  }));
99
123
 
100
- import { teleport } from "../commands/teleport.js";
124
+ const hatchLocalMock = mock(async () => {});
125
+
126
+ mock.module("../lib/hatch-local.js", () => ({
127
+ hatchLocal: hatchLocalMock,
128
+ }));
129
+
130
+ const hatchDockerMock = mock(async () => {});
131
+ const retireDockerMock = mock(async () => {});
132
+
133
+ const sleepContainersMock = mock(async () => {});
134
+ const dockerResourceNamesMock = mock((name: string) => ({
135
+ assistantContainer: `${name}-assistant`,
136
+ gatewayContainer: `${name}-gateway`,
137
+ cesContainer: `${name}-ces`,
138
+ network: `${name}-net`,
139
+ }));
140
+
141
+ mock.module("../lib/docker.js", () => ({
142
+ hatchDocker: hatchDockerMock,
143
+ retireDocker: retireDockerMock,
144
+ sleepContainers: sleepContainersMock,
145
+ dockerResourceNames: dockerResourceNamesMock,
146
+ }));
147
+
148
+ const stopProcessByPidFileMock = mock(async () => true);
149
+
150
+ mock.module("../lib/process.js", () => ({
151
+ stopProcessByPidFile: stopProcessByPidFileMock,
152
+ }));
153
+
154
+ const retireLocalMock = mock(async () => {});
155
+
156
+ mock.module("../lib/retire-local.js", () => ({
157
+ retireLocal: retireLocalMock,
158
+ }));
159
+
160
+ import {
161
+ teleport,
162
+ parseArgs,
163
+ resolveOrHatchTarget,
164
+ } from "../commands/teleport.js";
101
165
  import type { AssistantEntry } from "../lib/assistant-config.js";
102
166
 
103
167
  // ---------------------------------------------------------------------------
@@ -106,6 +170,9 @@ import type { AssistantEntry } from "../lib/assistant-config.js";
106
170
 
107
171
  afterAll(() => {
108
172
  findAssistantByNameMock.mockRestore();
173
+ saveAssistantEntryMock.mockRestore();
174
+ loadAllAssistantsMock.mockRestore();
175
+ removeAssistantEntryMock.mockRestore();
109
176
  rmSync(testDir, { recursive: true, force: true });
110
177
  delete process.env.VELLUM_LOCKFILE_DIR;
111
178
  });
@@ -122,6 +189,12 @@ beforeEach(() => {
122
189
  // Reset all mocks
123
190
  findAssistantByNameMock.mockReset();
124
191
  findAssistantByNameMock.mockReturnValue(null);
192
+ saveAssistantEntryMock.mockReset();
193
+ saveAssistantEntryMock.mockImplementation(() => {});
194
+ loadAllAssistantsMock.mockReset();
195
+ loadAllAssistantsMock.mockReturnValue([]);
196
+ removeAssistantEntryMock.mockReset();
197
+ removeAssistantEntryMock.mockImplementation(() => {});
125
198
 
126
199
  loadGuardianTokenMock.mockReset();
127
200
  loadGuardianTokenMock.mockReturnValue({
@@ -134,6 +207,14 @@ beforeEach(() => {
134
207
  readPlatformTokenMock.mockReturnValue("platform-token");
135
208
  fetchOrganizationIdMock.mockReset();
136
209
  fetchOrganizationIdMock.mockResolvedValue("org-123");
210
+ getPlatformUrlMock.mockReset();
211
+ getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
212
+ hatchAssistantMock.mockReset();
213
+ hatchAssistantMock.mockResolvedValue({
214
+ id: "platform-new-id",
215
+ name: "platform-new",
216
+ status: "active",
217
+ });
137
218
  platformInitiateExportMock.mockReset();
138
219
  platformInitiateExportMock.mockResolvedValue({
139
220
  jobId: "job-1",
@@ -176,6 +257,19 @@ beforeEach(() => {
176
257
  },
177
258
  });
178
259
 
260
+ hatchLocalMock.mockReset();
261
+ hatchLocalMock.mockResolvedValue(undefined);
262
+ hatchDockerMock.mockReset();
263
+ hatchDockerMock.mockResolvedValue(undefined);
264
+ retireDockerMock.mockReset();
265
+ retireDockerMock.mockResolvedValue(undefined);
266
+ retireLocalMock.mockReset();
267
+ retireLocalMock.mockResolvedValue(undefined);
268
+ sleepContainersMock.mockReset();
269
+ sleepContainersMock.mockResolvedValue(undefined);
270
+ stopProcessByPidFileMock.mockReset();
271
+ stopProcessByPidFileMock.mockResolvedValue(true);
272
+
179
273
  // Mock process.exit to throw so we can catch it
180
274
  exitMock = mock((code?: number) => {
181
275
  throw new Error(`process.exit:${code}`);
@@ -187,8 +281,6 @@ beforeEach(() => {
187
281
  consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
188
282
  });
189
283
 
190
- import { afterEach } from "bun:test";
191
-
192
284
  afterEach(() => {
193
285
  process.argv = originalArgv;
194
286
  process.exit = originalExit;
@@ -213,24 +305,44 @@ function makeEntry(
213
305
  };
214
306
  }
215
307
 
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
- );
308
+ /** Create a mock fetch that handles export and import endpoints. */
309
+ function createFetchMock() {
310
+ return mock(async (url: string | URL | Request) => {
311
+ const urlStr = typeof url === "string" ? url : url.toString();
312
+ if (urlStr.includes("/export")) {
313
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
314
+ }
315
+ if (urlStr.includes("/import-preflight")) {
316
+ return new Response(
317
+ JSON.stringify({
318
+ can_import: true,
319
+ summary: {
320
+ files_to_create: 1,
321
+ files_to_overwrite: 0,
322
+ files_unchanged: 0,
323
+ total_files: 1,
324
+ },
325
+ }),
326
+ { status: 200, headers: { "Content-Type": "application/json" } },
327
+ );
328
+ }
329
+ if (urlStr.includes("/import")) {
330
+ return new Response(
331
+ JSON.stringify({
332
+ success: true,
333
+ summary: {
334
+ total_files: 1,
335
+ files_created: 1,
336
+ files_overwritten: 0,
337
+ files_skipped: 0,
338
+ backups_created: 0,
339
+ },
340
+ }),
341
+ { status: 200, headers: { "Content-Type": "application/json" } },
342
+ );
343
+ }
344
+ return new Response("not found", { status: 404 });
345
+ });
234
346
  }
235
347
 
236
348
  // ---------------------------------------------------------------------------
@@ -254,7 +366,7 @@ describe("teleport arg parsing", () => {
254
366
  );
255
367
  });
256
368
 
257
- test("missing --from and --to prints help and exits 1", async () => {
369
+ test("missing --from and env flag prints help and exits 1", async () => {
258
370
  setArgv();
259
371
  await expect(teleport()).rejects.toThrow("process.exit:1");
260
372
  expect(consoleLogSpy).toHaveBeenCalledWith(
@@ -262,7 +374,7 @@ describe("teleport arg parsing", () => {
262
374
  );
263
375
  });
264
376
 
265
- test("missing --to prints help and exits 1", async () => {
377
+ test("missing env flag prints help and exits 1", async () => {
266
378
  setArgv("--from", "source");
267
379
  await expect(teleport()).rejects.toThrow("process.exit:1");
268
380
  expect(consoleLogSpy).toHaveBeenCalledWith(
@@ -270,126 +382,122 @@ describe("teleport arg parsing", () => {
270
382
  );
271
383
  });
272
384
 
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:"),
385
+ test("--local sets targetEnv to 'local' with no name", () => {
386
+ const result = parseArgs(["--from", "source", "--local"]);
387
+ expect(result.from).toBe("source");
388
+ expect(result.targetEnv).toBe("local");
389
+ expect(result.targetName).toBeUndefined();
390
+ });
391
+
392
+ test("--docker my-name sets targetEnv to 'docker' and targetName to 'my-name'", () => {
393
+ const result = parseArgs(["--from", "source", "--docker", "my-name"]);
394
+ expect(result.from).toBe("source");
395
+ expect(result.targetEnv).toBe("docker");
396
+ expect(result.targetName).toBe("my-name");
397
+ });
398
+
399
+ test("--platform sets targetEnv to 'platform'", () => {
400
+ const result = parseArgs(["--from", "source", "--platform"]);
401
+ expect(result.from).toBe("source");
402
+ expect(result.targetEnv).toBe("platform");
403
+ expect(result.targetName).toBeUndefined();
404
+ });
405
+
406
+ test("multiple env flags error", () => {
407
+ expect(() => parseArgs(["--from", "src", "--local", "--docker"])).toThrow(
408
+ "process.exit:1",
409
+ );
410
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
411
+ expect.stringContaining("Only one environment flag"),
278
412
  );
279
413
  });
280
414
 
281
- test("--from and --to are correctly parsed", async () => {
282
- setArgv("--from", "source", "--to", "target");
415
+ test("--keep-source is parsed", () => {
416
+ const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
417
+ expect(result.keepSource).toBe(true);
418
+ });
419
+
420
+ test("--dry-run is parsed", () => {
421
+ const result = parseArgs(["--from", "source", "--local", "--dry-run"]);
422
+ expect(result.dryRun).toBe(true);
423
+ });
424
+
425
+ test("target name after env flag is consumed but --flags are not", () => {
426
+ const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
427
+ expect(result.targetEnv).toBe("docker");
428
+ expect(result.targetName).toBeUndefined();
429
+ expect(result.keepSource).toBe(true);
430
+ });
431
+ });
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Same-environment rejection tests
435
+ // ---------------------------------------------------------------------------
436
+
437
+ describe("same-environment rejection", () => {
438
+ test("source local, target local -> error (after resolving target)", async () => {
439
+ setArgv("--from", "src", "--local", "dst");
440
+
441
+ const srcEntry = makeEntry("src", { cloud: "local" });
442
+ const dstEntry = makeEntry("dst", { cloud: "local" });
283
443
 
284
444
  findAssistantByNameMock.mockImplementation((name: string) => {
285
- if (name === "source") return makeEntry("source");
286
- if (name === "target") return makeEntry("target");
445
+ if (name === "src") return srcEntry;
446
+ if (name === "dst") return dstEntry;
287
447
  return null;
288
448
  });
289
449
 
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
450
  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;
451
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
315
452
 
316
453
  try {
317
- await teleport();
318
- // Should call findAssistantByName for both source and target
319
- expect(findAssistantByNameMock).toHaveBeenCalledWith("source");
320
- expect(findAssistantByNameMock).toHaveBeenCalledWith("target");
454
+ await expect(teleport()).rejects.toThrow("process.exit:1");
455
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
456
+ expect.stringContaining("Cannot teleport between two local assistants"),
457
+ );
321
458
  } finally {
322
459
  globalThis.fetch = originalFetch;
323
460
  }
324
461
  });
325
462
 
326
- test("--dry-run flag is detected", async () => {
327
- setArgv("--from", "source", "--to", "target", "--dry-run");
463
+ test("source docker, target docker -> error (after resolving target)", async () => {
464
+ setArgv("--from", "src", "--docker", "dst");
465
+
466
+ const srcEntry = makeEntry("src", { cloud: "docker" });
467
+ const dstEntry = makeEntry("dst", { cloud: "docker" });
328
468
 
329
469
  findAssistantByNameMock.mockImplementation((name: string) => {
330
- if (name === "source") return makeEntry("source");
331
- if (name === "target") return makeEntry("target");
470
+ if (name === "src") return srcEntry;
471
+ if (name === "dst") return dstEntry;
332
472
  return null;
333
473
  });
334
474
 
335
475
  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;
476
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
358
477
 
359
478
  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);
479
+ await expect(teleport()).rejects.toThrow("process.exit:1");
480
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
481
+ expect.stringContaining(
482
+ "Cannot teleport between two docker assistants",
483
+ ),
484
+ );
364
485
  } finally {
365
486
  globalThis.fetch = originalFetch;
366
487
  }
367
488
  });
368
489
 
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");
490
+ test("source vellum, target platform -> error (after resolving target)", async () => {
491
+ setArgv("--from", "src", "--platform", "dst");
387
492
 
388
493
  const srcEntry = makeEntry("src", {
389
494
  cloud: "vellum",
390
495
  runtimeUrl: "https://platform.vellum.ai",
391
496
  });
392
- const dstEntry = makeEntry("dst", { cloud: "local" });
497
+ const dstEntry = makeEntry("dst", {
498
+ cloud: "vellum",
499
+ runtimeUrl: "https://platform.vellum.ai",
500
+ });
393
501
 
394
502
  findAssistantByNameMock.mockImplementation((name: string) => {
395
503
  if (name === "src") return srcEntry;
@@ -397,433 +505,496 @@ describe("teleport cloud resolution", () => {
397
505
  return null;
398
506
  });
399
507
 
400
- // Platform export path: readPlatformToken → fetchOrganizationId → initiateExport → poll → download
401
- // then local import
402
508
  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;
509
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
419
510
 
420
511
  try {
421
- await teleport();
422
- // Should have used platform export functions
423
- expect(platformInitiateExportMock).toHaveBeenCalled();
424
- expect(platformPollExportStatusMock).toHaveBeenCalled();
425
- expect(platformDownloadExportMock).toHaveBeenCalled();
512
+ await expect(teleport()).rejects.toThrow("process.exit:1");
513
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
514
+ expect.stringContaining(
515
+ "Cannot teleport between two platform assistants",
516
+ ),
517
+ );
426
518
  } finally {
427
519
  globalThis.fetch = originalFetch;
428
520
  }
429
521
  });
430
522
 
431
- test("entry with cloud: 'local' resolves to local", async () => {
432
- setArgv("--from", "src", "--to", "dst");
523
+ test("same-env rejection happens before hatching (no orphaned assistants)", async () => {
524
+ setArgv("--from", "my-local", "--local");
433
525
 
434
- const srcEntry = makeEntry("src", { cloud: "local" });
435
- const dstEntry = makeEntry("dst", {
436
- cloud: "vellum",
437
- runtimeUrl: "https://platform.vellum.ai",
526
+ const localEntry = makeEntry("my-local", { cloud: "local" });
527
+ findAssistantByNameMock.mockImplementation((name: string) => {
528
+ if (name === "my-local") return localEntry;
529
+ return null;
438
530
  });
439
531
 
532
+ await expect(teleport()).rejects.toThrow("process.exit:1");
533
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
534
+ expect.stringContaining("Cannot teleport between two local assistants"),
535
+ );
536
+ // Crucially: no hatch should have been called — the early guard fires first
537
+ expect(hatchLocalMock).not.toHaveBeenCalled();
538
+ expect(hatchDockerMock).not.toHaveBeenCalled();
539
+ expect(hatchAssistantMock).not.toHaveBeenCalled();
540
+ });
541
+
542
+ test("same-env rejection before hatching for docker", async () => {
543
+ setArgv("--from", "my-docker", "--docker");
544
+
545
+ const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
440
546
  findAssistantByNameMock.mockImplementation((name: string) => {
441
- if (name === "src") return srcEntry;
442
- if (name === "dst") return dstEntry;
547
+ if (name === "my-docker") return dockerEntry;
443
548
  return null;
444
549
  });
445
550
 
446
- const originalFetch = globalThis.fetch;
447
- const fetchMock = mock(async () => {
448
- return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
551
+ await expect(teleport()).rejects.toThrow("process.exit:1");
552
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
553
+ expect.stringContaining("Cannot teleport between two docker assistants"),
554
+ );
555
+ expect(hatchLocalMock).not.toHaveBeenCalled();
556
+ expect(hatchDockerMock).not.toHaveBeenCalled();
557
+ expect(hatchAssistantMock).not.toHaveBeenCalled();
558
+ });
559
+
560
+ test("same-env rejection before hatching for platform (vellum cloud)", async () => {
561
+ setArgv("--from", "my-cloud", "--platform");
562
+
563
+ const platformEntry = makeEntry("my-cloud", {
564
+ cloud: "vellum",
565
+ runtimeUrl: "https://platform.vellum.ai",
566
+ });
567
+ findAssistantByNameMock.mockImplementation((name: string) => {
568
+ if (name === "my-cloud") return platformEntry;
569
+ return null;
449
570
  });
450
- globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
451
571
 
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
- }
572
+ await expect(teleport()).rejects.toThrow("process.exit:1");
573
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
574
+ expect.stringContaining(
575
+ "Cannot teleport between two platform assistants",
576
+ ),
577
+ );
578
+ expect(hatchLocalMock).not.toHaveBeenCalled();
579
+ expect(hatchDockerMock).not.toHaveBeenCalled();
580
+ expect(hatchAssistantMock).not.toHaveBeenCalled();
462
581
  });
463
582
 
464
- test("entry with no cloud but project set resolves to gcp (unsupported)", async () => {
465
- setArgv("--from", "src", "--to", "dst");
583
+ test("flag says docker but resolved target is local -> rejects cloud mismatch", async () => {
584
+ // User passes --docker but the named target is actually a local assistant
585
+ setArgv("--from", "src", "--docker", "misidentified");
466
586
 
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" });
587
+ const srcEntry = makeEntry("src", { cloud: "vellum" });
588
+ // Target is actually local despite the --docker flag
589
+ const dstEntry = makeEntry("misidentified", { cloud: "local" });
473
590
 
474
591
  findAssistantByNameMock.mockImplementation((name: string) => {
475
592
  if (name === "src") return srcEntry;
476
- if (name === "dst") return dstEntry;
593
+ if (name === "misidentified") return dstEntry;
477
594
  return null;
478
595
  });
479
596
 
480
- // GCP is unsupported, should print error and exit
481
597
  await expect(teleport()).rejects.toThrow("process.exit:1");
482
598
  expect(consoleErrorSpy).toHaveBeenCalledWith(
483
- expect.stringContaining("only supports local and platform"),
599
+ expect.stringContaining("is a local assistant, not docker"),
484
600
  );
485
601
  });
602
+ });
486
603
 
487
- test("entry with no cloud and no project resolves to local", async () => {
488
- setArgv("--from", "src", "--to", "dst");
604
+ // ---------------------------------------------------------------------------
605
+ // resolveOrHatchTarget tests
606
+ // ---------------------------------------------------------------------------
489
607
 
490
- // cloud is falsy, no project — resolveCloud returns "local"
491
- const srcEntry = makeEntry("src", { cloud: "" as string });
492
- const dstEntry = makeEntry("dst", { cloud: "local" });
608
+ describe("resolveOrHatchTarget", () => {
609
+ test("existing assistant is returned without hatching", async () => {
610
+ const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
611
+ findAssistantByNameMock.mockImplementation((name: string) => {
612
+ if (name === "my-docker") return dockerEntry;
613
+ return null;
614
+ });
615
+
616
+ const result = await resolveOrHatchTarget("docker", "my-docker");
617
+ expect(result).toBe(dockerEntry);
618
+ expect(hatchDockerMock).not.toHaveBeenCalled();
619
+ expect(consoleLogSpy).toHaveBeenCalledWith(
620
+ expect.stringContaining("Target: my-docker (docker)"),
621
+ );
622
+ });
493
623
 
624
+ test("name not found -> hatch docker", async () => {
625
+ const newEntry = makeEntry("new-one", { cloud: "docker" });
494
626
  findAssistantByNameMock.mockImplementation((name: string) => {
495
- if (name === "src") return srcEntry;
496
- if (name === "dst") return dstEntry;
627
+ // First call: lookup by name -> not found
628
+ // Second call: after hatch -> found
629
+ if (name === "new-one" && hatchDockerMock.mock.calls.length > 0) {
630
+ return newEntry;
631
+ }
497
632
  return null;
498
633
  });
499
634
 
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 });
635
+ const result = await resolveOrHatchTarget("docker", "new-one");
636
+ expect(hatchDockerMock).toHaveBeenCalledWith(
637
+ "vellum",
638
+ false,
639
+ "new-one",
640
+ false,
641
+ {},
642
+ );
643
+ expect(result).toBe(newEntry);
644
+ });
645
+
646
+ test("no name -> hatch local with null name, discovers via diff", async () => {
647
+ const existingEntry = makeEntry("existing-local", { cloud: "local" });
648
+ const newEntry = makeEntry("auto-generated", { cloud: "local" });
649
+
650
+ // Before hatch: only the existing entry
651
+ // After hatch: existing + new entry
652
+ loadAllAssistantsMock.mockImplementation(() => {
653
+ if (hatchLocalMock.mock.calls.length > 0) {
654
+ return [existingEntry, newEntry];
505
655
  }
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
- );
656
+ return [existingEntry];
520
657
  });
521
- globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
522
658
 
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
- }
659
+ const result = await resolveOrHatchTarget("local");
660
+ expect(hatchLocalMock).toHaveBeenCalledWith(
661
+ "vellum",
662
+ null,
663
+ false,
664
+ false,
665
+ false,
666
+ {},
667
+ );
668
+ expect(result).toBe(newEntry);
669
+ });
670
+
671
+ test("platform with existing ID -> returns existing without hatching", async () => {
672
+ const platformEntry = makeEntry("uuid-123", {
673
+ cloud: "vellum",
674
+ runtimeUrl: "https://platform.vellum.ai",
675
+ });
676
+ findAssistantByNameMock.mockImplementation((name: string) => {
677
+ if (name === "uuid-123") return platformEntry;
678
+ return null;
679
+ });
680
+
681
+ const result = await resolveOrHatchTarget("platform", "uuid-123");
682
+ expect(result).toBe(platformEntry);
683
+ expect(hatchAssistantMock).not.toHaveBeenCalled();
684
+ });
685
+
686
+ test("platform with unknown name -> hatches via hatchAssistant", async () => {
687
+ findAssistantByNameMock.mockReturnValue(null);
688
+
689
+ const result = await resolveOrHatchTarget("platform", "nonexistent");
690
+ expect(hatchAssistantMock).toHaveBeenCalledWith("platform-token");
691
+ expect(saveAssistantEntryMock).toHaveBeenCalledWith(
692
+ expect.objectContaining({
693
+ assistantId: "platform-new-id",
694
+ cloud: "vellum",
695
+ }),
696
+ );
697
+ expect(result.assistantId).toBe("platform-new-id");
698
+ });
699
+
700
+ test("existing assistant with wrong cloud -> rejects", async () => {
701
+ const localEntry = makeEntry("my-local", { cloud: "local" });
702
+ findAssistantByNameMock.mockImplementation((name: string) => {
703
+ if (name === "my-local") return localEntry;
704
+ return null;
705
+ });
706
+
707
+ await expect(resolveOrHatchTarget("docker", "my-local")).rejects.toThrow(
708
+ "process.exit:1",
709
+ );
710
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
711
+ expect.stringContaining("is a local assistant, not docker"),
712
+ );
713
+ });
714
+
715
+ test("name with path traversal -> rejects before hatching", async () => {
716
+ findAssistantByNameMock.mockReturnValue(null);
717
+
718
+ await expect(
719
+ resolveOrHatchTarget("docker", "../../../etc/passwd"),
720
+ ).rejects.toThrow("process.exit:1");
721
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
722
+ expect.stringContaining("invalid characters"),
723
+ );
724
+ expect(hatchDockerMock).not.toHaveBeenCalled();
531
725
  });
532
726
  });
533
727
 
534
728
  // ---------------------------------------------------------------------------
535
- // Transfer routing tests
729
+ // Auto-retire tests
536
730
  // ---------------------------------------------------------------------------
537
731
 
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");
732
+ describe("auto-retire", () => {
733
+ test("local -> docker: stops source before hatch, retires after import", async () => {
734
+ setArgv("--from", "my-local", "--docker");
735
+
736
+ const localEntry = makeEntry("my-local", {
737
+ cloud: "local",
738
+ resources: {
739
+ instanceDir: "/home/test",
740
+ pidFile: "/home/test/.vellum/assistant.pid",
741
+ signingKey: "key",
742
+ daemonPort: 7821,
743
+ gatewayPort: 7830,
744
+ qdrantPort: 6333,
745
+ cesPort: 8090,
746
+ },
747
+ });
748
+ const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
541
749
 
542
750
  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
- });
751
+ if (name === "my-local") return localEntry;
550
752
  return null;
551
753
  });
552
754
 
553
- const originalFetch = globalThis.fetch;
554
- const fetchMock = mock(async () => {
555
- return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
755
+ // Simulate hatch creating a new docker entry
756
+ loadAllAssistantsMock.mockImplementation(() => {
757
+ if (hatchDockerMock.mock.calls.length > 0) {
758
+ return [localEntry, dockerEntry];
759
+ }
760
+ return [localEntry];
556
761
  });
557
- globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
762
+
763
+ const originalFetch = globalThis.fetch;
764
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
558
765
 
559
766
  try {
560
767
  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();
768
+ // Source should be stopped (slept) before hatch
769
+ expect(stopProcessByPidFileMock).toHaveBeenCalled();
770
+ // Retire happens after successful import
771
+ expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
772
+ expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-local");
571
773
  } finally {
572
774
  globalThis.fetch = originalFetch;
573
775
  }
574
776
  });
575
777
 
576
- test("platform local calls platform export functions and local import endpoint", async () => {
577
- setArgv("--from", "platform-src", "--to", "local-dst");
778
+ test("docker -> local: sleeps containers before hatch, retires after import", async () => {
779
+ setArgv("--from", "my-docker", "--local");
780
+
781
+ const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
782
+ const localEntry = makeEntry("new-local", { cloud: "local" });
578
783
 
579
784
  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" });
785
+ if (name === "my-docker") return dockerEntry;
587
786
  return null;
588
787
  });
589
788
 
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
- );
789
+ loadAllAssistantsMock.mockImplementation(() => {
790
+ if (hatchLocalMock.mock.calls.length > 0) {
791
+ return [dockerEntry, localEntry];
792
+ }
793
+ return [dockerEntry];
605
794
  });
606
- globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
795
+
796
+ const originalFetch = globalThis.fetch;
797
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
607
798
 
608
799
  try {
609
800
  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();
801
+ // Docker source should be slept (containers stopped) before hatch
802
+ expect(sleepContainersMock).toHaveBeenCalled();
803
+ // Retire happens after successful import
804
+ expect(retireDockerMock).toHaveBeenCalledWith("my-docker");
805
+ expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-docker");
625
806
  } finally {
626
807
  globalThis.fetch = originalFetch;
627
808
  }
628
809
  });
629
810
 
630
- test("--dry-run calls preflight instead of import (local target)", async () => {
631
- setArgv("--from", "src", "--to", "dst", "--dry-run");
811
+ test("--keep-source skips retire and removeAssistantEntry", async () => {
812
+ setArgv("--from", "my-local", "--docker", "--keep-source");
813
+
814
+ const localEntry = makeEntry("my-local", { cloud: "local" });
815
+ const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
632
816
 
633
817
  findAssistantByNameMock.mockImplementation((name: string) => {
634
- if (name === "src") return makeEntry("src", { cloud: "local" });
635
- if (name === "dst") return makeEntry("dst", { cloud: "local" });
818
+ if (name === "my-local") return localEntry;
636
819
  return null;
637
820
  });
638
821
 
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
- );
822
+ loadAllAssistantsMock.mockImplementation(() => {
823
+ if (hatchDockerMock.mock.calls.length > 0) {
824
+ return [localEntry, dockerEntry];
658
825
  }
659
- return new Response("not found", { status: 404 });
826
+ return [localEntry];
660
827
  });
661
- globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
828
+
829
+ const originalFetch = globalThis.fetch;
830
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
662
831
 
663
832
  try {
664
833
  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"),
834
+ expect(retireLocalMock).not.toHaveBeenCalled();
835
+ expect(retireDockerMock).not.toHaveBeenCalled();
836
+ expect(removeAssistantEntryMock).not.toHaveBeenCalled();
837
+ expect(consoleLogSpy).toHaveBeenCalledWith(
838
+ expect.stringContaining("kept (--keep-source)"),
673
839
  );
674
- expect(importCalls.length).toBe(0);
675
840
  } finally {
676
841
  globalThis.fetch = originalFetch;
677
842
  }
678
843
  });
679
844
 
680
- test("--dry-run calls platformImportPreflight instead of platformImportBundle (platform target)", async () => {
681
- setArgv("--from", "src", "--to", "dst", "--dry-run");
845
+ test("platform transfers skip retire", async () => {
846
+ setArgv("--from", "my-local", "--platform");
847
+
848
+ const localEntry = makeEntry("my-local", { cloud: "local" });
682
849
 
683
850
  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
- });
851
+ if (name === "my-local") return localEntry;
690
852
  return null;
691
853
  });
692
854
 
693
855
  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;
856
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
698
857
 
699
858
  try {
700
859
  await teleport();
701
-
702
- // Should call platformImportPreflight
703
- expect(platformImportPreflightMock).toHaveBeenCalled();
704
-
705
- // Should NOT call platformImportBundle
706
- expect(platformImportBundleMock).not.toHaveBeenCalled();
860
+ expect(retireLocalMock).not.toHaveBeenCalled();
861
+ expect(retireDockerMock).not.toHaveBeenCalled();
862
+ expect(removeAssistantEntryMock).not.toHaveBeenCalled();
707
863
  } finally {
708
864
  globalThis.fetch = originalFetch;
709
865
  }
710
866
  });
711
867
 
712
- test("platform platform calls platform export and platform import", async () => {
713
- setArgv("--from", "platform-src", "--to", "platform-dst");
868
+ test("dry-run without existing target does not hatch or export", async () => {
869
+ setArgv("--from", "my-local", "--docker", "--dry-run");
870
+
871
+ const localEntry = makeEntry("my-local", { cloud: "local" });
714
872
 
715
873
  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
- });
874
+ if (name === "my-local") return localEntry;
726
875
  return null;
727
876
  });
728
877
 
729
878
  await teleport();
730
879
 
731
- // Platform export: should call all three platform export functions
732
- expect(platformInitiateExportMock).toHaveBeenCalled();
733
- expect(platformPollExportStatusMock).toHaveBeenCalled();
734
- expect(platformDownloadExportMock).toHaveBeenCalled();
880
+ // Should NOT hatch, export, import, or retire
881
+ expect(hatchDockerMock).not.toHaveBeenCalled();
882
+ expect(hatchLocalMock).not.toHaveBeenCalled();
883
+ expect(hatchAssistantMock).not.toHaveBeenCalled();
884
+ expect(retireLocalMock).not.toHaveBeenCalled();
885
+ expect(retireDockerMock).not.toHaveBeenCalled();
886
+ expect(removeAssistantEntryMock).not.toHaveBeenCalled();
887
+ });
888
+
889
+ test("dry-run with existing target runs preflight without hatching", async () => {
890
+ setArgv("--from", "my-local", "--docker", "my-docker", "--dry-run");
891
+
892
+ const localEntry = makeEntry("my-local", { cloud: "local" });
893
+ const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
894
+
895
+ findAssistantByNameMock.mockImplementation((name: string) => {
896
+ if (name === "my-local") return localEntry;
897
+ if (name === "my-docker") return dockerEntry;
898
+ return null;
899
+ });
900
+
901
+ const originalFetch = globalThis.fetch;
902
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
903
+
904
+ try {
905
+ await teleport();
735
906
 
736
- // Platform import: should call platformImportBundle
737
- expect(platformImportBundleMock).toHaveBeenCalled();
907
+ // Should NOT hatch or retire
908
+ expect(hatchDockerMock).not.toHaveBeenCalled();
909
+ expect(hatchLocalMock).not.toHaveBeenCalled();
910
+ expect(retireLocalMock).not.toHaveBeenCalled();
911
+ expect(retireDockerMock).not.toHaveBeenCalled();
912
+ expect(removeAssistantEntryMock).not.toHaveBeenCalled();
913
+ } finally {
914
+ globalThis.fetch = originalFetch;
915
+ }
738
916
  });
739
917
  });
740
918
 
741
919
  // ---------------------------------------------------------------------------
742
- // Edge case: extra/unrecognized arguments
920
+ // Full flow tests
743
921
  // ---------------------------------------------------------------------------
744
922
 
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
- );
923
+ describe("teleport full flow", () => {
924
+ test("hatch and import: --from my-local --docker", async () => {
925
+ setArgv("--from", "my-local", "--docker");
926
+
927
+ const localEntry = makeEntry("my-local", { cloud: "local" });
928
+ const dockerEntry = makeEntry("new-docker", { cloud: "docker" });
755
929
 
756
930
  findAssistantByNameMock.mockImplementation((name: string) => {
757
- if (name === "src") return makeEntry("src", { cloud: "local" });
758
- if (name === "dst") return makeEntry("dst", { cloud: "local" });
931
+ if (name === "my-local") return localEntry;
759
932
  return null;
760
933
  });
761
934
 
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 });
935
+ loadAllAssistantsMock.mockImplementation(() => {
936
+ if (hatchDockerMock.mock.calls.length > 0) {
937
+ return [localEntry, dockerEntry];
767
938
  }
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
- );
939
+ return [localEntry];
781
940
  });
941
+
942
+ const originalFetch = globalThis.fetch;
943
+ const fetchMock = createFetchMock();
782
944
  globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
783
945
 
784
946
  try {
785
947
  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);
948
+
949
+ // Verify sequence: export, hatch, import, retire
950
+ expect(hatchDockerMock).toHaveBeenCalled();
951
+ expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
952
+ expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-local");
953
+ expect(consoleLogSpy).toHaveBeenCalledWith(
954
+ expect.stringContaining("Teleport complete"),
955
+ );
791
956
  } finally {
792
957
  globalThis.fetch = originalFetch;
793
958
  }
794
959
  });
795
- });
796
960
 
797
- // ---------------------------------------------------------------------------
798
- // Edge case: --from or --to without a following value
799
- // ---------------------------------------------------------------------------
961
+ test("existing target overwrite: --from my-local --docker my-existing", async () => {
962
+ setArgv("--from", "my-local", "--docker", "my-existing");
800
963
 
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
- });
964
+ const localEntry = makeEntry("my-local", { cloud: "local" });
965
+ const dockerEntry = makeEntry("my-existing", { cloud: "docker" });
811
966
 
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
- );
967
+ findAssistantByNameMock.mockImplementation((name: string) => {
968
+ if (name === "my-local") return localEntry;
969
+ if (name === "my-existing") return dockerEntry;
970
+ return null;
971
+ });
972
+
973
+ const originalFetch = globalThis.fetch;
974
+ const fetchMock = createFetchMock();
975
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
976
+
977
+ try {
978
+ await teleport();
979
+
980
+ // No hatch should happen — existing target is used
981
+ expect(hatchDockerMock).not.toHaveBeenCalled();
982
+ // Source should still be retired
983
+ expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
984
+ expect(consoleLogSpy).toHaveBeenCalledWith(
985
+ expect.stringContaining("Teleport complete"),
986
+ );
987
+ } finally {
988
+ globalThis.fetch = originalFetch;
989
+ }
818
990
  });
819
991
 
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.
992
+ test("legacy --to flag shows deprecation message", async () => {
993
+ setArgv("--from", "source", "--to", "target");
994
+
824
995
  await expect(teleport()).rejects.toThrow("process.exit:1");
825
- expect(consoleLogSpy).toHaveBeenCalledWith(
826
- expect.stringContaining("Usage:"),
996
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
997
+ expect.stringContaining("--to is deprecated"),
827
998
  );
828
999
  });
829
1000
  });