@vellumai/cli 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +12 -2
  2. package/README.md +3 -3
  3. package/bun.lock +17 -17
  4. package/bunfig.toml +6 -0
  5. package/package.json +18 -18
  6. package/src/__tests__/assistant-config.test.ts +124 -0
  7. package/src/__tests__/env-drift.test.ts +87 -0
  8. package/src/__tests__/guardian-token.test.ts +225 -0
  9. package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
  10. package/src/__tests__/multi-local.test.ts +90 -13
  11. package/src/__tests__/orphan-detection.test.ts +214 -0
  12. package/src/__tests__/platform-client.test.ts +204 -0
  13. package/src/__tests__/preload.ts +27 -0
  14. package/src/__tests__/ssh-user-guard.test.ts +28 -0
  15. package/src/__tests__/teleport.test.ts +1073 -56
  16. package/src/commands/backup.ts +8 -0
  17. package/src/commands/exec.ts +186 -0
  18. package/src/commands/hatch.ts +1 -1
  19. package/src/commands/login.ts +209 -9
  20. package/src/commands/logs.ts +652 -0
  21. package/src/commands/pair.ts +9 -1
  22. package/src/commands/ps.ts +37 -7
  23. package/src/commands/recover.ts +8 -4
  24. package/src/commands/restore.ts +8 -0
  25. package/src/commands/retire.ts +16 -9
  26. package/src/commands/rollback.ts +32 -33
  27. package/src/commands/ssh.ts +7 -0
  28. package/src/commands/teleport.ts +253 -1
  29. package/src/commands/upgrade.ts +43 -52
  30. package/src/commands/wake.ts +25 -10
  31. package/src/components/DefaultMainScreen.tsx +7 -1
  32. package/src/index.ts +6 -0
  33. package/src/lib/__tests__/docker.test.ts +168 -0
  34. package/src/lib/assistant-config.ts +82 -108
  35. package/src/lib/aws.ts +12 -1
  36. package/src/lib/config-utils.ts +4 -4
  37. package/src/lib/constants.ts +0 -10
  38. package/src/lib/docker.ts +158 -8
  39. package/src/lib/environments/__tests__/paths.test.ts +228 -0
  40. package/src/lib/environments/__tests__/resolve.test.ts +226 -0
  41. package/src/lib/environments/__tests__/seeds.test.ts +72 -0
  42. package/src/lib/environments/paths.ts +109 -0
  43. package/src/lib/environments/resolve.ts +96 -0
  44. package/src/lib/environments/seeds.ts +74 -0
  45. package/src/lib/environments/types.ts +60 -0
  46. package/src/lib/exec-apple-container.ts +122 -0
  47. package/src/lib/gcp.ts +12 -1
  48. package/src/lib/guardian-token.ts +71 -10
  49. package/src/lib/hatch-local.ts +44 -23
  50. package/src/lib/local.ts +47 -5
  51. package/src/lib/orphan-detection.ts +28 -12
  52. package/src/lib/platform-client.ts +354 -24
  53. package/src/lib/retire-apple-container.ts +102 -0
  54. package/src/lib/ssh-apple-container.ts +166 -0
  55. package/src/lib/upgrade-lifecycle.ts +101 -28
  56. package/src/shared/provider-env-vars.ts +30 -6
@@ -23,11 +23,16 @@ process.env.VELLUM_LOCKFILE_DIR = testDir;
23
23
  // Mocks — must be set up before importing the module under test
24
24
  // ---------------------------------------------------------------------------
25
25
 
26
- // Import the real assistant-config module — do NOT mock it with mock.module()
27
- // because Bun's mock.module() replaces the module globally and leaks into
28
- // other test files (e.g. multi-local.test.ts) running in the same process.
29
- // Instead, we use spyOn to mock findAssistantByName on the imported module object.
26
+ // Import the real modules — do NOT mock them with mock.module() because
27
+ // Bun's mock.module() replaces modules globally and the replacement leaks
28
+ // into other test files (e.g. platform-client.test.ts, guardian-token.test.ts,
29
+ // multi-local.test.ts) running in the same process with no way to unmock.
30
+ // Instead, we use spyOn() on the imported module namespace objects — these
31
+ // mutate only the current process's live binding and are restored via
32
+ // mockRestore() in afterAll.
30
33
  import * as assistantConfig from "../lib/assistant-config.js";
34
+ import * as guardianToken from "../lib/guardian-token.js";
35
+ import * as platformClient from "../lib/platform-client.js";
31
36
 
32
37
  const findAssistantByNameMock = spyOn(
33
38
  assistantConfig,
@@ -49,40 +54,77 @@ const removeAssistantEntryMock = spyOn(
49
54
  "removeAssistantEntry",
50
55
  ).mockImplementation(() => {});
51
56
 
52
- const loadGuardianTokenMock = mock((_id: string) => ({
57
+ const loadGuardianTokenMock = spyOn(
58
+ guardianToken,
59
+ "loadGuardianToken",
60
+ ).mockReturnValue({
53
61
  accessToken: "local-token",
54
62
  accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
55
- }));
56
- const leaseGuardianTokenMock = mock(async () => ({
63
+ } as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
64
+
65
+ const leaseGuardianTokenMock = spyOn(
66
+ guardianToken,
67
+ "leaseGuardianToken",
68
+ ).mockResolvedValue({
57
69
  accessToken: "leased-token",
58
70
  accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
59
- }));
60
-
61
- mock.module("../lib/guardian-token.js", () => ({
62
- loadGuardianToken: loadGuardianTokenMock,
63
- leaseGuardianToken: leaseGuardianTokenMock,
64
- }));
71
+ } as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
72
+
73
+ const computeDeviceIdMock = spyOn(
74
+ guardianToken,
75
+ "computeDeviceId",
76
+ ).mockReturnValue("device-id-123");
77
+
78
+ const readPlatformTokenMock = spyOn(
79
+ platformClient,
80
+ "readPlatformToken",
81
+ ).mockReturnValue("platform-token");
82
+
83
+ const getPlatformUrlMock = spyOn(
84
+ platformClient,
85
+ "getPlatformUrl",
86
+ ).mockReturnValue("https://platform.vellum.ai");
87
+
88
+ const hatchAssistantMock = spyOn(
89
+ platformClient,
90
+ "hatchAssistant",
91
+ ).mockResolvedValue({
92
+ assistant: {
93
+ id: "platform-new-id",
94
+ name: "platform-new",
95
+ status: "active",
96
+ },
97
+ reusedExisting: false,
98
+ });
65
99
 
66
- const readPlatformTokenMock = mock((): string | null => "platform-token");
67
- const getPlatformUrlMock = mock(() => "https://platform.vellum.ai");
68
- const hatchAssistantMock = mock(async () => ({
69
- id: "platform-new-id",
70
- name: "platform-new",
71
- status: "active",
72
- }));
73
- const platformInitiateExportMock = mock(async () => ({
100
+ const platformInitiateExportMock = spyOn(
101
+ platformClient,
102
+ "platformInitiateExport",
103
+ ).mockResolvedValue({
74
104
  jobId: "job-1",
75
105
  status: "pending",
76
- }));
77
- const platformPollExportStatusMock = mock(async () => ({
106
+ });
107
+
108
+ const platformPollExportStatusMock = spyOn(
109
+ platformClient,
110
+ "platformPollExportStatus",
111
+ ).mockResolvedValue({
78
112
  status: "complete" as string,
79
113
  downloadUrl: "https://cdn.example.com/bundle.tar.gz",
80
- }));
81
- const platformDownloadExportMock = mock(async () => {
114
+ });
115
+
116
+ const platformDownloadExportMock = spyOn(
117
+ platformClient,
118
+ "platformDownloadExport",
119
+ ).mockImplementation(async () => {
82
120
  const data = new Uint8Array([10, 20, 30]);
83
121
  return new Response(data, { status: 200 });
84
122
  });
85
- const platformImportPreflightMock = mock(async () => ({
123
+
124
+ const platformImportPreflightMock = spyOn(
125
+ platformClient,
126
+ "platformImportPreflight",
127
+ ).mockResolvedValue({
86
128
  statusCode: 200,
87
129
  body: {
88
130
  can_import: true,
@@ -93,8 +135,12 @@ const platformImportPreflightMock = mock(async () => ({
93
135
  total_files: 3,
94
136
  },
95
137
  } as Record<string, unknown>,
96
- }));
97
- const platformImportBundleMock = mock(async () => ({
138
+ });
139
+
140
+ const platformImportBundleMock = spyOn(
141
+ platformClient,
142
+ "platformImportBundle",
143
+ ).mockResolvedValue({
98
144
  statusCode: 200,
99
145
  body: {
100
146
  success: true,
@@ -106,14 +152,26 @@ const platformImportBundleMock = mock(async () => ({
106
152
  backups_created: 1,
107
153
  },
108
154
  } as Record<string, unknown>,
109
- }));
110
- const platformRequestUploadUrlMock = mock(async () => ({
155
+ });
156
+
157
+ const platformRequestUploadUrlMock = spyOn(
158
+ platformClient,
159
+ "platformRequestUploadUrl",
160
+ ).mockResolvedValue({
111
161
  uploadUrl: "https://storage.googleapis.com/bucket/signed-upload-url",
112
162
  bundleKey: "bundle-key-123",
113
163
  expiresAt: new Date(Date.now() + 3600_000).toISOString(),
114
- }));
115
- const platformUploadToSignedUrlMock = mock(async () => {});
116
- const platformImportPreflightFromGcsMock = mock(async () => ({
164
+ });
165
+
166
+ const platformUploadToSignedUrlMock = spyOn(
167
+ platformClient,
168
+ "platformUploadToSignedUrl",
169
+ ).mockResolvedValue(undefined);
170
+
171
+ const platformImportPreflightFromGcsMock = spyOn(
172
+ platformClient,
173
+ "platformImportPreflightFromGcs",
174
+ ).mockResolvedValue({
117
175
  statusCode: 200,
118
176
  body: {
119
177
  can_import: true,
@@ -124,8 +182,12 @@ const platformImportPreflightFromGcsMock = mock(async () => ({
124
182
  total_files: 3,
125
183
  },
126
184
  } as Record<string, unknown>,
127
- }));
128
- const platformImportBundleFromGcsMock = mock(async () => ({
185
+ });
186
+
187
+ const platformImportBundleFromGcsMock = spyOn(
188
+ platformClient,
189
+ "platformImportBundleFromGcs",
190
+ ).mockResolvedValue({
129
191
  statusCode: 200,
130
192
  body: {
131
193
  success: true,
@@ -137,22 +199,47 @@ const platformImportBundleFromGcsMock = mock(async () => ({
137
199
  backups_created: 1,
138
200
  },
139
201
  } as Record<string, unknown>,
140
- }));
202
+ });
141
203
 
142
- mock.module("../lib/platform-client.js", () => ({
143
- readPlatformToken: readPlatformTokenMock,
144
- getPlatformUrl: getPlatformUrlMock,
145
- hatchAssistant: hatchAssistantMock,
146
- platformInitiateExport: platformInitiateExportMock,
147
- platformPollExportStatus: platformPollExportStatusMock,
148
- platformDownloadExport: platformDownloadExportMock,
149
- platformImportPreflight: platformImportPreflightMock,
150
- platformImportBundle: platformImportBundleMock,
151
- platformRequestUploadUrl: platformRequestUploadUrlMock,
152
- platformUploadToSignedUrl: platformUploadToSignedUrlMock,
153
- platformImportPreflightFromGcs: platformImportPreflightFromGcsMock,
154
- platformImportBundleFromGcs: platformImportBundleFromGcsMock,
155
- }));
204
+ const checkExistingPlatformAssistantMock = spyOn(
205
+ platformClient,
206
+ "checkExistingPlatformAssistant",
207
+ ).mockResolvedValue(null);
208
+
209
+ const ensureSelfHostedLocalRegistrationMock = spyOn(
210
+ platformClient,
211
+ "ensureSelfHostedLocalRegistration",
212
+ ).mockResolvedValue({
213
+ assistant: { id: "platform-assistant-1", name: "my-assistant" },
214
+ registration: {
215
+ client_installation_id: "device-id-123",
216
+ runtime_assistant_id: "target-local",
217
+ client_platform: "cli",
218
+ },
219
+ assistant_api_key: "api-key-123",
220
+ webhook_secret: "webhook-secret-123",
221
+ } as unknown as Awaited<
222
+ ReturnType<typeof platformClient.ensureSelfHostedLocalRegistration>
223
+ >);
224
+
225
+ const injectCredentialsIntoAssistantMock = spyOn(
226
+ platformClient,
227
+ "injectCredentialsIntoAssistant",
228
+ ).mockResolvedValue(true);
229
+
230
+ const fetchCurrentUserMock = spyOn(
231
+ platformClient,
232
+ "fetchCurrentUser",
233
+ ).mockResolvedValue({
234
+ id: "user-1",
235
+ email: "test@example.com",
236
+ display: "Test",
237
+ } as unknown as Awaited<ReturnType<typeof platformClient.fetchCurrentUser>>);
238
+
239
+ const fetchOrganizationIdMock = spyOn(
240
+ platformClient,
241
+ "fetchOrganizationId",
242
+ ).mockResolvedValue("org-1");
156
243
 
157
244
  const hatchLocalMock = mock(async () => {});
158
245
 
@@ -166,9 +253,14 @@ const retireDockerMock = mock(async () => {});
166
253
  const sleepContainersMock = mock(async () => {});
167
254
  const dockerResourceNamesMock = mock((name: string) => ({
168
255
  assistantContainer: `${name}-assistant`,
256
+ cesContainer: `${name}-credential-executor`,
257
+ cesSecurityVolume: `${name}-ces-sec`,
258
+ dockerdDataVolume: `${name}-dockerd-data`,
169
259
  gatewayContainer: `${name}-gateway`,
170
- cesContainer: `${name}-ces`,
260
+ gatewaySecurityVolume: `${name}-gateway-sec`,
171
261
  network: `${name}-net`,
262
+ socketVolume: `${name}-socket`,
263
+ workspaceVolume: `${name}-workspace`,
172
264
  }));
173
265
 
174
266
  mock.module("../lib/docker.js", () => ({
@@ -190,6 +282,14 @@ mock.module("../lib/retire-local.js", () => ({
190
282
  retireLocal: retireLocalMock,
191
283
  }));
192
284
 
285
+ const fetchCurrentVersionMock = mock(
286
+ async (_runtimeUrl: string): Promise<string | undefined> => undefined,
287
+ );
288
+
289
+ mock.module("../lib/upgrade-lifecycle.js", () => ({
290
+ fetchCurrentVersion: fetchCurrentVersionMock,
291
+ }));
292
+
193
293
  import {
194
294
  teleport,
195
295
  parseArgs,
@@ -206,6 +306,26 @@ afterAll(() => {
206
306
  saveAssistantEntryMock.mockRestore();
207
307
  loadAllAssistantsMock.mockRestore();
208
308
  removeAssistantEntryMock.mockRestore();
309
+ loadGuardianTokenMock.mockRestore();
310
+ leaseGuardianTokenMock.mockRestore();
311
+ readPlatformTokenMock.mockRestore();
312
+ getPlatformUrlMock.mockRestore();
313
+ hatchAssistantMock.mockRestore();
314
+ checkExistingPlatformAssistantMock.mockRestore();
315
+ platformInitiateExportMock.mockRestore();
316
+ platformPollExportStatusMock.mockRestore();
317
+ platformDownloadExportMock.mockRestore();
318
+ platformImportPreflightMock.mockRestore();
319
+ platformImportBundleMock.mockRestore();
320
+ platformRequestUploadUrlMock.mockRestore();
321
+ platformUploadToSignedUrlMock.mockRestore();
322
+ platformImportPreflightFromGcsMock.mockRestore();
323
+ platformImportBundleFromGcsMock.mockRestore();
324
+ ensureSelfHostedLocalRegistrationMock.mockRestore();
325
+ injectCredentialsIntoAssistantMock.mockRestore();
326
+ fetchCurrentUserMock.mockRestore();
327
+ fetchOrganizationIdMock.mockRestore();
328
+ computeDeviceIdMock.mockRestore();
209
329
  rmSync(testDir, { recursive: true, force: true });
210
330
  delete process.env.VELLUM_LOCKFILE_DIR;
211
331
  });
@@ -233,7 +353,7 @@ beforeEach(() => {
233
353
  loadGuardianTokenMock.mockReturnValue({
234
354
  accessToken: "local-token",
235
355
  accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
236
- });
356
+ } as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
237
357
  leaseGuardianTokenMock.mockReset();
238
358
 
239
359
  readPlatformTokenMock.mockReset();
@@ -242,9 +362,12 @@ beforeEach(() => {
242
362
  getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
243
363
  hatchAssistantMock.mockReset();
244
364
  hatchAssistantMock.mockResolvedValue({
245
- id: "platform-new-id",
246
- name: "platform-new",
247
- status: "active",
365
+ assistant: {
366
+ id: "platform-new-id",
367
+ name: "platform-new",
368
+ status: "active",
369
+ },
370
+ reusedExisting: false,
248
371
  });
249
372
  platformInitiateExportMock.mockReset();
250
373
  platformInitiateExportMock.mockResolvedValue({
@@ -322,6 +445,31 @@ beforeEach(() => {
322
445
  },
323
446
  },
324
447
  });
448
+ checkExistingPlatformAssistantMock.mockReset();
449
+ checkExistingPlatformAssistantMock.mockResolvedValue(null);
450
+ ensureSelfHostedLocalRegistrationMock.mockReset();
451
+ ensureSelfHostedLocalRegistrationMock.mockResolvedValue({
452
+ assistant: { id: "platform-assistant-1", name: "my-assistant" },
453
+ registration: {
454
+ client_installation_id: "device-id-123",
455
+ runtime_assistant_id: "target-local",
456
+ client_platform: "cli",
457
+ },
458
+ assistant_api_key: "api-key-123",
459
+ webhook_secret: "webhook-secret-123",
460
+ });
461
+ injectCredentialsIntoAssistantMock.mockReset();
462
+ injectCredentialsIntoAssistantMock.mockResolvedValue(true);
463
+ fetchCurrentUserMock.mockReset();
464
+ fetchCurrentUserMock.mockResolvedValue({
465
+ id: "user-1",
466
+ email: "test@example.com",
467
+ display: "Test",
468
+ });
469
+ fetchOrganizationIdMock.mockReset();
470
+ fetchOrganizationIdMock.mockResolvedValue("org-1");
471
+ computeDeviceIdMock.mockReset();
472
+ computeDeviceIdMock.mockReturnValue("device-id-123");
325
473
 
326
474
  hatchLocalMock.mockReset();
327
475
  hatchLocalMock.mockResolvedValue(undefined);
@@ -331,6 +479,8 @@ beforeEach(() => {
331
479
  retireDockerMock.mockResolvedValue(undefined);
332
480
  retireLocalMock.mockReset();
333
481
  retireLocalMock.mockResolvedValue(undefined);
482
+ fetchCurrentVersionMock.mockReset();
483
+ fetchCurrentVersionMock.mockResolvedValue(undefined);
334
484
  sleepContainersMock.mockReset();
335
485
  sleepContainersMock.mockResolvedValue(undefined);
336
486
  stopProcessByPidFileMock.mockReset();
@@ -762,6 +912,38 @@ describe("resolveOrHatchTarget", () => {
762
912
  expect(result.assistantId).toBe("platform-new-id");
763
913
  });
764
914
 
915
+ test("platform with no name -> blocks when hatch returns reusedExisting (defensive safety net)", async () => {
916
+ findAssistantByNameMock.mockReturnValue(null);
917
+ hatchAssistantMock.mockResolvedValue({
918
+ assistant: {
919
+ id: "existing-platform-id",
920
+ name: "existing-platform",
921
+ status: "active",
922
+ },
923
+ reusedExisting: true,
924
+ });
925
+
926
+ // Defensive safety net: even though the pre-check in teleport() should
927
+ // catch this first, resolveOrHatchTarget still blocks on reusedExisting
928
+ // to guard against a TOCTOU race (matching the Swift client behavior).
929
+ await expect(resolveOrHatchTarget("platform", undefined)).rejects.toThrow(
930
+ "process.exit:1",
931
+ );
932
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
933
+ expect.stringContaining("already have a platform assistant"),
934
+ );
935
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
936
+ expect.stringContaining("Retire it first"),
937
+ );
938
+ // The existing assistant is saved to the lockfile so `vellum retire` can find it
939
+ expect(saveAssistantEntryMock).toHaveBeenCalledWith(
940
+ expect.objectContaining({
941
+ assistantId: "existing-platform-id",
942
+ cloud: "vellum",
943
+ }),
944
+ );
945
+ });
946
+
765
947
  test("existing assistant with wrong cloud -> rejects", async () => {
766
948
  const localEntry = makeEntry("my-local", { cloud: "local" });
767
949
  findAssistantByNameMock.mockImplementation((name: string) => {
@@ -1324,7 +1506,14 @@ describe("platform teleport org ID and reordered flow", () => {
1324
1506
 
1325
1507
  hatchAssistantMock.mockImplementation(async () => {
1326
1508
  callOrder.push("hatchAssistant");
1327
- return { id: "platform-new-id", name: "platform-new", status: "active" };
1509
+ return {
1510
+ assistant: {
1511
+ id: "platform-new-id",
1512
+ name: "platform-new",
1513
+ status: "active",
1514
+ },
1515
+ reusedExisting: false,
1516
+ };
1328
1517
  });
1329
1518
 
1330
1519
  const originalFetch = globalThis.fetch;
@@ -1423,3 +1612,831 @@ describe("platform teleport org ID and reordered flow", () => {
1423
1612
  }
1424
1613
  });
1425
1614
  });
1615
+
1616
+ // ---------------------------------------------------------------------------
1617
+ // Pre-check: block teleport to platform when existing assistant detected
1618
+ // ---------------------------------------------------------------------------
1619
+
1620
+ describe("pre-check: block teleport to platform when existing assistant detected", () => {
1621
+ test("blocks BEFORE GCS upload when pre-check finds existing assistant", async () => {
1622
+ setArgv("--from", "my-local", "--platform");
1623
+
1624
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1625
+
1626
+ findAssistantByNameMock.mockImplementation((name: string) => {
1627
+ if (name === "my-local") return localEntry;
1628
+ return null;
1629
+ });
1630
+
1631
+ // Pre-check returns an existing assistant
1632
+ checkExistingPlatformAssistantMock.mockResolvedValue({
1633
+ id: "existing-platform-id",
1634
+ name: "existing-platform",
1635
+ status: "active",
1636
+ });
1637
+
1638
+ const originalFetch = globalThis.fetch;
1639
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1640
+
1641
+ try {
1642
+ await expect(teleport()).rejects.toThrow("process.exit:1");
1643
+
1644
+ // Pre-check should have been called
1645
+ expect(checkExistingPlatformAssistantMock).toHaveBeenCalledWith(
1646
+ "platform-token",
1647
+ undefined,
1648
+ );
1649
+
1650
+ // GCS upload should NOT have been attempted
1651
+ expect(platformRequestUploadUrlMock).not.toHaveBeenCalled();
1652
+ expect(platformUploadToSignedUrlMock).not.toHaveBeenCalled();
1653
+
1654
+ // Hatch should NOT have been called
1655
+ expect(hatchAssistantMock).not.toHaveBeenCalled();
1656
+
1657
+ // Error message should be actionable
1658
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1659
+ expect.stringContaining("already have a platform assistant"),
1660
+ );
1661
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1662
+ expect.stringContaining("Retire it first"),
1663
+ );
1664
+
1665
+ // The existing assistant is saved to the lockfile
1666
+ expect(saveAssistantEntryMock).toHaveBeenCalledWith(
1667
+ expect.objectContaining({
1668
+ assistantId: "existing-platform-id",
1669
+ cloud: "vellum",
1670
+ }),
1671
+ );
1672
+ } finally {
1673
+ globalThis.fetch = originalFetch;
1674
+ }
1675
+ });
1676
+
1677
+ test("skips pre-check when targeting an existing named assistant", async () => {
1678
+ setArgv("--from", "my-local", "--platform", "existing-platform");
1679
+
1680
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1681
+ const platformEntry = makeEntry("existing-platform", {
1682
+ cloud: "vellum",
1683
+ runtimeUrl: "https://platform.vellum.ai",
1684
+ });
1685
+
1686
+ findAssistantByNameMock.mockImplementation((name: string) => {
1687
+ if (name === "my-local") return localEntry;
1688
+ if (name === "existing-platform") return platformEntry;
1689
+ return null;
1690
+ });
1691
+
1692
+ const originalFetch = globalThis.fetch;
1693
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1694
+
1695
+ try {
1696
+ await teleport();
1697
+
1698
+ // Pre-check should NOT be called when targeting a known named assistant
1699
+ expect(checkExistingPlatformAssistantMock).not.toHaveBeenCalled();
1700
+
1701
+ // Upload should proceed normally
1702
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1703
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1704
+ expect.stringContaining("Teleport complete"),
1705
+ );
1706
+ } finally {
1707
+ globalThis.fetch = originalFetch;
1708
+ }
1709
+ });
1710
+
1711
+ test("pre-check failure is non-fatal — teleport proceeds", async () => {
1712
+ setArgv("--from", "my-local", "--platform");
1713
+
1714
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1715
+
1716
+ findAssistantByNameMock.mockImplementation((name: string) => {
1717
+ if (name === "my-local") return localEntry;
1718
+ return null;
1719
+ });
1720
+
1721
+ // Pre-check returns null (no existing assistant or API failed gracefully)
1722
+ checkExistingPlatformAssistantMock.mockResolvedValue(null);
1723
+
1724
+ const originalFetch = globalThis.fetch;
1725
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1726
+
1727
+ try {
1728
+ await teleport();
1729
+
1730
+ // Pre-check was called
1731
+ expect(checkExistingPlatformAssistantMock).toHaveBeenCalled();
1732
+
1733
+ // Upload and hatch should proceed normally
1734
+ expect(platformRequestUploadUrlMock).toHaveBeenCalled();
1735
+ expect(hatchAssistantMock).toHaveBeenCalled();
1736
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1737
+ expect.stringContaining("Teleport complete"),
1738
+ );
1739
+ } finally {
1740
+ globalThis.fetch = originalFetch;
1741
+ }
1742
+ });
1743
+ });
1744
+
1745
+ // ---------------------------------------------------------------------------
1746
+ // Version guard: block platform→non-platform when target is behind
1747
+ // ---------------------------------------------------------------------------
1748
+
1749
+ describe("version guard: block platform→non-platform when target is behind", () => {
1750
+ test("blocks platform→local when local version is behind platform", async () => {
1751
+ setArgv("--from", "my-platform", "--local", "my-local");
1752
+
1753
+ const platformEntry = makeEntry("my-platform", {
1754
+ cloud: "vellum",
1755
+ runtimeUrl: "https://platform.vellum.ai",
1756
+ });
1757
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1758
+
1759
+ findAssistantByNameMock.mockImplementation((name: string) => {
1760
+ if (name === "my-platform") return platformEntry;
1761
+ if (name === "my-local") return localEntry;
1762
+ return null;
1763
+ });
1764
+
1765
+ // Source (platform) is on 0.7.0, target (local) is on 0.6.0
1766
+ fetchCurrentVersionMock.mockImplementation((url: string) => {
1767
+ if (url === "https://platform.vellum.ai") return Promise.resolve("0.7.0");
1768
+ return Promise.resolve("0.6.0");
1769
+ });
1770
+
1771
+ const originalFetch = globalThis.fetch;
1772
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1773
+
1774
+ try {
1775
+ await expect(teleport()).rejects.toThrow("process.exit:1");
1776
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1777
+ expect.stringContaining("is running 0.6.0"),
1778
+ );
1779
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1780
+ expect.stringContaining("Upgrade your local assistant first"),
1781
+ );
1782
+ } finally {
1783
+ globalThis.fetch = originalFetch;
1784
+ }
1785
+ });
1786
+
1787
+ test("allows platform→local when versions are equal", async () => {
1788
+ setArgv("--from", "my-platform", "--local", "my-local");
1789
+
1790
+ const platformEntry = makeEntry("my-platform", {
1791
+ cloud: "vellum",
1792
+ runtimeUrl: "https://platform.vellum.ai",
1793
+ });
1794
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1795
+
1796
+ findAssistantByNameMock.mockImplementation((name: string) => {
1797
+ if (name === "my-platform") return platformEntry;
1798
+ if (name === "my-local") return localEntry;
1799
+ return null;
1800
+ });
1801
+
1802
+ // Both on 0.7.0
1803
+ fetchCurrentVersionMock.mockResolvedValue("0.7.0");
1804
+
1805
+ const originalFetch = globalThis.fetch;
1806
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1807
+
1808
+ try {
1809
+ await teleport();
1810
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1811
+ expect.stringContaining("Teleport complete"),
1812
+ );
1813
+ } finally {
1814
+ globalThis.fetch = originalFetch;
1815
+ }
1816
+ });
1817
+
1818
+ test("allows platform→local when local is ahead of platform", async () => {
1819
+ setArgv("--from", "my-platform", "--local", "my-local");
1820
+
1821
+ const platformEntry = makeEntry("my-platform", {
1822
+ cloud: "vellum",
1823
+ runtimeUrl: "https://platform.vellum.ai",
1824
+ });
1825
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1826
+
1827
+ findAssistantByNameMock.mockImplementation((name: string) => {
1828
+ if (name === "my-platform") return platformEntry;
1829
+ if (name === "my-local") return localEntry;
1830
+ return null;
1831
+ });
1832
+
1833
+ // Source (platform) is on 0.7.0, target (local) is on 0.8.0
1834
+ fetchCurrentVersionMock.mockImplementation((url: string) => {
1835
+ if (url === "https://platform.vellum.ai") return Promise.resolve("0.7.0");
1836
+ return Promise.resolve("0.8.0");
1837
+ });
1838
+
1839
+ const originalFetch = globalThis.fetch;
1840
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1841
+
1842
+ try {
1843
+ await teleport();
1844
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1845
+ expect.stringContaining("Teleport complete"),
1846
+ );
1847
+ } finally {
1848
+ globalThis.fetch = originalFetch;
1849
+ }
1850
+ });
1851
+
1852
+ test("allows teleport when source version cannot be fetched (best-effort)", async () => {
1853
+ setArgv("--from", "my-platform", "--local", "my-local");
1854
+
1855
+ const platformEntry = makeEntry("my-platform", {
1856
+ cloud: "vellum",
1857
+ runtimeUrl: "https://platform.vellum.ai",
1858
+ });
1859
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1860
+
1861
+ findAssistantByNameMock.mockImplementation((name: string) => {
1862
+ if (name === "my-platform") return platformEntry;
1863
+ if (name === "my-local") return localEntry;
1864
+ return null;
1865
+ });
1866
+
1867
+ // Source (platform) is unreachable, target (local) is on 0.6.0
1868
+ fetchCurrentVersionMock.mockImplementation((url: string) => {
1869
+ if (url === "https://platform.vellum.ai")
1870
+ return Promise.resolve(undefined);
1871
+ return Promise.resolve("0.6.0");
1872
+ });
1873
+
1874
+ const originalFetch = globalThis.fetch;
1875
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1876
+
1877
+ try {
1878
+ await teleport();
1879
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1880
+ expect.stringContaining("Teleport complete"),
1881
+ );
1882
+ } finally {
1883
+ globalThis.fetch = originalFetch;
1884
+ }
1885
+ });
1886
+
1887
+ test("allows teleport when target version cannot be fetched (best-effort)", async () => {
1888
+ setArgv("--from", "my-platform", "--local", "my-local");
1889
+
1890
+ const platformEntry = makeEntry("my-platform", {
1891
+ cloud: "vellum",
1892
+ runtimeUrl: "https://platform.vellum.ai",
1893
+ });
1894
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1895
+
1896
+ findAssistantByNameMock.mockImplementation((name: string) => {
1897
+ if (name === "my-platform") return platformEntry;
1898
+ if (name === "my-local") return localEntry;
1899
+ return null;
1900
+ });
1901
+
1902
+ // Source (platform) is on 0.7.0, target (local) is unreachable
1903
+ fetchCurrentVersionMock.mockImplementation((url: string) => {
1904
+ if (url === "https://platform.vellum.ai") return Promise.resolve("0.7.0");
1905
+ return Promise.resolve(undefined);
1906
+ });
1907
+
1908
+ const originalFetch = globalThis.fetch;
1909
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1910
+
1911
+ try {
1912
+ await teleport();
1913
+ expect(consoleLogSpy).toHaveBeenCalledWith(
1914
+ expect.stringContaining("Teleport complete"),
1915
+ );
1916
+ } finally {
1917
+ globalThis.fetch = originalFetch;
1918
+ }
1919
+ });
1920
+
1921
+ test("pre-release target is behind release source: 0.7.0-local.xxx < 0.7.0", async () => {
1922
+ setArgv("--from", "my-platform", "--local", "my-local");
1923
+
1924
+ const platformEntry = makeEntry("my-platform", {
1925
+ cloud: "vellum",
1926
+ runtimeUrl: "https://platform.vellum.ai",
1927
+ });
1928
+ const localEntry = makeEntry("my-local", { cloud: "local" });
1929
+
1930
+ findAssistantByNameMock.mockImplementation((name: string) => {
1931
+ if (name === "my-platform") return platformEntry;
1932
+ if (name === "my-local") return localEntry;
1933
+ return null;
1934
+ });
1935
+
1936
+ // Per semver, pre-release < release for same core version
1937
+ fetchCurrentVersionMock.mockImplementation((url: string) => {
1938
+ if (url === "https://platform.vellum.ai") return Promise.resolve("0.7.0");
1939
+ return Promise.resolve("0.7.0-local.20260411.abc123");
1940
+ });
1941
+
1942
+ const originalFetch = globalThis.fetch;
1943
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1944
+
1945
+ try {
1946
+ await expect(teleport()).rejects.toThrow("process.exit:1");
1947
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1948
+ expect.stringContaining("is running 0.7.0-local.20260411.abc123"),
1949
+ );
1950
+ } finally {
1951
+ globalThis.fetch = originalFetch;
1952
+ }
1953
+ });
1954
+
1955
+ test("blocks platform→docker when docker version is behind platform", async () => {
1956
+ setArgv("--from", "my-platform", "--docker", "my-docker");
1957
+
1958
+ const platformEntry = makeEntry("my-platform", {
1959
+ cloud: "vellum",
1960
+ runtimeUrl: "https://platform.vellum.ai",
1961
+ });
1962
+ const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
1963
+
1964
+ findAssistantByNameMock.mockImplementation((name: string) => {
1965
+ if (name === "my-platform") return platformEntry;
1966
+ if (name === "my-docker") return dockerEntry;
1967
+ return null;
1968
+ });
1969
+
1970
+ // Source (platform) is on 0.7.0, target (docker) is on 0.5.0
1971
+ fetchCurrentVersionMock.mockImplementation((url: string) => {
1972
+ if (url === "https://platform.vellum.ai") return Promise.resolve("0.7.0");
1973
+ return Promise.resolve("0.5.0");
1974
+ });
1975
+
1976
+ const originalFetch = globalThis.fetch;
1977
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
1978
+
1979
+ try {
1980
+ await expect(teleport()).rejects.toThrow("process.exit:1");
1981
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1982
+ expect.stringContaining("is running 0.5.0"),
1983
+ );
1984
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1985
+ expect.stringContaining("Upgrade your docker assistant first"),
1986
+ );
1987
+ } finally {
1988
+ globalThis.fetch = originalFetch;
1989
+ }
1990
+ });
1991
+
1992
+ test("dry-run: blocks platform→local when local version is behind", async () => {
1993
+ setArgv("--from", "my-platform", "--local", "my-local", "--dry-run");
1994
+
1995
+ const platformEntry = makeEntry("my-platform", {
1996
+ cloud: "vellum",
1997
+ runtimeUrl: "https://platform.vellum.ai",
1998
+ });
1999
+ const localEntry = makeEntry("my-local", { cloud: "local" });
2000
+
2001
+ findAssistantByNameMock.mockImplementation((name: string) => {
2002
+ if (name === "my-platform") return platformEntry;
2003
+ if (name === "my-local") return localEntry;
2004
+ return null;
2005
+ });
2006
+
2007
+ // Source (platform) is on 0.7.0, target (local) is on 0.6.0
2008
+ fetchCurrentVersionMock.mockImplementation((url: string) => {
2009
+ if (url === "https://platform.vellum.ai") return Promise.resolve("0.7.0");
2010
+ return Promise.resolve("0.6.0");
2011
+ });
2012
+
2013
+ const originalFetch = globalThis.fetch;
2014
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
2015
+
2016
+ try {
2017
+ await expect(teleport()).rejects.toThrow("process.exit:1");
2018
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
2019
+ expect.stringContaining("is running 0.6.0"),
2020
+ );
2021
+ } finally {
2022
+ globalThis.fetch = originalFetch;
2023
+ }
2024
+ });
2025
+
2026
+ test("newly hatched target is cleaned up when version check fails", async () => {
2027
+ // No existing local target — teleport will hatch a new one, then
2028
+ // the version guard should retire it to avoid orphans.
2029
+ setArgv("--from", "my-platform", "--local");
2030
+
2031
+ const platformEntry = makeEntry("my-platform", {
2032
+ cloud: "vellum",
2033
+ runtimeUrl: "https://platform.vellum.ai",
2034
+ });
2035
+ const newLocalEntry = makeEntry("new-local", { cloud: "local" });
2036
+
2037
+ findAssistantByNameMock.mockImplementation((name: string) => {
2038
+ if (name === "my-platform") return platformEntry;
2039
+ return null;
2040
+ });
2041
+
2042
+ // Simulate hatch creating a new local entry
2043
+ loadAllAssistantsMock.mockImplementation(() => {
2044
+ if (hatchLocalMock.mock.calls.length > 0) {
2045
+ return [platformEntry, newLocalEntry];
2046
+ }
2047
+ return [platformEntry];
2048
+ });
2049
+
2050
+ // Source (platform) is on 0.7.0, newly hatched local is on 0.6.0
2051
+ fetchCurrentVersionMock.mockImplementation((url: string) => {
2052
+ if (url === "https://platform.vellum.ai") return Promise.resolve("0.7.0");
2053
+ return Promise.resolve("0.6.0");
2054
+ });
2055
+
2056
+ const originalFetch = globalThis.fetch;
2057
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
2058
+
2059
+ try {
2060
+ await expect(teleport()).rejects.toThrow("process.exit:1");
2061
+ // Should have hatched a new local assistant
2062
+ expect(hatchLocalMock).toHaveBeenCalled();
2063
+ // Should retire the orphaned assistant
2064
+ expect(retireLocalMock).toHaveBeenCalledWith("new-local", newLocalEntry);
2065
+ expect(removeAssistantEntryMock).toHaveBeenCalledWith("new-local");
2066
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
2067
+ expect.stringContaining("Cleaning up newly hatched assistant"),
2068
+ );
2069
+ } finally {
2070
+ globalThis.fetch = originalFetch;
2071
+ }
2072
+ });
2073
+
2074
+ test("does not check versions for local→platform direction", async () => {
2075
+ setArgv("--from", "my-local", "--platform");
2076
+
2077
+ const localEntry = makeEntry("my-local", { cloud: "local" });
2078
+
2079
+ findAssistantByNameMock.mockImplementation((name: string) => {
2080
+ if (name === "my-local") return localEntry;
2081
+ return null;
2082
+ });
2083
+
2084
+ const originalFetch = globalThis.fetch;
2085
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
2086
+
2087
+ try {
2088
+ await teleport();
2089
+ // fetchCurrentVersion should NOT be called for local→platform
2090
+ expect(fetchCurrentVersionMock).not.toHaveBeenCalled();
2091
+ expect(consoleLogSpy).toHaveBeenCalledWith(
2092
+ expect.stringContaining("Teleport complete"),
2093
+ );
2094
+ } finally {
2095
+ globalThis.fetch = originalFetch;
2096
+ }
2097
+ });
2098
+ });
2099
+
2100
+ // ---------------------------------------------------------------------------
2101
+ // Credential import display tests
2102
+ // ---------------------------------------------------------------------------
2103
+
2104
+ describe("credential import display", () => {
2105
+ test("prints credential counts when credentialsImported is present", async () => {
2106
+ setArgv("--from", "my-local", "--platform");
2107
+
2108
+ const localEntry = makeEntry("my-local", { cloud: "local" });
2109
+
2110
+ findAssistantByNameMock.mockImplementation((name: string) => {
2111
+ if (name === "my-local") return localEntry;
2112
+ return null;
2113
+ });
2114
+
2115
+ platformImportBundleFromGcsMock.mockResolvedValue({
2116
+ statusCode: 200,
2117
+ body: {
2118
+ success: true,
2119
+ summary: {
2120
+ total_files: 3,
2121
+ files_created: 2,
2122
+ files_overwritten: 1,
2123
+ files_skipped: 0,
2124
+ backups_created: 1,
2125
+ },
2126
+ credentialsImported: {
2127
+ total: 5,
2128
+ succeeded: 5,
2129
+ failed: 0,
2130
+ failedAccounts: [],
2131
+ },
2132
+ },
2133
+ });
2134
+
2135
+ const originalFetch = globalThis.fetch;
2136
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
2137
+
2138
+ try {
2139
+ await teleport();
2140
+ expect(consoleLogSpy).toHaveBeenCalledWith(" Credentials imported: 5/5");
2141
+ } finally {
2142
+ globalThis.fetch = originalFetch;
2143
+ }
2144
+ });
2145
+
2146
+ test("does not print credential line when credentialsImported is absent (old server)", async () => {
2147
+ setArgv("--from", "my-local", "--platform");
2148
+
2149
+ const localEntry = makeEntry("my-local", { cloud: "local" });
2150
+
2151
+ findAssistantByNameMock.mockImplementation((name: string) => {
2152
+ if (name === "my-local") return localEntry;
2153
+ return null;
2154
+ });
2155
+
2156
+ // Mock the GCS import path (used by default since platformRequestUploadUrl
2157
+ // succeeds) with a response that has no credentialsImported field, simulating
2158
+ // an older server.
2159
+ platformImportBundleFromGcsMock.mockResolvedValue({
2160
+ statusCode: 200,
2161
+ body: {
2162
+ success: true,
2163
+ summary: {
2164
+ total_files: 3,
2165
+ files_created: 2,
2166
+ files_overwritten: 1,
2167
+ files_skipped: 0,
2168
+ backups_created: 1,
2169
+ },
2170
+ },
2171
+ });
2172
+
2173
+ const originalFetch = globalThis.fetch;
2174
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
2175
+
2176
+ try {
2177
+ await teleport();
2178
+ const allLogCalls = consoleLogSpy.mock.calls.map((c: unknown[]) => c[0]);
2179
+ const credentialLines = allLogCalls.filter(
2180
+ (msg: string) =>
2181
+ typeof msg === "string" && msg.includes("Credentials imported"),
2182
+ );
2183
+ expect(credentialLines).toHaveLength(0);
2184
+ } finally {
2185
+ globalThis.fetch = originalFetch;
2186
+ }
2187
+ });
2188
+
2189
+ test("lists failed credential accounts individually", async () => {
2190
+ setArgv("--from", "my-local", "--platform");
2191
+
2192
+ const localEntry = makeEntry("my-local", { cloud: "local" });
2193
+
2194
+ findAssistantByNameMock.mockImplementation((name: string) => {
2195
+ if (name === "my-local") return localEntry;
2196
+ return null;
2197
+ });
2198
+
2199
+ platformImportBundleFromGcsMock.mockResolvedValue({
2200
+ statusCode: 200,
2201
+ body: {
2202
+ success: true,
2203
+ summary: {
2204
+ total_files: 3,
2205
+ files_created: 2,
2206
+ files_overwritten: 1,
2207
+ files_skipped: 0,
2208
+ backups_created: 1,
2209
+ },
2210
+ credentialsImported: {
2211
+ total: 5,
2212
+ succeeded: 3,
2213
+ failed: 2,
2214
+ failedAccounts: ["google:user@example.com", "github:octocat"],
2215
+ },
2216
+ },
2217
+ });
2218
+
2219
+ const originalFetch = globalThis.fetch;
2220
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
2221
+
2222
+ try {
2223
+ await teleport();
2224
+ expect(consoleLogSpy).toHaveBeenCalledWith(" Credentials imported: 3/5");
2225
+ expect(consoleLogSpy).toHaveBeenCalledWith(" Credentials failed: 2");
2226
+ expect(consoleLogSpy).toHaveBeenCalledWith(
2227
+ " - google:user@example.com",
2228
+ );
2229
+ expect(consoleLogSpy).toHaveBeenCalledWith(" - github:octocat");
2230
+ } finally {
2231
+ globalThis.fetch = originalFetch;
2232
+ }
2233
+ });
2234
+
2235
+ test("shows platform credentials skipped count", async () => {
2236
+ setArgv("--from", "my-local", "--platform");
2237
+
2238
+ const localEntry = makeEntry("my-local", { cloud: "local" });
2239
+
2240
+ findAssistantByNameMock.mockImplementation((name: string) => {
2241
+ if (name === "my-local") return localEntry;
2242
+ return null;
2243
+ });
2244
+
2245
+ platformImportBundleFromGcsMock.mockResolvedValue({
2246
+ statusCode: 200,
2247
+ body: {
2248
+ success: true,
2249
+ summary: {
2250
+ total_files: 3,
2251
+ files_created: 2,
2252
+ files_overwritten: 1,
2253
+ files_skipped: 0,
2254
+ backups_created: 1,
2255
+ },
2256
+ credentialsImported: {
2257
+ total: 5,
2258
+ succeeded: 5,
2259
+ failed: 0,
2260
+ failedAccounts: [],
2261
+ skippedPlatform: 3,
2262
+ },
2263
+ },
2264
+ });
2265
+
2266
+ const originalFetch = globalThis.fetch;
2267
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
2268
+
2269
+ try {
2270
+ await teleport();
2271
+ expect(consoleLogSpy).toHaveBeenCalledWith(" Credentials imported: 5/5");
2272
+ expect(consoleLogSpy).toHaveBeenCalledWith(
2273
+ " Platform credentials skipped: 3",
2274
+ );
2275
+ } finally {
2276
+ globalThis.fetch = originalFetch;
2277
+ }
2278
+ });
2279
+ });
2280
+
2281
+ // ---------------------------------------------------------------------------
2282
+ // Platform credential injection after teleport
2283
+ // ---------------------------------------------------------------------------
2284
+
2285
+ describe("teleport platform credential injection", () => {
2286
+ test("platform→local teleport calls ensureSelfHostedLocalRegistration and injectCredentialsIntoAssistant", async () => {
2287
+ setArgv("--from", "my-platform", "--local", "my-local");
2288
+
2289
+ const platformEntry = makeEntry("my-platform", {
2290
+ cloud: "vellum",
2291
+ runtimeUrl: "https://platform.vellum.ai",
2292
+ });
2293
+ const localEntry = makeEntry("my-local", {
2294
+ cloud: "local",
2295
+ bearerToken: "local-bearer",
2296
+ });
2297
+
2298
+ findAssistantByNameMock.mockImplementation((name: string) => {
2299
+ if (name === "my-platform") return platformEntry;
2300
+ if (name === "my-local") return localEntry;
2301
+ return null;
2302
+ });
2303
+
2304
+ const originalFetch = globalThis.fetch;
2305
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
2306
+
2307
+ try {
2308
+ await teleport();
2309
+ expect(ensureSelfHostedLocalRegistrationMock).toHaveBeenCalledWith(
2310
+ "platform-token",
2311
+ "org-1",
2312
+ "device-id-123",
2313
+ "my-local",
2314
+ "cli",
2315
+ );
2316
+ expect(injectCredentialsIntoAssistantMock).toHaveBeenCalledWith({
2317
+ gatewayUrl: "http://localhost:7821",
2318
+ bearerToken: "local-bearer",
2319
+ assistantApiKey: "api-key-123",
2320
+ platformAssistantId: "platform-assistant-1",
2321
+ platformBaseUrl: "https://platform.vellum.ai",
2322
+ organizationId: "org-1",
2323
+ userId: "user-1",
2324
+ webhookSecret: "webhook-secret-123",
2325
+ });
2326
+ expect(consoleLogSpy).toHaveBeenCalledWith(
2327
+ " Platform credentials injected.",
2328
+ );
2329
+ } finally {
2330
+ globalThis.fetch = originalFetch;
2331
+ }
2332
+ });
2333
+
2334
+ test("platform→docker teleport also calls credential injection", async () => {
2335
+ setArgv("--from", "my-platform", "--docker", "my-docker");
2336
+
2337
+ const platformEntry = makeEntry("my-platform", {
2338
+ cloud: "vellum",
2339
+ runtimeUrl: "https://platform.vellum.ai",
2340
+ });
2341
+ const dockerEntry = makeEntry("my-docker", {
2342
+ cloud: "docker",
2343
+ runtimeUrl: "http://localhost:8821",
2344
+ bearerToken: "docker-bearer",
2345
+ });
2346
+
2347
+ findAssistantByNameMock.mockImplementation((name: string) => {
2348
+ if (name === "my-platform") return platformEntry;
2349
+ if (name === "my-docker") return dockerEntry;
2350
+ return null;
2351
+ });
2352
+
2353
+ const originalFetch = globalThis.fetch;
2354
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
2355
+
2356
+ try {
2357
+ await teleport();
2358
+ expect(ensureSelfHostedLocalRegistrationMock).toHaveBeenCalled();
2359
+ expect(injectCredentialsIntoAssistantMock).toHaveBeenCalled();
2360
+ expect(consoleLogSpy).toHaveBeenCalledWith(
2361
+ " Platform credentials injected.",
2362
+ );
2363
+ } finally {
2364
+ globalThis.fetch = originalFetch;
2365
+ }
2366
+ });
2367
+
2368
+ test("local→local teleport does NOT call credential injection", async () => {
2369
+ setArgv("--from", "my-local", "--docker", "my-docker");
2370
+
2371
+ const localEntry = makeEntry("my-local", { cloud: "local" });
2372
+ const dockerEntry = makeEntry("my-docker", {
2373
+ cloud: "docker",
2374
+ runtimeUrl: "http://localhost:8821",
2375
+ });
2376
+
2377
+ findAssistantByNameMock.mockImplementation((name: string) => {
2378
+ if (name === "my-local") return localEntry;
2379
+ if (name === "my-docker") return dockerEntry;
2380
+ return null;
2381
+ });
2382
+
2383
+ const originalFetch = globalThis.fetch;
2384
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
2385
+
2386
+ try {
2387
+ await teleport();
2388
+ expect(ensureSelfHostedLocalRegistrationMock).not.toHaveBeenCalled();
2389
+ expect(injectCredentialsIntoAssistantMock).not.toHaveBeenCalled();
2390
+ } finally {
2391
+ globalThis.fetch = originalFetch;
2392
+ }
2393
+ });
2394
+
2395
+ test("if user is not logged in (readPlatformToken returns null), injection is skipped gracefully", async () => {
2396
+ setArgv("--from", "my-platform", "--local", "my-local");
2397
+
2398
+ const platformEntry = makeEntry("my-platform", {
2399
+ cloud: "vellum",
2400
+ runtimeUrl: "https://platform.vellum.ai",
2401
+ });
2402
+ const localEntry = makeEntry("my-local", { cloud: "local" });
2403
+
2404
+ findAssistantByNameMock.mockImplementation((name: string) => {
2405
+ if (name === "my-platform") return platformEntry;
2406
+ if (name === "my-local") return localEntry;
2407
+ return null;
2408
+ });
2409
+
2410
+ // The readPlatformToken mock is called twice during teleport:
2411
+ // once during the platform export flow and once during credential injection.
2412
+ // We need the first call to return a token (for the export to work)
2413
+ // and the second call to return null (to test the skip path).
2414
+ let callCount = 0;
2415
+ readPlatformTokenMock.mockImplementation(() => {
2416
+ callCount++;
2417
+ // The platform export path calls readPlatformToken first;
2418
+ // the credential injection helper calls it after import.
2419
+ // Return null only on the last call (the injection helper).
2420
+ if (callCount <= 1) return "platform-token";
2421
+ return null;
2422
+ });
2423
+
2424
+ const originalFetch = globalThis.fetch;
2425
+ globalThis.fetch = createFetchMock() as unknown as typeof globalThis.fetch;
2426
+
2427
+ try {
2428
+ await teleport();
2429
+ expect(ensureSelfHostedLocalRegistrationMock).not.toHaveBeenCalled();
2430
+ expect(injectCredentialsIntoAssistantMock).not.toHaveBeenCalled();
2431
+ expect(consoleLogSpy).toHaveBeenCalledWith(
2432
+ " Skipped platform credential injection (not logged in).",
2433
+ );
2434
+ // Teleport still succeeds
2435
+ expect(consoleLogSpy).toHaveBeenCalledWith(
2436
+ expect.stringContaining("Teleport complete"),
2437
+ );
2438
+ } finally {
2439
+ globalThis.fetch = originalFetch;
2440
+ }
2441
+ });
2442
+ });