@vellumai/cli 0.5.15 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,10 @@
1
- import { findAssistantByName } from "../lib/assistant-config.js";
1
+ import {
2
+ findAssistantByName,
3
+ loadAllAssistants,
4
+ removeAssistantEntry,
5
+ saveAssistantEntry,
6
+ setActiveAssistant,
7
+ } from "../lib/assistant-config.js";
2
8
  import type { AssistantEntry } from "../lib/assistant-config.js";
3
9
  import {
4
10
  loadGuardianToken,
@@ -7,47 +13,147 @@ import {
7
13
  import {
8
14
  readPlatformToken,
9
15
  fetchOrganizationId,
16
+ getPlatformUrl,
17
+ hatchAssistant,
10
18
  platformInitiateExport,
11
19
  platformPollExportStatus,
12
20
  platformDownloadExport,
13
21
  platformImportPreflight,
14
22
  platformImportBundle,
23
+ platformRequestUploadUrl,
24
+ platformUploadToSignedUrl,
25
+ platformImportPreflightFromGcs,
26
+ platformImportBundleFromGcs,
15
27
  } from "../lib/platform-client.js";
28
+ import {
29
+ hatchDocker,
30
+ retireDocker,
31
+ sleepContainers,
32
+ dockerResourceNames,
33
+ } from "../lib/docker.js";
34
+ import { hatchLocal } from "../lib/hatch-local.js";
35
+ import { retireLocal } from "../lib/retire-local.js";
36
+ import { validateAssistantName } from "../lib/retire-archive.js";
37
+ import { stopProcessByPidFile } from "../lib/process.js";
38
+ import { join } from "node:path";
16
39
 
17
40
  function printHelp(): void {
18
41
  console.log(
19
- "Usage: vellum teleport --from <assistant> --to <assistant> [options]",
42
+ "Usage: vellum teleport --from <assistant> <--local | --docker | --platform> [name] [options]",
20
43
  );
21
44
  console.log("");
22
45
  console.log(
23
- "Transfer assistant data between local and platform environments.",
46
+ "Transfer assistant data between local, docker, and platform environments.",
47
+ );
48
+ console.log("");
49
+ console.log(
50
+ "The --from flag specifies the source assistant to export data from.",
51
+ );
52
+ console.log(
53
+ "Exactly one environment flag (--local, --docker, --platform) specifies",
24
54
  );
55
+ console.log(
56
+ "the target environment. An optional name after the environment flag",
57
+ );
58
+ console.log(
59
+ "targets an existing assistant (overwriting its data) or names a newly",
60
+ );
61
+ console.log(
62
+ "hatched one. If no name is given, a new assistant is hatched with an",
63
+ );
64
+ console.log("auto-generated name.");
65
+ console.log("");
66
+ console.log(
67
+ "The source and target must be different environments. Same-environment",
68
+ );
69
+ console.log("transfers (e.g. local to local) are not supported.");
70
+ console.log("");
71
+ console.log(
72
+ "For local-to-docker and docker-to-local transfers, the source assistant",
73
+ );
74
+ console.log(
75
+ "is automatically retired after a successful import to free up ports and",
76
+ );
77
+ console.log("avoid resource conflicts. Use --keep-source to skip this.");
78
+ console.log("");
79
+ console.log("Environment flags:");
80
+ console.log(" --local [name] Target a local bare-metal assistant");
81
+ console.log(" --docker [name] Target a docker assistant");
82
+ console.log(" --platform [name] Target a platform-hosted assistant");
25
83
  console.log("");
26
84
  console.log("Options:");
27
- console.log(" --from <name> Source assistant to export data from");
28
- console.log(" --to <name> Target assistant to import data into");
29
85
  console.log(
30
- " --dry-run Preview the transfer without applying changes",
86
+ " --from <name> Source assistant to export data from (required)",
87
+ );
88
+ console.log(
89
+ " --keep-source Do not retire the source after local/docker transfers",
90
+ );
91
+ console.log(
92
+ " --dry-run Preview the transfer without applying changes.",
93
+ );
94
+ console.log(
95
+ " If the target exists, runs preflight analysis.",
96
+ );
97
+ console.log(
98
+ " If the target would be hatched, shows what would happen",
31
99
  );
32
- console.log(" --help, -h Show this help");
100
+ console.log(" without creating anything.");
101
+ console.log(" --help, -h Show this help");
33
102
  console.log("");
34
103
  console.log("Examples:");
35
- console.log(" vellum teleport --from my-local --to my-cloud");
36
- console.log(" vellum teleport --from my-cloud --to my-local --dry-run");
37
- console.log(" vellum teleport --from staging --to production --dry-run");
104
+ console.log(" vellum teleport --from my-local --docker");
105
+ console.log(
106
+ " Hatch a new docker assistant, import data, and retire my-local",
107
+ );
108
+ console.log("");
109
+ console.log(" vellum teleport --from my-local --docker my-docker");
110
+ console.log(
111
+ " Import data from my-local into existing docker assistant my-docker",
112
+ );
113
+ console.log(
114
+ " (or hatch a new docker assistant named my-docker if it doesn't exist)",
115
+ );
116
+ console.log("");
117
+ console.log(" vellum teleport --from my-local --platform");
118
+ console.log(
119
+ " Hatch a new platform assistant and import data from my-local",
120
+ );
121
+ console.log("");
122
+ console.log(" vellum teleport --from my-cloud --local my-new-local");
123
+ console.log(
124
+ " Import data from platform assistant my-cloud into local assistant",
125
+ );
126
+ console.log("");
127
+ console.log(" vellum teleport --from my-docker --local --keep-source");
128
+ console.log(
129
+ " Transfer to a new local assistant but keep the docker source running",
130
+ );
131
+ console.log("");
132
+ console.log(
133
+ " vellum teleport --from staging --docker staging-copy --dry-run",
134
+ );
135
+ console.log(" Preview what would be imported without applying changes");
38
136
  }
39
137
 
40
- function parseArgs(argv: string[]): {
138
+ export function parseArgs(argv: string[]): {
41
139
  from: string | undefined;
42
140
  to: string | undefined;
141
+ targetEnv: "local" | "docker" | "platform" | undefined;
142
+ targetName: string | undefined;
143
+ keepSource: boolean;
43
144
  dryRun: boolean;
44
145
  help: boolean;
45
146
  } {
46
147
  let from: string | undefined;
47
148
  let to: string | undefined;
149
+ let targetEnv: "local" | "docker" | "platform" | undefined;
150
+ let targetName: string | undefined;
151
+ let keepSource = false;
48
152
  let dryRun = false;
49
153
  let help = false;
50
154
 
155
+ const envFlags: string[] = [];
156
+
51
157
  for (let i = 0; i < argv.length; i++) {
52
158
  const arg = argv[i];
53
159
  if (arg === "--from" && i + 1 < argv.length) {
@@ -60,6 +166,20 @@ function parseArgs(argv: string[]): {
60
166
  continue;
61
167
  }
62
168
  to = argv[++i];
169
+ } else if (
170
+ arg === "--local" ||
171
+ arg === "--docker" ||
172
+ arg === "--platform"
173
+ ) {
174
+ const env = arg.slice(2) as "local" | "docker" | "platform";
175
+ envFlags.push(env);
176
+ targetEnv = env;
177
+ // Peek at next arg for optional target name
178
+ if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
179
+ targetName = argv[++i];
180
+ }
181
+ } else if (arg === "--keep-source") {
182
+ keepSource = true;
63
183
  } else if (arg === "--dry-run") {
64
184
  dryRun = true;
65
185
  } else if (arg === "--help" || arg === "-h") {
@@ -67,7 +187,14 @@ function parseArgs(argv: string[]): {
67
187
  }
68
188
  }
69
189
 
70
- return { from, to, dryRun, help };
190
+ if (envFlags.length > 1) {
191
+ console.error(
192
+ "Error: Only one environment flag (--local, --docker, --platform) may be specified.",
193
+ );
194
+ process.exit(1);
195
+ }
196
+
197
+ return { from, to, targetEnv, targetName, keepSource, dryRun, help };
71
198
  }
72
199
 
73
200
  function resolveCloud(entry: AssistantEntry): string {
@@ -107,6 +234,245 @@ async function getAccessToken(
107
234
  }
108
235
  }
109
236
 
237
+ // ---------------------------------------------------------------------------
238
+ // HTTP-based export/import helpers (shared by local and docker)
239
+ // ---------------------------------------------------------------------------
240
+
241
+ async function exportViaHttp(
242
+ entry: AssistantEntry,
243
+ ): Promise<Uint8Array<ArrayBuffer>> {
244
+ let accessToken = await getAccessToken(
245
+ entry.runtimeUrl,
246
+ entry.assistantId,
247
+ entry.assistantId,
248
+ );
249
+
250
+ let response: Response;
251
+ try {
252
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
253
+ method: "POST",
254
+ headers: {
255
+ Authorization: `Bearer ${accessToken}`,
256
+ "Content-Type": "application/json",
257
+ },
258
+ body: JSON.stringify({ description: "teleport export" }),
259
+ signal: AbortSignal.timeout(120_000),
260
+ });
261
+
262
+ // Retry once with a fresh token on 401
263
+ if (response.status === 401) {
264
+ let refreshedToken: string | null = null;
265
+ try {
266
+ const freshToken = await leaseGuardianToken(
267
+ entry.runtimeUrl,
268
+ entry.assistantId,
269
+ );
270
+ refreshedToken = freshToken.accessToken;
271
+ } catch {
272
+ // If token refresh fails, fall through to the error handler below
273
+ }
274
+ if (refreshedToken) {
275
+ accessToken = refreshedToken;
276
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
277
+ method: "POST",
278
+ headers: {
279
+ Authorization: `Bearer ${accessToken}`,
280
+ "Content-Type": "application/json",
281
+ },
282
+ body: JSON.stringify({ description: "teleport export" }),
283
+ signal: AbortSignal.timeout(120_000),
284
+ });
285
+ }
286
+ }
287
+ } catch (err) {
288
+ if (err instanceof Error && err.name === "TimeoutError") {
289
+ console.error("Error: Export request timed out after 2 minutes.");
290
+ process.exit(1);
291
+ }
292
+ const msg = err instanceof Error ? err.message : String(err);
293
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
294
+ console.error(
295
+ `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
296
+ );
297
+ console.error(`Try: vellum wake ${entry.assistantId}`);
298
+ process.exit(1);
299
+ }
300
+ throw err;
301
+ }
302
+
303
+ if (response.status === 401 || response.status === 403) {
304
+ console.error("Authentication failed.");
305
+ process.exit(1);
306
+ }
307
+
308
+ if (response.status === 404) {
309
+ console.error("Assistant not found or not running.");
310
+ process.exit(1);
311
+ }
312
+
313
+ if (
314
+ response.status === 502 ||
315
+ response.status === 503 ||
316
+ response.status === 504
317
+ ) {
318
+ console.error(
319
+ `Assistant is unreachable. Try 'vellum wake ${entry.assistantId}'.`,
320
+ );
321
+ process.exit(1);
322
+ }
323
+
324
+ if (!response.ok) {
325
+ const body = await response.text();
326
+ console.error(`Error: Export failed (${response.status}): ${body}`);
327
+ process.exit(1);
328
+ }
329
+
330
+ const arrayBuffer = await response.arrayBuffer();
331
+ return new Uint8Array(arrayBuffer);
332
+ }
333
+
334
+ async function importViaHttp(
335
+ entry: AssistantEntry,
336
+ bundleData: Uint8Array<ArrayBuffer>,
337
+ dryRun: boolean,
338
+ ): Promise<void> {
339
+ let accessToken = await getAccessToken(
340
+ entry.runtimeUrl,
341
+ entry.assistantId,
342
+ entry.assistantId,
343
+ );
344
+
345
+ if (dryRun) {
346
+ console.log("Running preflight analysis...\n");
347
+
348
+ let response: Response;
349
+ try {
350
+ response = await fetch(
351
+ `${entry.runtimeUrl}/v1/migrations/import-preflight`,
352
+ {
353
+ method: "POST",
354
+ headers: {
355
+ Authorization: `Bearer ${accessToken}`,
356
+ "Content-Type": "application/octet-stream",
357
+ },
358
+ body: new Blob([bundleData]),
359
+ signal: AbortSignal.timeout(120_000),
360
+ },
361
+ );
362
+
363
+ // Retry once with a fresh token on 401
364
+ if (response.status === 401) {
365
+ let refreshedToken: string | null = null;
366
+ try {
367
+ const freshToken = await leaseGuardianToken(
368
+ entry.runtimeUrl,
369
+ entry.assistantId,
370
+ );
371
+ refreshedToken = freshToken.accessToken;
372
+ } catch {
373
+ // If token refresh fails, fall through to the error handler below
374
+ }
375
+ if (refreshedToken) {
376
+ accessToken = refreshedToken;
377
+ response = await fetch(
378
+ `${entry.runtimeUrl}/v1/migrations/import-preflight`,
379
+ {
380
+ method: "POST",
381
+ headers: {
382
+ Authorization: `Bearer ${accessToken}`,
383
+ "Content-Type": "application/octet-stream",
384
+ },
385
+ body: new Blob([bundleData]),
386
+ signal: AbortSignal.timeout(120_000),
387
+ },
388
+ );
389
+ }
390
+ }
391
+ } catch (err) {
392
+ if (err instanceof Error && err.name === "TimeoutError") {
393
+ console.error("Error: Preflight request timed out after 2 minutes.");
394
+ process.exit(1);
395
+ }
396
+ const msg = err instanceof Error ? err.message : String(err);
397
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
398
+ console.error(
399
+ `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
400
+ );
401
+ console.error(`Try: vellum wake ${entry.assistantId}`);
402
+ process.exit(1);
403
+ }
404
+ throw err;
405
+ }
406
+
407
+ handleLocalResponseErrors(response, entry.assistantId);
408
+
409
+ const result = (await response.json()) as PreflightResponse;
410
+ printPreflightSummary(result);
411
+ return;
412
+ }
413
+
414
+ // Actual import
415
+ console.log("Importing data...");
416
+
417
+ let response: Response;
418
+ try {
419
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
420
+ method: "POST",
421
+ headers: {
422
+ Authorization: `Bearer ${accessToken}`,
423
+ "Content-Type": "application/octet-stream",
424
+ },
425
+ body: new Blob([bundleData]),
426
+ signal: AbortSignal.timeout(120_000),
427
+ });
428
+
429
+ // Retry once with a fresh token on 401
430
+ if (response.status === 401) {
431
+ let refreshedToken: string | null = null;
432
+ try {
433
+ const freshToken = await leaseGuardianToken(
434
+ entry.runtimeUrl,
435
+ entry.assistantId,
436
+ );
437
+ refreshedToken = freshToken.accessToken;
438
+ } catch {
439
+ // If token refresh fails, fall through to the error handler below
440
+ }
441
+ if (refreshedToken) {
442
+ accessToken = refreshedToken;
443
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
444
+ method: "POST",
445
+ headers: {
446
+ Authorization: `Bearer ${accessToken}`,
447
+ "Content-Type": "application/octet-stream",
448
+ },
449
+ body: new Blob([bundleData]),
450
+ signal: AbortSignal.timeout(120_000),
451
+ });
452
+ }
453
+ }
454
+ } catch (err) {
455
+ if (err instanceof Error && err.name === "TimeoutError") {
456
+ console.error("Error: Import request timed out after 2 minutes.");
457
+ process.exit(1);
458
+ }
459
+ const msg = err instanceof Error ? err.message : String(err);
460
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
461
+ console.error(
462
+ `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
463
+ );
464
+ console.error(`Try: vellum wake ${entry.assistantId}`);
465
+ process.exit(1);
466
+ }
467
+ throw err;
468
+ }
469
+
470
+ handleLocalResponseErrors(response, entry.assistantId);
471
+
472
+ const result = (await response.json()) as ImportResponse;
473
+ printImportSummary(result);
474
+ }
475
+
110
476
  // ---------------------------------------------------------------------------
111
477
  // Export from source assistant
112
478
  // ---------------------------------------------------------------------------
@@ -205,100 +571,12 @@ async function exportFromAssistant(
205
571
  return new Uint8Array(arrayBuffer);
206
572
  }
207
573
 
208
- if (cloud === "local") {
209
- // Local source — direct export endpoint
210
- let accessToken = await getAccessToken(
211
- entry.runtimeUrl,
212
- entry.assistantId,
213
- entry.assistantId,
214
- );
215
-
216
- let response: Response;
217
- try {
218
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
219
- method: "POST",
220
- headers: {
221
- Authorization: `Bearer ${accessToken}`,
222
- "Content-Type": "application/json",
223
- },
224
- body: JSON.stringify({ description: "teleport export" }),
225
- signal: AbortSignal.timeout(120_000),
226
- });
227
-
228
- // Retry once with a fresh token on 401
229
- if (response.status === 401) {
230
- let refreshedToken: string | null = null;
231
- try {
232
- const freshToken = await leaseGuardianToken(
233
- entry.runtimeUrl,
234
- entry.assistantId,
235
- );
236
- refreshedToken = freshToken.accessToken;
237
- } catch {
238
- // If token refresh fails, fall through to the error handler below
239
- }
240
- if (refreshedToken) {
241
- accessToken = refreshedToken;
242
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
243
- method: "POST",
244
- headers: {
245
- Authorization: `Bearer ${accessToken}`,
246
- "Content-Type": "application/json",
247
- },
248
- body: JSON.stringify({ description: "teleport export" }),
249
- signal: AbortSignal.timeout(120_000),
250
- });
251
- }
252
- }
253
- } catch (err) {
254
- if (err instanceof Error && err.name === "TimeoutError") {
255
- console.error("Error: Export request timed out after 2 minutes.");
256
- process.exit(1);
257
- }
258
- const msg = err instanceof Error ? err.message : String(err);
259
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
260
- console.error(
261
- `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
262
- );
263
- console.error(`Try: vellum wake ${entry.assistantId}`);
264
- process.exit(1);
265
- }
266
- throw err;
267
- }
268
-
269
- if (response.status === 401 || response.status === 403) {
270
- console.error("Authentication failed.");
271
- process.exit(1);
272
- }
273
-
274
- if (response.status === 404) {
275
- console.error("Assistant not found or not running.");
276
- process.exit(1);
277
- }
278
-
279
- if (
280
- response.status === 502 ||
281
- response.status === 503 ||
282
- response.status === 504
283
- ) {
284
- console.error(
285
- `Assistant is unreachable. Try 'vellum wake ${entry.assistantId}'.`,
286
- );
287
- process.exit(1);
288
- }
289
-
290
- if (!response.ok) {
291
- const body = await response.text();
292
- console.error(`Error: Export failed (${response.status}): ${body}`);
293
- process.exit(1);
294
- }
295
-
296
- const arrayBuffer = await response.arrayBuffer();
297
- return new Uint8Array(arrayBuffer);
574
+ if (cloud === "local" || cloud === "docker") {
575
+ return exportViaHttp(entry);
298
576
  }
299
577
 
300
578
  console.error(
301
- "Teleport only supports local and platform assistants as source.",
579
+ "Teleport only supports local, docker, and platform assistants as source.",
302
580
  );
303
581
  process.exit(1);
304
582
  }
@@ -354,6 +632,7 @@ async function importToAssistant(
354
632
  cloud: string,
355
633
  bundleData: Uint8Array<ArrayBuffer>,
356
634
  dryRun: boolean,
635
+ preUploadedBundleKey?: string | null,
357
636
  ): Promise<void> {
358
637
  if (cloud === "vellum") {
359
638
  // Platform target
@@ -375,6 +654,32 @@ async function importToAssistant(
375
654
  throw err;
376
655
  }
377
656
 
657
+ // Use pre-uploaded bundle key if provided (string), skip upload if null
658
+ // (signals signed URLs were already tried and unavailable), or try
659
+ // signed-URL upload if undefined (never attempted).
660
+ let bundleKey: string | undefined =
661
+ preUploadedBundleKey === null ? undefined : preUploadedBundleKey;
662
+ if (preUploadedBundleKey === undefined) {
663
+ try {
664
+ const { uploadUrl, bundleKey: key } = await platformRequestUploadUrl(
665
+ token,
666
+ orgId,
667
+ entry.runtimeUrl,
668
+ );
669
+ bundleKey = key;
670
+ console.log("Uploading bundle...");
671
+ await platformUploadToSignedUrl(uploadUrl, bundleData);
672
+ } catch (err) {
673
+ // If signed uploads unavailable (503), fall back to inline upload
674
+ const msg = err instanceof Error ? err.message : String(err);
675
+ if (msg.includes("not available")) {
676
+ bundleKey = undefined;
677
+ } else {
678
+ throw err;
679
+ }
680
+ }
681
+ }
682
+
378
683
  if (dryRun) {
379
684
  console.log("Running preflight analysis...\n");
380
685
 
@@ -383,12 +688,19 @@ async function importToAssistant(
383
688
  body: Record<string, unknown>;
384
689
  };
385
690
  try {
386
- preflightResult = await platformImportPreflight(
387
- bundleData,
388
- token,
389
- orgId,
390
- entry.runtimeUrl,
391
- );
691
+ preflightResult = bundleKey
692
+ ? await platformImportPreflightFromGcs(
693
+ bundleKey,
694
+ token,
695
+ orgId,
696
+ entry.runtimeUrl,
697
+ )
698
+ : await platformImportPreflight(
699
+ bundleData,
700
+ token,
701
+ orgId,
702
+ entry.runtimeUrl,
703
+ );
392
704
  } catch (err) {
393
705
  if (err instanceof Error && err.name === "TimeoutError") {
394
706
  console.error("Error: Preflight request timed out after 2 minutes.");
@@ -438,12 +750,19 @@ async function importToAssistant(
438
750
 
439
751
  let importResult: { statusCode: number; body: Record<string, unknown> };
440
752
  try {
441
- importResult = await platformImportBundle(
442
- bundleData,
443
- token,
444
- orgId,
445
- entry.runtimeUrl,
446
- );
753
+ importResult = bundleKey
754
+ ? await platformImportBundleFromGcs(
755
+ bundleKey,
756
+ token,
757
+ orgId,
758
+ entry.runtimeUrl,
759
+ )
760
+ : await platformImportBundle(
761
+ bundleData,
762
+ token,
763
+ orgId,
764
+ entry.runtimeUrl,
765
+ );
447
766
  } catch (err) {
448
767
  if (err instanceof Error && err.name === "TimeoutError") {
449
768
  console.error("Error: Import request timed out after 2 minutes.");
@@ -459,94 +778,137 @@ async function importToAssistant(
459
778
  return;
460
779
  }
461
780
 
462
- if (cloud === "local") {
463
- // Local target
464
- const accessToken = await getAccessToken(
465
- entry.runtimeUrl,
466
- entry.assistantId,
467
- entry.assistantId,
468
- );
781
+ if (cloud === "local" || cloud === "docker") {
782
+ await importViaHttp(entry, bundleData, dryRun);
783
+ return;
784
+ }
469
785
 
470
- if (dryRun) {
471
- console.log("Running preflight analysis...\n");
786
+ console.error(
787
+ "Teleport only supports local, docker, and platform assistants as target.",
788
+ );
789
+ process.exit(1);
790
+ }
791
+
792
+ // ---------------------------------------------------------------------------
793
+ // Resolve or hatch target assistant
794
+ // ---------------------------------------------------------------------------
472
795
 
473
- let response: Response;
796
+ export async function resolveOrHatchTarget(
797
+ targetEnv: "local" | "docker" | "platform",
798
+ targetName?: string,
799
+ orgId?: string,
800
+ ): Promise<AssistantEntry> {
801
+ // If a name is provided, try to find an existing assistant
802
+ if (targetName) {
803
+ const existing = findAssistantByName(targetName);
804
+ if (existing) {
805
+ // Validate the existing assistant's cloud matches the requested env
806
+ const existingCloud = resolveCloud(existing);
807
+ const normalizedExisting =
808
+ existingCloud === "vellum" ? "platform" : existingCloud;
809
+ if (normalizedExisting !== targetEnv) {
810
+ console.error(
811
+ `Error: Assistant '${targetName}' is a ${normalizedExisting} assistant, not ${targetEnv}. ` +
812
+ `Use --${normalizedExisting} to target it.`,
813
+ );
814
+ process.exit(1);
815
+ }
816
+ console.log(`Target: ${targetName} (${targetEnv})`);
817
+ return existing;
818
+ }
819
+
820
+ // Name not found — will hatch.
821
+ if (targetEnv === "platform") {
822
+ // Platform API doesn't accept custom names — warn and ignore
823
+ console.log(
824
+ `Note: Platform assistants receive a server-assigned ID. The name '${targetName}' will not be used.`,
825
+ );
826
+ } else {
827
+ // Validate the name before passing to hatch
474
828
  try {
475
- response = await fetch(
476
- `${entry.runtimeUrl}/v1/migrations/import-preflight`,
477
- {
478
- method: "POST",
479
- headers: {
480
- Authorization: `Bearer ${accessToken}`,
481
- "Content-Type": "application/octet-stream",
482
- },
483
- body: new Blob([bundleData]),
484
- signal: AbortSignal.timeout(120_000),
485
- },
829
+ validateAssistantName(targetName);
830
+ } catch {
831
+ console.error(
832
+ "Error: Target name contains invalid characters (path separators or traversal segments are not allowed).",
486
833
  );
834
+ process.exit(1);
835
+ }
836
+ }
837
+ }
838
+
839
+ // Hatch a new assistant in the target environment
840
+ if (targetEnv === "local") {
841
+ const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
842
+ await hatchLocal("vellum", targetName ?? null, false, false, false, {});
843
+ const entry = targetName
844
+ ? findAssistantByName(targetName)
845
+ : (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
846
+ null);
847
+ if (!entry) {
848
+ console.error("Error: Could not find the newly hatched local assistant.");
849
+ process.exit(1);
850
+ }
851
+ console.log(`Hatched new local assistant: ${entry.assistantId}`);
852
+ return entry;
853
+ }
854
+
855
+ if (targetEnv === "docker") {
856
+ const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
857
+ await hatchDocker("vellum", false, targetName ?? null, false, {});
858
+ const entry = targetName
859
+ ? findAssistantByName(targetName)
860
+ : (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
861
+ null);
862
+ if (!entry) {
863
+ console.error(
864
+ "Error: Could not find the newly hatched docker assistant.",
865
+ );
866
+ process.exit(1);
867
+ }
868
+ console.log(`Hatched new docker assistant: ${entry.assistantId}`);
869
+ return entry;
870
+ }
871
+
872
+ if (targetEnv === "platform") {
873
+ const token = readPlatformToken();
874
+ if (!token) {
875
+ console.error("Not logged in. Run 'vellum login' first.");
876
+ process.exit(1);
877
+ }
878
+
879
+ let resolvedOrgId: string;
880
+ if (orgId) {
881
+ resolvedOrgId = orgId;
882
+ } else {
883
+ try {
884
+ resolvedOrgId = await fetchOrganizationId(token);
487
885
  } catch (err) {
488
- if (err instanceof Error && err.name === "TimeoutError") {
489
- console.error("Error: Preflight request timed out after 2 minutes.");
490
- process.exit(1);
491
- }
492
886
  const msg = err instanceof Error ? err.message : String(err);
493
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
887
+ if (msg.includes("401") || msg.includes("403")) {
494
888
  console.error(
495
- `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
889
+ "Authentication failed. Run 'vellum login' to refresh.",
496
890
  );
497
- console.error(`Try: vellum wake ${entry.assistantId}`);
498
891
  process.exit(1);
499
892
  }
500
893
  throw err;
501
894
  }
502
-
503
- handleLocalResponseErrors(response, entry.assistantId);
504
-
505
- const result = (await response.json()) as PreflightResponse;
506
- printPreflightSummary(result);
507
- return;
508
- }
509
-
510
- // Actual import
511
- console.log("Importing data...");
512
-
513
- let response: Response;
514
- try {
515
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
516
- method: "POST",
517
- headers: {
518
- Authorization: `Bearer ${accessToken}`,
519
- "Content-Type": "application/octet-stream",
520
- },
521
- body: new Blob([bundleData]),
522
- signal: AbortSignal.timeout(120_000),
523
- });
524
- } catch (err) {
525
- if (err instanceof Error && err.name === "TimeoutError") {
526
- console.error("Error: Import request timed out after 2 minutes.");
527
- process.exit(1);
528
- }
529
- const msg = err instanceof Error ? err.message : String(err);
530
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
531
- console.error(
532
- `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
533
- );
534
- console.error(`Try: vellum wake ${entry.assistantId}`);
535
- process.exit(1);
536
- }
537
- throw err;
538
895
  }
539
896
 
540
- handleLocalResponseErrors(response, entry.assistantId);
541
-
542
- const result = (await response.json()) as ImportResponse;
543
- printImportSummary(result);
544
- return;
897
+ const result = await hatchAssistant(token, resolvedOrgId);
898
+ const entry: AssistantEntry = {
899
+ assistantId: result.id,
900
+ runtimeUrl: getPlatformUrl(),
901
+ cloud: "vellum",
902
+ species: "vellum",
903
+ hatchedAt: new Date().toISOString(),
904
+ };
905
+ saveAssistantEntry(entry);
906
+ setActiveAssistant(result.id);
907
+ console.log(`Hatched new platform assistant: ${result.id}`);
908
+ return entry;
545
909
  }
546
910
 
547
- console.error(
548
- "Teleport only supports local and platform assistants as target.",
549
- );
911
+ console.error(`Error: Unknown target environment '${targetEnv}'.`);
550
912
  process.exit(1);
551
913
  }
552
914
 
@@ -701,19 +1063,36 @@ function printImportSummary(result: ImportResponse): void {
701
1063
 
702
1064
  export async function teleport(): Promise<void> {
703
1065
  const args = process.argv.slice(3);
704
- const { from, to, dryRun, help } = parseArgs(args);
1066
+ const { from, to, targetEnv, targetName, keepSource, dryRun, help } =
1067
+ parseArgs(args);
705
1068
 
706
1069
  if (help) {
707
1070
  printHelp();
708
1071
  process.exit(0);
709
1072
  }
710
1073
 
711
- if (!from || !to) {
1074
+ // Legacy --to flag deprecation
1075
+ if (to) {
1076
+ console.error("Error: --to is deprecated. Use environment flags instead:");
1077
+ console.error(
1078
+ " vellum teleport --from <source> --local|--docker|--platform [name]",
1079
+ );
1080
+ console.error("");
1081
+ console.error("Run 'vellum teleport --help' for details.");
1082
+ process.exit(1);
1083
+ }
1084
+
1085
+ if (!from) {
712
1086
  printHelp();
713
1087
  process.exit(1);
714
1088
  }
715
1089
 
716
- // Look up both assistants
1090
+ if (!targetEnv) {
1091
+ printHelp();
1092
+ process.exit(1);
1093
+ }
1094
+
1095
+ // Look up source assistant
717
1096
  const fromEntry = findAssistantByName(from);
718
1097
  if (!fromEntry) {
719
1098
  console.error(
@@ -722,25 +1101,205 @@ export async function teleport(): Promise<void> {
722
1101
  process.exit(1);
723
1102
  }
724
1103
 
725
- const toEntry = findAssistantByName(to);
726
- if (!toEntry) {
1104
+ const fromCloud = resolveCloud(fromEntry);
1105
+
1106
+ // Early same-environment guard — compare source cloud against the CLI flag
1107
+ // BEFORE exporting or hatching, to avoid creating orphaned assistants.
1108
+ const normalizedSourceEnv = fromCloud === "vellum" ? "platform" : fromCloud;
1109
+ if (normalizedSourceEnv === targetEnv) {
727
1110
  console.error(
728
- `Assistant '${to}' not found in lockfile. Run \`vellum ps\` to see available assistants.`,
1111
+ `Cannot teleport between two ${targetEnv} assistants. Teleport transfers data across different environments.`,
729
1112
  );
730
1113
  process.exit(1);
731
1114
  }
732
1115
 
733
- const fromCloud = resolveCloud(fromEntry);
734
- const toCloud = resolveCloud(toEntry);
1116
+ // Dry-run without an existing target: skip export, hatch, and import —
1117
+ // just report what would happen.
1118
+ if (dryRun) {
1119
+ const existingTarget = targetName ? findAssistantByName(targetName) : null;
1120
+
1121
+ if (existingTarget) {
1122
+ // Target exists — validate cloud matches the flag, then run preflight
1123
+ const toCloud = resolveCloud(existingTarget);
1124
+ const normalizedTargetEnv = toCloud === "vellum" ? "platform" : toCloud;
1125
+ if (normalizedTargetEnv !== targetEnv) {
1126
+ console.error(
1127
+ `Error: Assistant '${targetName}' is a ${normalizedTargetEnv} assistant, not ${targetEnv}. ` +
1128
+ `Use --${normalizedTargetEnv} to target it.`,
1129
+ );
1130
+ process.exit(1);
1131
+ }
1132
+ if (normalizedSourceEnv === normalizedTargetEnv) {
1133
+ console.error(
1134
+ `Cannot teleport between two ${normalizedTargetEnv} assistants. Teleport transfers data across different environments.`,
1135
+ );
1136
+ process.exit(1);
1137
+ }
1138
+
1139
+ console.log(`Exporting from ${from} (${fromCloud})...`);
1140
+ const bundleData = await exportFromAssistant(fromEntry, fromCloud);
1141
+ console.log(`Importing to ${existingTarget.assistantId} (${toCloud})...`);
1142
+ await importToAssistant(existingTarget, toCloud, bundleData, true);
1143
+ } else {
1144
+ // No existing target — just describe what would happen
1145
+ console.log("Dry run summary:");
1146
+ console.log(` Would export data from: ${from} (${fromCloud})`);
1147
+ if (targetEnv === "platform") {
1148
+ // For platform targets, reflect the reordered flow
1149
+ console.log(` Would upload bundle via signed URL (if available)`);
1150
+ console.log(
1151
+ ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1152
+ );
1153
+ console.log(` Would import data into the new assistant`);
1154
+ } else {
1155
+ console.log(
1156
+ ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1157
+ );
1158
+ console.log(` Would import data into the new assistant`);
1159
+ }
1160
+ }
1161
+
1162
+ console.log(`Dry run complete — no changes were made.`);
1163
+ return;
1164
+ }
735
1165
 
736
1166
  // Export from source
737
1167
  console.log(`Exporting from ${from} (${fromCloud})...`);
738
1168
  const bundleData = await exportFromAssistant(fromEntry, fromCloud);
739
1169
 
1170
+ // Platform target: reordered flow — upload to GCS before hatching so that
1171
+ // if upload fails, no empty assistant is left dangling on the platform.
1172
+ if (targetEnv === "platform") {
1173
+ // Step B — Auth + Org ID
1174
+ const token = readPlatformToken();
1175
+ if (!token) {
1176
+ console.error("Not logged in. Run 'vellum login' first.");
1177
+ process.exit(1);
1178
+ }
1179
+
1180
+ // If targeting an existing assistant, validate cloud match early — before
1181
+ // uploading — so we don't waste a GCS upload on an invalid command.
1182
+ const existingTarget = targetName ? findAssistantByName(targetName) : null;
1183
+ if (existingTarget) {
1184
+ const existingCloud = resolveCloud(existingTarget);
1185
+ if (existingCloud !== "vellum") {
1186
+ console.error(
1187
+ `Error: Assistant '${targetName}' is a ${existingCloud} assistant, not platform. ` +
1188
+ `Use --${existingCloud} to target it.`,
1189
+ );
1190
+ process.exit(1);
1191
+ }
1192
+ }
1193
+
1194
+ // Use the existing target's runtimeUrl for all platform calls so upload,
1195
+ // org ID fetch, and import hit the same instance.
1196
+ const targetPlatformUrl = existingTarget?.runtimeUrl;
1197
+
1198
+ let orgId: string;
1199
+ try {
1200
+ orgId = await fetchOrganizationId(token, targetPlatformUrl);
1201
+ } catch (err) {
1202
+ const msg = err instanceof Error ? err.message : String(err);
1203
+ if (msg.includes("401") || msg.includes("403")) {
1204
+ console.error("Authentication failed. Run 'vellum login' to refresh.");
1205
+ process.exit(1);
1206
+ }
1207
+ throw err;
1208
+ }
1209
+
1210
+ // Step C — Upload to GCS
1211
+ // bundleKey: string = uploaded successfully, null = tried but unavailable,
1212
+ // undefined would mean "never tried" (not used here).
1213
+ let bundleKey: string | null = null;
1214
+ try {
1215
+ const { uploadUrl, bundleKey: key } = await platformRequestUploadUrl(
1216
+ token,
1217
+ orgId,
1218
+ targetPlatformUrl,
1219
+ );
1220
+ bundleKey = key;
1221
+ console.log("Uploading bundle to GCS...");
1222
+ await platformUploadToSignedUrl(uploadUrl, bundleData);
1223
+ } catch (err) {
1224
+ // If signed uploads unavailable (503), fall back to inline upload later
1225
+ const msg = err instanceof Error ? err.message : String(err);
1226
+ if (msg.includes("not available")) {
1227
+ bundleKey = null;
1228
+ } else {
1229
+ throw err;
1230
+ }
1231
+ }
1232
+
1233
+ // Step D — Hatch (upload succeeded or fallback to inline — safe to hatch)
1234
+ const toEntry = await resolveOrHatchTarget(targetEnv, targetName, orgId);
1235
+ const toCloud = resolveCloud(toEntry);
1236
+
1237
+ // Step E — Import from GCS (or inline fallback)
1238
+ // Pass bundleKey (string) or null to signal "already tried, use inline".
1239
+ console.log(`Importing to ${toEntry.assistantId} (${toCloud})...`);
1240
+ await importToAssistant(toEntry, toCloud, bundleData, false, bundleKey);
1241
+
1242
+ // Success summary
1243
+ console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
1244
+ return;
1245
+ }
1246
+
1247
+ // Non-platform targets (local/docker): existing flow unchanged
1248
+ // For local<->docker transfers, stop (sleep) the source to free up ports
1249
+ // before hatching the target. We do NOT retire yet — if hatch or import
1250
+ // fails, the user can recover by running `vellum wake <source>`.
1251
+ const sourceIsLocalOrDocker = fromCloud === "local" || fromCloud === "docker";
1252
+ const targetIsLocalOrDocker = targetEnv === "local" || targetEnv === "docker";
1253
+ if (sourceIsLocalOrDocker && targetIsLocalOrDocker && !keepSource) {
1254
+ console.log(`Stopping source assistant '${from}' to free ports...`);
1255
+ if (fromCloud === "docker") {
1256
+ const res = dockerResourceNames(fromEntry.assistantId);
1257
+ await sleepContainers(res);
1258
+ } else if (fromEntry.resources) {
1259
+ const pidFile = fromEntry.resources.pidFile;
1260
+ const vellumDir = join(fromEntry.resources.instanceDir, ".vellum");
1261
+ const gatewayPidFile = join(vellumDir, "gateway.pid");
1262
+ await stopProcessByPidFile(pidFile, "assistant");
1263
+ await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1264
+ }
1265
+ console.log(`Source assistant '${from}' stopped.`);
1266
+ }
1267
+
1268
+ // Resolve or hatch target (after source is stopped to avoid port conflicts)
1269
+ const toEntry = await resolveOrHatchTarget(targetEnv, targetName);
1270
+ const toCloud = resolveCloud(toEntry);
1271
+
1272
+ // Post-hatch same-environment safety net — uses resolved clouds in case
1273
+ // the resolved target cloud differs from the CLI flag (e.g., --docker
1274
+ // targeting a name that is actually a local entry).
1275
+ const normalizedTargetEnv = toCloud === "vellum" ? "platform" : toCloud;
1276
+ if (normalizedSourceEnv === normalizedTargetEnv) {
1277
+ console.error(
1278
+ `Cannot teleport between two ${normalizedTargetEnv} assistants. Teleport transfers data across different environments.`,
1279
+ );
1280
+ process.exit(1);
1281
+ }
1282
+
740
1283
  // Import to target
741
- console.log(`Importing to ${to} (${toCloud})...`);
742
- await importToAssistant(toEntry, toCloud, bundleData, dryRun);
1284
+ console.log(`Importing to ${toEntry.assistantId} (${toCloud})...`);
1285
+ await importToAssistant(toEntry, toCloud, bundleData, false);
1286
+
1287
+ // Retire source after successful import
1288
+ if (sourceIsLocalOrDocker && targetIsLocalOrDocker) {
1289
+ if (!keepSource) {
1290
+ console.log(`Retiring source assistant '${from}'...`);
1291
+ if (fromCloud === "docker") {
1292
+ await retireDocker(fromEntry.assistantId);
1293
+ } else {
1294
+ await retireLocal(fromEntry.assistantId, fromEntry);
1295
+ }
1296
+ removeAssistantEntry(fromEntry.assistantId);
1297
+ console.log(`Source assistant '${from}' retired.`);
1298
+ } else {
1299
+ console.log(`Source assistant '${from}' kept (--keep-source).`);
1300
+ }
1301
+ }
743
1302
 
744
1303
  // Success summary
745
- console.log(`Teleport complete: ${from} → ${to}`);
1304
+ console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
746
1305
  }