@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,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,143 @@ 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,
15
23
  } from "../lib/platform-client.js";
24
+ import {
25
+ hatchDocker,
26
+ retireDocker,
27
+ sleepContainers,
28
+ dockerResourceNames,
29
+ } from "../lib/docker.js";
30
+ import { hatchLocal } from "../lib/hatch-local.js";
31
+ import { retireLocal } from "../lib/retire-local.js";
32
+ import { validateAssistantName } from "../lib/retire-archive.js";
33
+ import { stopProcessByPidFile } from "../lib/process.js";
34
+ import { join } from "node:path";
16
35
 
17
36
  function printHelp(): void {
18
37
  console.log(
19
- "Usage: vellum teleport --from <assistant> --to <assistant> [options]",
38
+ "Usage: vellum teleport --from <assistant> <--local | --docker | --platform> [name] [options]",
39
+ );
40
+ console.log("");
41
+ console.log(
42
+ "Transfer assistant data between local, docker, and platform environments.",
43
+ );
44
+ console.log("");
45
+ console.log(
46
+ "The --from flag specifies the source assistant to export data from.",
47
+ );
48
+ console.log(
49
+ "Exactly one environment flag (--local, --docker, --platform) specifies",
50
+ );
51
+ console.log(
52
+ "the target environment. An optional name after the environment flag",
53
+ );
54
+ console.log(
55
+ "targets an existing assistant (overwriting its data) or names a newly",
20
56
  );
57
+ console.log(
58
+ "hatched one. If no name is given, a new assistant is hatched with an",
59
+ );
60
+ console.log("auto-generated name.");
21
61
  console.log("");
22
62
  console.log(
23
- "Transfer assistant data between local and platform environments.",
63
+ "The source and target must be different environments. Same-environment",
24
64
  );
65
+ console.log("transfers (e.g. local to local) are not supported.");
66
+ console.log("");
67
+ console.log(
68
+ "For local-to-docker and docker-to-local transfers, the source assistant",
69
+ );
70
+ console.log(
71
+ "is automatically retired after a successful import to free up ports and",
72
+ );
73
+ console.log("avoid resource conflicts. Use --keep-source to skip this.");
74
+ console.log("");
75
+ console.log("Environment flags:");
76
+ console.log(" --local [name] Target a local bare-metal assistant");
77
+ console.log(" --docker [name] Target a docker assistant");
78
+ console.log(" --platform [name] Target a platform-hosted assistant");
25
79
  console.log("");
26
80
  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
81
  console.log(
30
- " --dry-run Preview the transfer without applying changes",
82
+ " --from <name> Source assistant to export data from (required)",
31
83
  );
32
- console.log(" --help, -h Show this help");
84
+ console.log(
85
+ " --keep-source Do not retire the source after local/docker transfers",
86
+ );
87
+ console.log(
88
+ " --dry-run Preview the transfer without applying changes.",
89
+ );
90
+ console.log(
91
+ " If the target exists, runs preflight analysis.",
92
+ );
93
+ console.log(
94
+ " If the target would be hatched, shows what would happen",
95
+ );
96
+ console.log(" without creating anything.");
97
+ console.log(" --help, -h Show this help");
33
98
  console.log("");
34
99
  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");
100
+ console.log(" vellum teleport --from my-local --docker");
101
+ console.log(
102
+ " Hatch a new docker assistant, import data, and retire my-local",
103
+ );
104
+ console.log("");
105
+ console.log(" vellum teleport --from my-local --docker my-docker");
106
+ console.log(
107
+ " Import data from my-local into existing docker assistant my-docker",
108
+ );
109
+ console.log(
110
+ " (or hatch a new docker assistant named my-docker if it doesn't exist)",
111
+ );
112
+ console.log("");
113
+ console.log(" vellum teleport --from my-local --platform");
114
+ console.log(
115
+ " Hatch a new platform assistant and import data from my-local",
116
+ );
117
+ console.log("");
118
+ console.log(" vellum teleport --from my-cloud --local my-new-local");
119
+ console.log(
120
+ " Import data from platform assistant my-cloud into local assistant",
121
+ );
122
+ console.log("");
123
+ console.log(" vellum teleport --from my-docker --local --keep-source");
124
+ console.log(
125
+ " Transfer to a new local assistant but keep the docker source running",
126
+ );
127
+ console.log("");
128
+ console.log(
129
+ " vellum teleport --from staging --docker staging-copy --dry-run",
130
+ );
131
+ console.log(" Preview what would be imported without applying changes");
38
132
  }
39
133
 
40
- function parseArgs(argv: string[]): {
134
+ export function parseArgs(argv: string[]): {
41
135
  from: string | undefined;
42
136
  to: string | undefined;
137
+ targetEnv: "local" | "docker" | "platform" | undefined;
138
+ targetName: string | undefined;
139
+ keepSource: boolean;
43
140
  dryRun: boolean;
44
141
  help: boolean;
45
142
  } {
46
143
  let from: string | undefined;
47
144
  let to: string | undefined;
145
+ let targetEnv: "local" | "docker" | "platform" | undefined;
146
+ let targetName: string | undefined;
147
+ let keepSource = false;
48
148
  let dryRun = false;
49
149
  let help = false;
50
150
 
151
+ const envFlags: string[] = [];
152
+
51
153
  for (let i = 0; i < argv.length; i++) {
52
154
  const arg = argv[i];
53
155
  if (arg === "--from" && i + 1 < argv.length) {
@@ -60,6 +162,20 @@ function parseArgs(argv: string[]): {
60
162
  continue;
61
163
  }
62
164
  to = argv[++i];
165
+ } else if (
166
+ arg === "--local" ||
167
+ arg === "--docker" ||
168
+ arg === "--platform"
169
+ ) {
170
+ const env = arg.slice(2) as "local" | "docker" | "platform";
171
+ envFlags.push(env);
172
+ targetEnv = env;
173
+ // Peek at next arg for optional target name
174
+ if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
175
+ targetName = argv[++i];
176
+ }
177
+ } else if (arg === "--keep-source") {
178
+ keepSource = true;
63
179
  } else if (arg === "--dry-run") {
64
180
  dryRun = true;
65
181
  } else if (arg === "--help" || arg === "-h") {
@@ -67,7 +183,14 @@ function parseArgs(argv: string[]): {
67
183
  }
68
184
  }
69
185
 
70
- return { from, to, dryRun, help };
186
+ if (envFlags.length > 1) {
187
+ console.error(
188
+ "Error: Only one environment flag (--local, --docker, --platform) may be specified.",
189
+ );
190
+ process.exit(1);
191
+ }
192
+
193
+ return { from, to, targetEnv, targetName, keepSource, dryRun, help };
71
194
  }
72
195
 
73
196
  function resolveCloud(entry: AssistantEntry): string {
@@ -107,6 +230,245 @@ async function getAccessToken(
107
230
  }
108
231
  }
109
232
 
233
+ // ---------------------------------------------------------------------------
234
+ // HTTP-based export/import helpers (shared by local and docker)
235
+ // ---------------------------------------------------------------------------
236
+
237
+ async function exportViaHttp(
238
+ entry: AssistantEntry,
239
+ ): Promise<Uint8Array<ArrayBuffer>> {
240
+ let accessToken = await getAccessToken(
241
+ entry.runtimeUrl,
242
+ entry.assistantId,
243
+ entry.assistantId,
244
+ );
245
+
246
+ let response: Response;
247
+ try {
248
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
249
+ method: "POST",
250
+ headers: {
251
+ Authorization: `Bearer ${accessToken}`,
252
+ "Content-Type": "application/json",
253
+ },
254
+ body: JSON.stringify({ description: "teleport export" }),
255
+ signal: AbortSignal.timeout(120_000),
256
+ });
257
+
258
+ // Retry once with a fresh token on 401
259
+ if (response.status === 401) {
260
+ let refreshedToken: string | null = null;
261
+ try {
262
+ const freshToken = await leaseGuardianToken(
263
+ entry.runtimeUrl,
264
+ entry.assistantId,
265
+ );
266
+ refreshedToken = freshToken.accessToken;
267
+ } catch {
268
+ // If token refresh fails, fall through to the error handler below
269
+ }
270
+ if (refreshedToken) {
271
+ accessToken = refreshedToken;
272
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
273
+ method: "POST",
274
+ headers: {
275
+ Authorization: `Bearer ${accessToken}`,
276
+ "Content-Type": "application/json",
277
+ },
278
+ body: JSON.stringify({ description: "teleport export" }),
279
+ signal: AbortSignal.timeout(120_000),
280
+ });
281
+ }
282
+ }
283
+ } catch (err) {
284
+ if (err instanceof Error && err.name === "TimeoutError") {
285
+ console.error("Error: Export request timed out after 2 minutes.");
286
+ process.exit(1);
287
+ }
288
+ const msg = err instanceof Error ? err.message : String(err);
289
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
290
+ console.error(
291
+ `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
292
+ );
293
+ console.error(`Try: vellum wake ${entry.assistantId}`);
294
+ process.exit(1);
295
+ }
296
+ throw err;
297
+ }
298
+
299
+ if (response.status === 401 || response.status === 403) {
300
+ console.error("Authentication failed.");
301
+ process.exit(1);
302
+ }
303
+
304
+ if (response.status === 404) {
305
+ console.error("Assistant not found or not running.");
306
+ process.exit(1);
307
+ }
308
+
309
+ if (
310
+ response.status === 502 ||
311
+ response.status === 503 ||
312
+ response.status === 504
313
+ ) {
314
+ console.error(
315
+ `Assistant is unreachable. Try 'vellum wake ${entry.assistantId}'.`,
316
+ );
317
+ process.exit(1);
318
+ }
319
+
320
+ if (!response.ok) {
321
+ const body = await response.text();
322
+ console.error(`Error: Export failed (${response.status}): ${body}`);
323
+ process.exit(1);
324
+ }
325
+
326
+ const arrayBuffer = await response.arrayBuffer();
327
+ return new Uint8Array(arrayBuffer);
328
+ }
329
+
330
+ async function importViaHttp(
331
+ entry: AssistantEntry,
332
+ bundleData: Uint8Array<ArrayBuffer>,
333
+ dryRun: boolean,
334
+ ): Promise<void> {
335
+ let accessToken = await getAccessToken(
336
+ entry.runtimeUrl,
337
+ entry.assistantId,
338
+ entry.assistantId,
339
+ );
340
+
341
+ if (dryRun) {
342
+ console.log("Running preflight analysis...\n");
343
+
344
+ let response: Response;
345
+ try {
346
+ response = await fetch(
347
+ `${entry.runtimeUrl}/v1/migrations/import-preflight`,
348
+ {
349
+ method: "POST",
350
+ headers: {
351
+ Authorization: `Bearer ${accessToken}`,
352
+ "Content-Type": "application/octet-stream",
353
+ },
354
+ body: new Blob([bundleData]),
355
+ signal: AbortSignal.timeout(120_000),
356
+ },
357
+ );
358
+
359
+ // Retry once with a fresh token on 401
360
+ if (response.status === 401) {
361
+ let refreshedToken: string | null = null;
362
+ try {
363
+ const freshToken = await leaseGuardianToken(
364
+ entry.runtimeUrl,
365
+ entry.assistantId,
366
+ );
367
+ refreshedToken = freshToken.accessToken;
368
+ } catch {
369
+ // If token refresh fails, fall through to the error handler below
370
+ }
371
+ if (refreshedToken) {
372
+ accessToken = refreshedToken;
373
+ response = await fetch(
374
+ `${entry.runtimeUrl}/v1/migrations/import-preflight`,
375
+ {
376
+ method: "POST",
377
+ headers: {
378
+ Authorization: `Bearer ${accessToken}`,
379
+ "Content-Type": "application/octet-stream",
380
+ },
381
+ body: new Blob([bundleData]),
382
+ signal: AbortSignal.timeout(120_000),
383
+ },
384
+ );
385
+ }
386
+ }
387
+ } catch (err) {
388
+ if (err instanceof Error && err.name === "TimeoutError") {
389
+ console.error("Error: Preflight request timed out after 2 minutes.");
390
+ process.exit(1);
391
+ }
392
+ const msg = err instanceof Error ? err.message : String(err);
393
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
394
+ console.error(
395
+ `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
396
+ );
397
+ console.error(`Try: vellum wake ${entry.assistantId}`);
398
+ process.exit(1);
399
+ }
400
+ throw err;
401
+ }
402
+
403
+ handleLocalResponseErrors(response, entry.assistantId);
404
+
405
+ const result = (await response.json()) as PreflightResponse;
406
+ printPreflightSummary(result);
407
+ return;
408
+ }
409
+
410
+ // Actual import
411
+ console.log("Importing data...");
412
+
413
+ let response: Response;
414
+ try {
415
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
416
+ method: "POST",
417
+ headers: {
418
+ Authorization: `Bearer ${accessToken}`,
419
+ "Content-Type": "application/octet-stream",
420
+ },
421
+ body: new Blob([bundleData]),
422
+ signal: AbortSignal.timeout(120_000),
423
+ });
424
+
425
+ // Retry once with a fresh token on 401
426
+ if (response.status === 401) {
427
+ let refreshedToken: string | null = null;
428
+ try {
429
+ const freshToken = await leaseGuardianToken(
430
+ entry.runtimeUrl,
431
+ entry.assistantId,
432
+ );
433
+ refreshedToken = freshToken.accessToken;
434
+ } catch {
435
+ // If token refresh fails, fall through to the error handler below
436
+ }
437
+ if (refreshedToken) {
438
+ accessToken = refreshedToken;
439
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
440
+ method: "POST",
441
+ headers: {
442
+ Authorization: `Bearer ${accessToken}`,
443
+ "Content-Type": "application/octet-stream",
444
+ },
445
+ body: new Blob([bundleData]),
446
+ signal: AbortSignal.timeout(120_000),
447
+ });
448
+ }
449
+ }
450
+ } catch (err) {
451
+ if (err instanceof Error && err.name === "TimeoutError") {
452
+ console.error("Error: Import request timed out after 2 minutes.");
453
+ process.exit(1);
454
+ }
455
+ const msg = err instanceof Error ? err.message : String(err);
456
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
457
+ console.error(
458
+ `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
459
+ );
460
+ console.error(`Try: vellum wake ${entry.assistantId}`);
461
+ process.exit(1);
462
+ }
463
+ throw err;
464
+ }
465
+
466
+ handleLocalResponseErrors(response, entry.assistantId);
467
+
468
+ const result = (await response.json()) as ImportResponse;
469
+ printImportSummary(result);
470
+ }
471
+
110
472
  // ---------------------------------------------------------------------------
111
473
  // Export from source assistant
112
474
  // ---------------------------------------------------------------------------
@@ -205,100 +567,12 @@ async function exportFromAssistant(
205
567
  return new Uint8Array(arrayBuffer);
206
568
  }
207
569
 
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);
570
+ if (cloud === "local" || cloud === "docker") {
571
+ return exportViaHttp(entry);
298
572
  }
299
573
 
300
574
  console.error(
301
- "Teleport only supports local and platform assistants as source.",
575
+ "Teleport only supports local, docker, and platform assistants as source.",
302
576
  );
303
577
  process.exit(1);
304
578
  }
@@ -459,94 +733,118 @@ async function importToAssistant(
459
733
  return;
460
734
  }
461
735
 
462
- if (cloud === "local") {
463
- // Local target
464
- const accessToken = await getAccessToken(
465
- entry.runtimeUrl,
466
- entry.assistantId,
467
- entry.assistantId,
468
- );
736
+ if (cloud === "local" || cloud === "docker") {
737
+ await importViaHttp(entry, bundleData, dryRun);
738
+ return;
739
+ }
469
740
 
470
- if (dryRun) {
471
- console.log("Running preflight analysis...\n");
741
+ console.error(
742
+ "Teleport only supports local, docker, and platform assistants as target.",
743
+ );
744
+ process.exit(1);
745
+ }
472
746
 
473
- let response: Response;
474
- 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
- },
747
+ // ---------------------------------------------------------------------------
748
+ // Resolve or hatch target assistant
749
+ // ---------------------------------------------------------------------------
750
+
751
+ export async function resolveOrHatchTarget(
752
+ targetEnv: "local" | "docker" | "platform",
753
+ targetName?: string,
754
+ ): Promise<AssistantEntry> {
755
+ // If a name is provided, try to find an existing assistant
756
+ if (targetName) {
757
+ const existing = findAssistantByName(targetName);
758
+ if (existing) {
759
+ // Validate the existing assistant's cloud matches the requested env
760
+ const existingCloud = resolveCloud(existing);
761
+ const normalizedExisting =
762
+ existingCloud === "vellum" ? "platform" : existingCloud;
763
+ if (normalizedExisting !== targetEnv) {
764
+ console.error(
765
+ `Error: Assistant '${targetName}' is a ${normalizedExisting} assistant, not ${targetEnv}. ` +
766
+ `Use --${normalizedExisting} to target it.`,
486
767
  );
487
- } 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
- const msg = err instanceof Error ? err.message : String(err);
493
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
494
- console.error(
495
- `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
496
- );
497
- console.error(`Try: vellum wake ${entry.assistantId}`);
498
- process.exit(1);
499
- }
500
- throw err;
768
+ process.exit(1);
501
769
  }
502
-
503
- handleLocalResponseErrors(response, entry.assistantId);
504
-
505
- const result = (await response.json()) as PreflightResponse;
506
- printPreflightSummary(result);
507
- return;
770
+ console.log(`Target: ${targetName} (${targetEnv})`);
771
+ return existing;
508
772
  }
509
773
 
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")) {
774
+ // Name not found — will hatch.
775
+ if (targetEnv === "platform") {
776
+ // Platform API doesn't accept custom names — warn and ignore
777
+ console.log(
778
+ `Note: Platform assistants receive a server-assigned ID. The name '${targetName}' will not be used.`,
779
+ );
780
+ } else {
781
+ // Validate the name before passing to hatch
782
+ try {
783
+ validateAssistantName(targetName);
784
+ } catch {
531
785
  console.error(
532
- `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
786
+ "Error: Target name contains invalid characters (path separators or traversal segments are not allowed).",
533
787
  );
534
- console.error(`Try: vellum wake ${entry.assistantId}`);
535
788
  process.exit(1);
536
789
  }
537
- throw err;
538
790
  }
791
+ }
539
792
 
540
- handleLocalResponseErrors(response, entry.assistantId);
793
+ // Hatch a new assistant in the target environment
794
+ if (targetEnv === "local") {
795
+ const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
796
+ await hatchLocal("vellum", targetName ?? null, false, false, false, {});
797
+ const entry = targetName
798
+ ? findAssistantByName(targetName)
799
+ : (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
800
+ null);
801
+ if (!entry) {
802
+ console.error("Error: Could not find the newly hatched local assistant.");
803
+ process.exit(1);
804
+ }
805
+ console.log(`Hatched new local assistant: ${entry.assistantId}`);
806
+ return entry;
807
+ }
541
808
 
542
- const result = (await response.json()) as ImportResponse;
543
- printImportSummary(result);
544
- return;
809
+ if (targetEnv === "docker") {
810
+ const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
811
+ await hatchDocker("vellum", false, targetName ?? null, false, {});
812
+ const entry = targetName
813
+ ? findAssistantByName(targetName)
814
+ : (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
815
+ null);
816
+ if (!entry) {
817
+ console.error(
818
+ "Error: Could not find the newly hatched docker assistant.",
819
+ );
820
+ process.exit(1);
821
+ }
822
+ console.log(`Hatched new docker assistant: ${entry.assistantId}`);
823
+ return entry;
545
824
  }
546
825
 
547
- console.error(
548
- "Teleport only supports local and platform assistants as target.",
549
- );
826
+ if (targetEnv === "platform") {
827
+ const token = readPlatformToken();
828
+ if (!token) {
829
+ console.error("Not logged in. Run 'vellum login' first.");
830
+ process.exit(1);
831
+ }
832
+
833
+ const result = await hatchAssistant(token);
834
+ const entry: AssistantEntry = {
835
+ assistantId: result.id,
836
+ runtimeUrl: getPlatformUrl(),
837
+ cloud: "vellum",
838
+ species: "vellum",
839
+ hatchedAt: new Date().toISOString(),
840
+ };
841
+ saveAssistantEntry(entry);
842
+ setActiveAssistant(result.id);
843
+ console.log(`Hatched new platform assistant: ${result.id}`);
844
+ return entry;
845
+ }
846
+
847
+ console.error(`Error: Unknown target environment '${targetEnv}'.`);
550
848
  process.exit(1);
551
849
  }
552
850
 
@@ -701,19 +999,36 @@ function printImportSummary(result: ImportResponse): void {
701
999
 
702
1000
  export async function teleport(): Promise<void> {
703
1001
  const args = process.argv.slice(3);
704
- const { from, to, dryRun, help } = parseArgs(args);
1002
+ const { from, to, targetEnv, targetName, keepSource, dryRun, help } =
1003
+ parseArgs(args);
705
1004
 
706
1005
  if (help) {
707
1006
  printHelp();
708
1007
  process.exit(0);
709
1008
  }
710
1009
 
711
- if (!from || !to) {
1010
+ // Legacy --to flag deprecation
1011
+ if (to) {
1012
+ console.error("Error: --to is deprecated. Use environment flags instead:");
1013
+ console.error(
1014
+ " vellum teleport --from <source> --local|--docker|--platform [name]",
1015
+ );
1016
+ console.error("");
1017
+ console.error("Run 'vellum teleport --help' for details.");
1018
+ process.exit(1);
1019
+ }
1020
+
1021
+ if (!from) {
1022
+ printHelp();
1023
+ process.exit(1);
1024
+ }
1025
+
1026
+ if (!targetEnv) {
712
1027
  printHelp();
713
1028
  process.exit(1);
714
1029
  }
715
1030
 
716
- // Look up both assistants
1031
+ // Look up source assistant
717
1032
  const fromEntry = findAssistantByName(from);
718
1033
  if (!fromEntry) {
719
1034
  console.error(
@@ -722,25 +1037,118 @@ export async function teleport(): Promise<void> {
722
1037
  process.exit(1);
723
1038
  }
724
1039
 
725
- const toEntry = findAssistantByName(to);
726
- if (!toEntry) {
1040
+ const fromCloud = resolveCloud(fromEntry);
1041
+
1042
+ // Early same-environment guard — compare source cloud against the CLI flag
1043
+ // BEFORE exporting or hatching, to avoid creating orphaned assistants.
1044
+ const normalizedSourceEnv = fromCloud === "vellum" ? "platform" : fromCloud;
1045
+ if (normalizedSourceEnv === targetEnv) {
727
1046
  console.error(
728
- `Assistant '${to}' not found in lockfile. Run \`vellum ps\` to see available assistants.`,
1047
+ `Cannot teleport between two ${targetEnv} assistants. Teleport transfers data across different environments.`,
729
1048
  );
730
1049
  process.exit(1);
731
1050
  }
732
1051
 
733
- const fromCloud = resolveCloud(fromEntry);
734
- const toCloud = resolveCloud(toEntry);
1052
+ // Dry-run without an existing target: skip export, hatch, and import —
1053
+ // just report what would happen.
1054
+ if (dryRun) {
1055
+ const existingTarget = targetName ? findAssistantByName(targetName) : null;
1056
+
1057
+ if (existingTarget) {
1058
+ // Target exists — validate cloud matches the flag, then run preflight
1059
+ const toCloud = resolveCloud(existingTarget);
1060
+ const normalizedTargetEnv = toCloud === "vellum" ? "platform" : toCloud;
1061
+ if (normalizedTargetEnv !== targetEnv) {
1062
+ console.error(
1063
+ `Error: Assistant '${targetName}' is a ${normalizedTargetEnv} assistant, not ${targetEnv}. ` +
1064
+ `Use --${normalizedTargetEnv} to target it.`,
1065
+ );
1066
+ process.exit(1);
1067
+ }
1068
+ if (normalizedSourceEnv === normalizedTargetEnv) {
1069
+ console.error(
1070
+ `Cannot teleport between two ${normalizedTargetEnv} assistants. Teleport transfers data across different environments.`,
1071
+ );
1072
+ process.exit(1);
1073
+ }
1074
+
1075
+ console.log(`Exporting from ${from} (${fromCloud})...`);
1076
+ const bundleData = await exportFromAssistant(fromEntry, fromCloud);
1077
+ console.log(`Importing to ${existingTarget.assistantId} (${toCloud})...`);
1078
+ await importToAssistant(existingTarget, toCloud, bundleData, true);
1079
+ } else {
1080
+ // No existing target — just describe what would happen
1081
+ console.log("Dry run summary:");
1082
+ console.log(` Would export data from: ${from} (${fromCloud})`);
1083
+ console.log(
1084
+ ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1085
+ );
1086
+ console.log(` Would import data into the new assistant`);
1087
+ }
1088
+
1089
+ console.log(`Dry run complete — no changes were made.`);
1090
+ return;
1091
+ }
735
1092
 
736
1093
  // Export from source
737
1094
  console.log(`Exporting from ${from} (${fromCloud})...`);
738
1095
  const bundleData = await exportFromAssistant(fromEntry, fromCloud);
739
1096
 
1097
+ // For local<->docker transfers, stop (sleep) the source to free up ports
1098
+ // before hatching the target. We do NOT retire yet — if hatch or import
1099
+ // fails, the user can recover by running `vellum wake <source>`.
1100
+ const sourceIsLocalOrDocker = fromCloud === "local" || fromCloud === "docker";
1101
+ const targetIsLocalOrDocker = targetEnv === "local" || targetEnv === "docker";
1102
+ if (sourceIsLocalOrDocker && targetIsLocalOrDocker && !keepSource) {
1103
+ console.log(`Stopping source assistant '${from}' to free ports...`);
1104
+ if (fromCloud === "docker") {
1105
+ const res = dockerResourceNames(fromEntry.assistantId);
1106
+ await sleepContainers(res);
1107
+ } else if (fromEntry.resources) {
1108
+ const pidFile = fromEntry.resources.pidFile;
1109
+ const vellumDir = join(fromEntry.resources.instanceDir, ".vellum");
1110
+ const gatewayPidFile = join(vellumDir, "gateway.pid");
1111
+ await stopProcessByPidFile(pidFile, "assistant");
1112
+ await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1113
+ }
1114
+ console.log(`Source assistant '${from}' stopped.`);
1115
+ }
1116
+
1117
+ // Resolve or hatch target (after source is stopped to avoid port conflicts)
1118
+ const toEntry = await resolveOrHatchTarget(targetEnv, targetName);
1119
+ const toCloud = resolveCloud(toEntry);
1120
+
1121
+ // Post-hatch same-environment safety net — uses resolved clouds in case
1122
+ // the resolved target cloud differs from the CLI flag (e.g., --docker
1123
+ // targeting a name that is actually a local entry).
1124
+ const normalizedTargetEnv = toCloud === "vellum" ? "platform" : toCloud;
1125
+ if (normalizedSourceEnv === normalizedTargetEnv) {
1126
+ console.error(
1127
+ `Cannot teleport between two ${normalizedTargetEnv} assistants. Teleport transfers data across different environments.`,
1128
+ );
1129
+ process.exit(1);
1130
+ }
1131
+
740
1132
  // Import to target
741
- console.log(`Importing to ${to} (${toCloud})...`);
742
- await importToAssistant(toEntry, toCloud, bundleData, dryRun);
1133
+ console.log(`Importing to ${toEntry.assistantId} (${toCloud})...`);
1134
+ await importToAssistant(toEntry, toCloud, bundleData, false);
1135
+
1136
+ // Retire source after successful import
1137
+ if (sourceIsLocalOrDocker && targetIsLocalOrDocker) {
1138
+ if (!keepSource) {
1139
+ console.log(`Retiring source assistant '${from}'...`);
1140
+ if (fromCloud === "docker") {
1141
+ await retireDocker(fromEntry.assistantId);
1142
+ } else {
1143
+ await retireLocal(fromEntry.assistantId, fromEntry);
1144
+ }
1145
+ removeAssistantEntry(fromEntry.assistantId);
1146
+ console.log(`Source assistant '${from}' retired.`);
1147
+ } else {
1148
+ console.log(`Source assistant '${from}' kept (--keep-source).`);
1149
+ }
1150
+ }
743
1151
 
744
1152
  // Success summary
745
- console.log(`Teleport complete: ${from} → ${to}`);
1153
+ console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
746
1154
  }