@vellumai/cli 0.6.6 → 0.7.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.
Files changed (45) hide show
  1. package/AGENTS.md +8 -2
  2. package/package.json +1 -1
  3. package/src/__tests__/assistant-config.test.ts +1 -7
  4. package/src/__tests__/config-utils.test.ts +159 -0
  5. package/src/__tests__/env-drift.test.ts +10 -32
  6. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  7. package/src/__tests__/multi-local.test.ts +0 -5
  8. package/src/__tests__/sleep.test.ts +1 -2
  9. package/src/__tests__/teleport.test.ts +919 -1255
  10. package/src/commands/env.ts +93 -0
  11. package/src/commands/events.ts +2 -0
  12. package/src/commands/exec.ts +40 -8
  13. package/src/commands/hatch.ts +6 -2
  14. package/src/commands/login.ts +89 -6
  15. package/src/commands/ps.ts +104 -20
  16. package/src/commands/sleep.ts +5 -2
  17. package/src/commands/ssh.ts +15 -2
  18. package/src/commands/teleport.ts +447 -583
  19. package/src/commands/terminal.ts +9 -221
  20. package/src/commands/wake.ts +2 -1
  21. package/src/components/DefaultMainScreen.tsx +304 -152
  22. package/src/index.ts +3 -0
  23. package/src/lib/__tests__/docker.test.ts +50 -74
  24. package/src/lib/__tests__/job-polling.test.ts +278 -0
  25. package/src/lib/__tests__/local-runtime-client.test.ts +383 -0
  26. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  27. package/src/lib/assistant-config.ts +12 -8
  28. package/src/lib/client-identity.ts +67 -0
  29. package/src/lib/config-utils.ts +97 -1
  30. package/src/lib/docker.ts +73 -75
  31. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  32. package/src/lib/environments/resolve.ts +89 -7
  33. package/src/lib/environments/seeds.ts +8 -5
  34. package/src/lib/environments/types.ts +10 -0
  35. package/src/lib/hatch-local.ts +15 -120
  36. package/src/lib/health-check.ts +98 -0
  37. package/src/lib/job-polling.ts +195 -0
  38. package/src/lib/local-runtime-client.ts +178 -0
  39. package/src/lib/local.ts +139 -15
  40. package/src/lib/orphan-detection.ts +2 -35
  41. package/src/lib/platform-client.ts +215 -0
  42. package/src/lib/retire-local.ts +6 -2
  43. package/src/lib/terminal-session.ts +457 -0
  44. package/src/shared/provider-env-vars.ts +2 -3
  45. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  findAssistantByName,
3
3
  loadAllAssistants,
4
+ getDaemonPidPath,
4
5
  removeAssistantEntry,
5
6
  saveAssistantEntry,
6
7
  setActiveAssistant,
@@ -17,15 +18,10 @@ import {
17
18
  hatchAssistant,
18
19
  checkExistingPlatformAssistant,
19
20
  platformInitiateExport,
20
- platformPollExportStatus,
21
- platformDownloadExport,
22
- platformImportPreflight,
23
- platformImportBundle,
24
- platformRequestUploadUrl,
25
- platformUploadToSignedUrl,
26
- platformImportPreflightFromGcs,
21
+ platformPollJobStatus,
27
22
  platformImportBundleFromGcs,
28
- platformPollImportStatus,
23
+ platformImportPreflightFromGcs,
24
+ platformRequestSignedUrl,
29
25
  ensureSelfHostedLocalRegistration,
30
26
  readGatewayCredential,
31
27
  reprovisionAssistantApiKey,
@@ -33,6 +29,13 @@ import {
33
29
  fetchCurrentUser,
34
30
  fetchOrganizationId,
35
31
  } from "../lib/platform-client.js";
32
+ import {
33
+ localRuntimeExportToGcs,
34
+ localRuntimeImportFromGcs,
35
+ localRuntimePollJobStatus,
36
+ MigrationInProgressError,
37
+ } from "../lib/local-runtime-client.js";
38
+ import { pollJobUntilDone } from "../lib/job-polling.js";
36
39
  import {
37
40
  hatchDocker,
38
41
  retireDocker,
@@ -221,11 +224,17 @@ async function getAccessToken(
221
224
  runtimeUrl: string,
222
225
  assistantId: string,
223
226
  displayName: string,
227
+ options?: { forceRefresh?: boolean },
224
228
  ): Promise<string> {
225
- const tokenData = loadGuardianToken(assistantId);
226
-
227
- if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
228
- return tokenData.accessToken;
229
+ // When forceRefresh is set (e.g. after a runtime 401 on the cached token)
230
+ // we skip the cache and lease a brand-new token from the gateway, so a
231
+ // stale-but-unexpired token can't keep failing on every retry.
232
+ if (!options?.forceRefresh) {
233
+ const tokenData = loadGuardianToken(assistantId);
234
+
235
+ if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
236
+ return tokenData.accessToken;
237
+ }
229
238
  }
230
239
 
231
240
  try {
@@ -244,336 +253,49 @@ async function getAccessToken(
244
253
  }
245
254
  }
246
255
 
247
- // ---------------------------------------------------------------------------
248
- // HTTP-based export/import helpers (shared by local and docker)
249
- // ---------------------------------------------------------------------------
250
-
251
- async function exportViaHttp(
252
- entry: AssistantEntry,
253
- ): Promise<Uint8Array<ArrayBuffer>> {
254
- let accessToken = await getAccessToken(
255
- entry.runtimeUrl,
256
- entry.assistantId,
257
- entry.assistantId,
258
- );
259
-
260
- let response: Response;
261
- try {
262
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
263
- method: "POST",
264
- headers: {
265
- Authorization: `Bearer ${accessToken}`,
266
- "Content-Type": "application/json",
267
- },
268
- body: JSON.stringify({ description: "teleport export" }),
269
- signal: AbortSignal.timeout(120_000),
270
- });
271
-
272
- // Retry once with a fresh token on 401
273
- if (response.status === 401) {
274
- let refreshedToken: string | null = null;
275
- try {
276
- const freshToken = await leaseGuardianToken(
277
- entry.runtimeUrl,
278
- entry.assistantId,
279
- );
280
- refreshedToken = freshToken.accessToken;
281
- } catch {
282
- // If token refresh fails, fall through to the error handler below
283
- }
284
- if (refreshedToken) {
285
- accessToken = refreshedToken;
286
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
287
- method: "POST",
288
- headers: {
289
- Authorization: `Bearer ${accessToken}`,
290
- "Content-Type": "application/json",
291
- },
292
- body: JSON.stringify({ description: "teleport export" }),
293
- signal: AbortSignal.timeout(120_000),
294
- });
295
- }
296
- }
297
- } catch (err) {
298
- if (err instanceof Error && err.name === "TimeoutError") {
299
- console.error("Error: Export request timed out after 2 minutes.");
300
- process.exit(1);
301
- }
302
- const msg = err instanceof Error ? err.message : String(err);
303
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
304
- console.error(
305
- `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
306
- );
307
- console.error(`Try: vellum wake ${entry.assistantId}`);
308
- process.exit(1);
309
- }
310
- throw err;
311
- }
312
-
313
- if (response.status === 401 || response.status === 403) {
314
- console.error("Authentication failed.");
315
- process.exit(1);
316
- }
317
-
318
- if (response.status === 404) {
319
- console.error("Assistant not found or not running.");
320
- process.exit(1);
321
- }
322
-
323
- if (
324
- response.status === 502 ||
325
- response.status === 503 ||
326
- response.status === 504
327
- ) {
328
- console.error(
329
- `Assistant is unreachable. Try 'vellum wake ${entry.assistantId}'.`,
330
- );
331
- process.exit(1);
332
- }
333
-
334
- if (!response.ok) {
335
- const body = await response.text();
336
- console.error(`Error: Export failed (${response.status}): ${body}`);
337
- process.exit(1);
338
- }
339
-
340
- const arrayBuffer = await response.arrayBuffer();
341
- return new Uint8Array(arrayBuffer);
256
+ /**
257
+ * Detect a 401 Unauthorized raised by `localRuntimeExportToGcs` /
258
+ * `localRuntimeImportFromGcs`. Both throw Error with a message of the form
259
+ * `"Local runtime <op> failed (401): ..."` when the gateway rejects the
260
+ * cached guardian token.
261
+ */
262
+ function isRuntime401(err: unknown): boolean {
263
+ const msg = err instanceof Error ? err.message : String(err);
264
+ return /Local runtime [^(]*failed \(401\)/.test(msg);
342
265
  }
343
266
 
344
- async function importViaHttp(
345
- entry: AssistantEntry,
346
- bundleData: Uint8Array<ArrayBuffer>,
347
- dryRun: boolean,
348
- ): Promise<void> {
349
- let accessToken = await getAccessToken(
350
- entry.runtimeUrl,
351
- entry.assistantId,
352
- entry.assistantId,
353
- );
354
-
355
- if (dryRun) {
356
- console.log("Running preflight analysis...\n");
357
-
358
- let response: Response;
359
- try {
360
- response = await fetch(
361
- `${entry.runtimeUrl}/v1/migrations/import-preflight`,
362
- {
363
- method: "POST",
364
- headers: {
365
- Authorization: `Bearer ${accessToken}`,
366
- "Content-Type": "application/octet-stream",
367
- },
368
- body: new Blob([bundleData]),
369
- signal: AbortSignal.timeout(120_000),
370
- },
371
- );
372
-
373
- // Retry once with a fresh token on 401
374
- if (response.status === 401) {
375
- let refreshedToken: string | null = null;
376
- try {
377
- const freshToken = await leaseGuardianToken(
378
- entry.runtimeUrl,
379
- entry.assistantId,
380
- );
381
- refreshedToken = freshToken.accessToken;
382
- } catch {
383
- // If token refresh fails, fall through to the error handler below
384
- }
385
- if (refreshedToken) {
386
- accessToken = refreshedToken;
387
- response = await fetch(
388
- `${entry.runtimeUrl}/v1/migrations/import-preflight`,
389
- {
390
- method: "POST",
391
- headers: {
392
- Authorization: `Bearer ${accessToken}`,
393
- "Content-Type": "application/octet-stream",
394
- },
395
- body: new Blob([bundleData]),
396
- signal: AbortSignal.timeout(120_000),
397
- },
398
- );
399
- }
400
- }
401
- } catch (err) {
402
- if (err instanceof Error && err.name === "TimeoutError") {
403
- console.error("Error: Preflight request timed out after 2 minutes.");
404
- process.exit(1);
405
- }
406
- const msg = err instanceof Error ? err.message : String(err);
407
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
408
- console.error(
409
- `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
410
- );
411
- console.error(`Try: vellum wake ${entry.assistantId}`);
412
- process.exit(1);
413
- }
414
- throw err;
415
- }
416
-
417
- handleLocalResponseErrors(response, entry.assistantId);
418
-
419
- const result = (await response.json()) as PreflightResponse;
420
- printPreflightSummary(result);
421
- return;
422
- }
423
-
424
- // Actual import
425
- console.log("Importing data...");
426
-
427
- let response: Response;
267
+ /**
268
+ * Run a runtime kickoff (`localRuntimeExportToGcs` / `localRuntimeImportFromGcs`)
269
+ * with a one-shot refresh-and-retry on 401. Matches the pre-rewrite
270
+ * `exportViaHttp`/`importViaHttp` behavior: if the cached guardian token is
271
+ * stale-but-unexpired and the runtime returns 401, we lease a fresh token
272
+ * and retry once. Any other error — or a repeated 401 on the refreshed token
273
+ * — propagates to the caller.
274
+ */
275
+ async function callRuntimeWithAuthRetry<T>(
276
+ runtimeUrl: string,
277
+ assistantId: string,
278
+ fn: (token: string) => Promise<T>,
279
+ ): Promise<T> {
280
+ const firstToken = await getAccessToken(runtimeUrl, assistantId, assistantId);
428
281
  try {
429
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
430
- method: "POST",
431
- headers: {
432
- Authorization: `Bearer ${accessToken}`,
433
- "Content-Type": "application/octet-stream",
434
- },
435
- body: new Blob([bundleData]),
436
- signal: AbortSignal.timeout(300_000),
437
- });
438
-
439
- // Retry once with a fresh token on 401
440
- if (response.status === 401) {
441
- let refreshedToken: string | null = null;
442
- try {
443
- const freshToken = await leaseGuardianToken(
444
- entry.runtimeUrl,
445
- entry.assistantId,
446
- );
447
- refreshedToken = freshToken.accessToken;
448
- } catch {
449
- // If token refresh fails, fall through to the error handler below
450
- }
451
- if (refreshedToken) {
452
- accessToken = refreshedToken;
453
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
454
- method: "POST",
455
- headers: {
456
- Authorization: `Bearer ${accessToken}`,
457
- "Content-Type": "application/octet-stream",
458
- },
459
- body: new Blob([bundleData]),
460
- signal: AbortSignal.timeout(300_000),
461
- });
462
- }
463
- }
282
+ return await fn(firstToken);
464
283
  } catch (err) {
465
- if (err instanceof Error && err.name === "TimeoutError") {
466
- console.error("Error: Import request timed out after 5 minutes.");
467
- process.exit(1);
468
- }
469
- const msg = err instanceof Error ? err.message : String(err);
470
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
471
- console.error(
472
- `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
473
- );
474
- console.error(`Try: vellum wake ${entry.assistantId}`);
475
- process.exit(1);
476
- }
477
- throw err;
478
- }
479
-
480
- handleLocalResponseErrors(response, entry.assistantId);
481
-
482
- const result = (await response.json()) as ImportResponse;
483
- printImportSummary(result);
484
- }
485
-
486
- // ---------------------------------------------------------------------------
487
- // Export from source assistant
488
- // ---------------------------------------------------------------------------
489
-
490
- async function exportFromAssistant(
491
- entry: AssistantEntry,
492
- cloud: string,
493
- ): Promise<Uint8Array<ArrayBuffer>> {
494
- if (cloud === "vellum") {
495
- // Platform source — use Django async export
496
- const token = readPlatformToken();
497
- if (!token) {
498
- console.error("Not logged in. Run 'vellum login' first.");
499
- process.exit(1);
284
+ if (!isRuntime401(err)) {
285
+ throw err;
500
286
  }
501
-
502
- // Initiate export job
503
- const { jobId } = await platformInitiateExport(
504
- token,
505
- "teleport export",
506
- entry.runtimeUrl,
287
+ const refreshedToken = await getAccessToken(
288
+ runtimeUrl,
289
+ assistantId,
290
+ assistantId,
291
+ { forceRefresh: true },
507
292
  );
508
-
509
- console.log(`Export started (job ${jobId})...`);
510
-
511
- // Poll for completion
512
- const POLL_INTERVAL_MS = 2_000;
513
- const TIMEOUT_MS = 5 * 60 * 1_000;
514
- const deadline = Date.now() + TIMEOUT_MS;
515
- let downloadUrl: string | undefined;
516
-
517
- while (Date.now() < deadline) {
518
- let status: { status: string; downloadUrl?: string; error?: string };
519
- try {
520
- status = await platformPollExportStatus(jobId, token, entry.runtimeUrl);
521
- } catch (err) {
522
- const msg = err instanceof Error ? err.message : String(err);
523
- if (msg.includes("not found")) {
524
- throw err;
525
- }
526
- // Re-throw permanent 4xx errors (auth, forbidden, etc.)
527
- // but retry transient 5xx errors
528
- const statusMatch = msg.match(/status check failed: (\d+)/);
529
- if (statusMatch) {
530
- const statusCode = parseInt(statusMatch[1], 10);
531
- if (statusCode >= 400 && statusCode < 500) {
532
- throw err;
533
- }
534
- }
535
- // Transient error — retry
536
- console.warn(`Polling failed, retrying... (${msg})`);
537
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
538
- continue;
539
- }
540
-
541
- if (status.status === "complete") {
542
- downloadUrl = status.downloadUrl;
543
- break;
544
- }
545
-
546
- if (status.status === "failed") {
547
- console.error(`Export failed: ${status.error ?? "unknown error"}`);
548
- process.exit(1);
549
- }
550
-
551
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
552
- }
553
-
554
- if (!downloadUrl) {
555
- console.error("Export timed out after 5 minutes.");
556
- process.exit(1);
557
- }
558
-
559
- // Download the bundle
560
- const response = await platformDownloadExport(downloadUrl);
561
- const arrayBuffer = await response.arrayBuffer();
562
- return new Uint8Array(arrayBuffer);
293
+ return await fn(refreshedToken);
563
294
  }
564
-
565
- if (cloud === "local" || cloud === "docker") {
566
- return exportViaHttp(entry);
567
- }
568
-
569
- console.error(
570
- "Teleport only supports local, docker, and platform assistants as source.",
571
- );
572
- process.exit(1);
573
295
  }
574
296
 
575
297
  // ---------------------------------------------------------------------------
576
- // Import into target assistant
298
+ // Summary response shapes (reused by the GCS job result payload)
577
299
  // ---------------------------------------------------------------------------
578
300
 
579
301
  interface PreflightFileEntry {
@@ -625,86 +347,198 @@ interface ImportResponse {
625
347
  };
626
348
  }
627
349
 
628
- async function importToAssistant(
350
+ // ---------------------------------------------------------------------------
351
+ // Export from source — unified GCS flow
352
+ //
353
+ // Every source (local, docker, platform) produces a `bundleKey` referring to
354
+ // a bundle sitting in GCS. The CLI never holds the bundle bytes.
355
+ // ---------------------------------------------------------------------------
356
+
357
+ async function exportFromAssistant(
629
358
  entry: AssistantEntry,
630
359
  cloud: string,
631
- bundleData: Uint8Array<ArrayBuffer>,
632
- dryRun: boolean,
633
- preUploadedBundleKey?: string | null,
634
- ): Promise<void> {
635
- if (cloud === "vellum") {
636
- // Platform target
637
- const token = readPlatformToken();
638
- if (!token) {
639
- console.error("Not logged in. Run 'vellum login' first.");
640
- process.exit(1);
360
+ bundlePlatformUrl?: string,
361
+ ): Promise<{ bundleKey: string }> {
362
+ const platformToken = readPlatformToken();
363
+ if (!platformToken) {
364
+ console.error(
365
+ "Not logged in. Run 'vellum login' first (required for GCS-based teleport).",
366
+ );
367
+ process.exit(1);
368
+ }
369
+
370
+ if (cloud === "local" || cloud === "docker") {
371
+ // Request a signed upload URL from the platform instance that will
372
+ // eventually own the bundle (i.e. the one the importer will read from).
373
+ // Passing the target's runtime URL here keeps upload and download on
374
+ // the same platform — otherwise a non-default/stale platform URL would
375
+ // cause the import to look at an empty object.
376
+ const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
377
+ { operation: "upload" },
378
+ platformToken,
379
+ bundlePlatformUrl,
380
+ );
381
+
382
+ // Wrap the kickoff in a one-shot refresh-and-retry helper so a stale-but-
383
+ // unexpired cached guardian token surfaces as 401 → re-lease → retry
384
+ // rather than a terminal failure. `accessToken` below is whichever token
385
+ // succeeded on the kickoff; we reuse it for polling so the runtime sees a
386
+ // consistent credential throughout the migration.
387
+ let jobId: string;
388
+ let accessToken: string;
389
+ try {
390
+ const result = await callRuntimeWithAuthRetry(
391
+ entry.runtimeUrl,
392
+ entry.assistantId,
393
+ async (token) => {
394
+ const r = await localRuntimeExportToGcs(entry.runtimeUrl, token, {
395
+ uploadUrl,
396
+ description: "teleport export",
397
+ });
398
+ return { jobId: r.jobId, token };
399
+ },
400
+ );
401
+ jobId = result.jobId;
402
+ accessToken = result.token;
403
+ } catch (err) {
404
+ if (err instanceof MigrationInProgressError) {
405
+ // Fail fast — the existing job is writing to a different GCS object
406
+ // (its caller's signed URL, not ours), so polling it would leave us
407
+ // pointing at an empty/unrelated bundle. Surface the existing job id
408
+ // so the user can decide whether to wait or investigate.
409
+ console.error(
410
+ `Error: Another teleport export is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish or check its status, then re-run.`,
411
+ );
412
+ process.exit(1);
413
+ }
414
+ throw err;
641
415
  }
642
416
 
643
- // Use pre-uploaded bundle key if provided (string), skip upload if null
644
- // (signals signed URLs were already tried and unavailable), or try
645
- // signed-URL upload if undefined (never attempted).
646
- let bundleKey: string | undefined =
647
- preUploadedBundleKey === null ? undefined : preUploadedBundleKey;
648
- if (preUploadedBundleKey === undefined) {
649
- try {
650
- const { uploadUrl, bundleKey: key } = await platformRequestUploadUrl(
651
- token,
417
+ console.log(`Export started (job ${jobId})...`);
418
+
419
+ const terminal = await pollJobUntilDone({
420
+ label: "local-runtime export",
421
+ poll: () =>
422
+ localRuntimePollJobStatus(entry.runtimeUrl, accessToken, jobId),
423
+ // Large exports can take longer than a guardian-token lease. If the
424
+ // runtime returns 401 mid-poll, re-lease a fresh token and rebind the
425
+ // closure variable so the next poll uses it.
426
+ refreshOn401: async () => {
427
+ accessToken = await getAccessToken(
652
428
  entry.runtimeUrl,
429
+ entry.assistantId,
430
+ entry.assistantId,
431
+ { forceRefresh: true },
653
432
  );
654
- bundleKey = key;
655
- console.log("Uploading bundle...");
656
- await platformUploadToSignedUrl(uploadUrl, bundleData);
657
- } catch (err) {
658
- // If signed uploads unavailable (503), fall back to inline upload
659
- const msg = err instanceof Error ? err.message : String(err);
660
- if (msg.includes("not available")) {
661
- bundleKey = undefined;
662
- } else {
663
- throw err;
433
+ },
434
+ });
435
+
436
+ if (terminal.status === "failed") {
437
+ console.error(`Export failed: ${terminal.error}`);
438
+ process.exit(1);
439
+ }
440
+
441
+ return { bundleKey };
442
+ }
443
+
444
+ if (cloud === "vellum") {
445
+ // Platform source — initiate a server-side export. The platform writes
446
+ // the bundle to its own `exports/<org>/<id>.vbundle` key; we discover
447
+ // that key via the unified job-status endpoint's `bundle_key` field.
448
+ const { jobId } = await platformInitiateExport(
449
+ platformToken,
450
+ "teleport export",
451
+ entry.runtimeUrl,
452
+ );
453
+
454
+ console.log(`Export started (job ${jobId})...`);
455
+
456
+ let exportPlatformToken = platformToken;
457
+ const terminal = await pollJobUntilDone({
458
+ label: "platform export",
459
+ poll: () =>
460
+ platformPollJobStatus(jobId, exportPlatformToken, entry.runtimeUrl),
461
+ // The platform token is normally static per-process, but re-reading the
462
+ // on-disk credential covers the case where the user ran `vellum login`
463
+ // in another terminal during a long migration. A persistent 401 after
464
+ // a re-read surfaces to the caller with a clear next step.
465
+ refreshOn401: async () => {
466
+ const refreshed = readPlatformToken();
467
+ if (!refreshed) {
468
+ throw new Error(
469
+ "Platform auth expired during export and no credential was found on disk. Run 'vellum login' and retry.",
470
+ );
664
471
  }
665
- }
472
+ exportPlatformToken = refreshed;
473
+ },
474
+ });
475
+
476
+ if (terminal.status === "failed") {
477
+ console.error(`Export failed: ${terminal.error}`);
478
+ process.exit(1);
666
479
  }
667
480
 
481
+ if (!terminal.bundleKey) {
482
+ console.error(
483
+ "Export completed but the platform did not return a bundle_key. Is the platform up to date?",
484
+ );
485
+ process.exit(1);
486
+ }
487
+
488
+ return { bundleKey: terminal.bundleKey };
489
+ }
490
+
491
+ console.error(
492
+ "Teleport only supports local, docker, and platform assistants as source.",
493
+ );
494
+ process.exit(1);
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Import into target — unified GCS flow
499
+ // ---------------------------------------------------------------------------
500
+
501
+ async function importToAssistant(
502
+ entry: AssistantEntry,
503
+ cloud: string,
504
+ bundleKey: string,
505
+ dryRun: boolean,
506
+ bundlePlatformUrl?: string,
507
+ ): Promise<void> {
508
+ const platformToken = readPlatformToken();
509
+ if (!platformToken) {
510
+ console.error(
511
+ "Not logged in. Run 'vellum login' first (required for GCS-based teleport).",
512
+ );
513
+ process.exit(1);
514
+ }
515
+
516
+ if (cloud === "vellum") {
517
+ // Platform target — the bundle is already in GCS; kick off preflight or
518
+ // async import via the unified job-status endpoint.
668
519
  if (dryRun) {
669
520
  console.log("Running preflight analysis...\n");
670
521
 
671
- let preflightResult: {
672
- statusCode: number;
673
- body: Record<string, unknown>;
674
- };
675
- try {
676
- preflightResult = bundleKey
677
- ? await platformImportPreflightFromGcs(
678
- bundleKey,
679
- token,
680
- entry.runtimeUrl,
681
- )
682
- : await platformImportPreflight(bundleData, token, entry.runtimeUrl);
683
- } catch (err) {
684
- if (err instanceof Error && err.name === "TimeoutError") {
685
- console.error("Error: Preflight request timed out after 2 minutes.");
686
- process.exit(1);
687
- }
688
- throw err;
689
- }
522
+ const preflight = await platformImportPreflightFromGcs(
523
+ bundleKey,
524
+ platformToken,
525
+ entry.runtimeUrl,
526
+ );
690
527
 
691
- if (
692
- preflightResult.statusCode === 401 ||
693
- preflightResult.statusCode === 403
694
- ) {
528
+ if (preflight.statusCode === 401 || preflight.statusCode === 403) {
695
529
  console.error("Authentication failed. Run 'vellum login' to refresh.");
696
530
  process.exit(1);
697
531
  }
698
532
 
699
- if (preflightResult.statusCode === 404) {
533
+ if (preflight.statusCode === 404) {
700
534
  console.error("Assistant not found or not running.");
701
535
  process.exit(1);
702
536
  }
703
537
 
704
538
  if (
705
- preflightResult.statusCode === 502 ||
706
- preflightResult.statusCode === 503 ||
707
- preflightResult.statusCode === 504
539
+ preflight.statusCode === 502 ||
540
+ preflight.statusCode === 503 ||
541
+ preflight.statusCode === 504
708
542
  ) {
709
543
  console.error(
710
544
  `Assistant is unreachable. Try 'vellum wake ${entry.assistantId}'.`,
@@ -712,35 +546,53 @@ async function importToAssistant(
712
546
  process.exit(1);
713
547
  }
714
548
 
715
- if (preflightResult.statusCode !== 200) {
549
+ if (preflight.statusCode !== 200) {
716
550
  console.error(
717
- `Error: Preflight check failed (${preflightResult.statusCode}): ${JSON.stringify(preflightResult.body)}`,
551
+ `Error: Preflight check failed (${preflight.statusCode}): ${JSON.stringify(preflight.body)}`,
718
552
  );
719
553
  process.exit(1);
720
554
  }
721
555
 
722
- const result = preflightResult.body as unknown as PreflightResponse;
556
+ const result = preflight.body as unknown as PreflightResponse;
723
557
  printPreflightSummary(result);
724
558
  return;
725
559
  }
726
560
 
727
- // Actual import
728
561
  console.log("Importing data...");
729
562
 
730
- let importResult: { statusCode: number; body: Record<string, unknown> };
731
- try {
732
- importResult = bundleKey
733
- ? await platformImportBundleFromGcs(bundleKey, token, entry.runtimeUrl)
734
- : await platformImportBundle(bundleData, token, entry.runtimeUrl);
735
- } catch (err) {
736
- if (err instanceof Error && err.name === "TimeoutError") {
737
- console.error("Error: Import request timed out.");
738
- process.exit(1);
739
- }
740
- throw err;
563
+ const importResult = await platformImportBundleFromGcs(
564
+ bundleKey,
565
+ platformToken,
566
+ entry.runtimeUrl,
567
+ );
568
+
569
+ if (importResult.statusCode === 401 || importResult.statusCode === 403) {
570
+ console.error("Authentication failed. Run 'vellum login' to refresh.");
571
+ process.exit(1);
572
+ }
573
+
574
+ if (importResult.statusCode === 404) {
575
+ console.error("Assistant not found or not running.");
576
+ process.exit(1);
741
577
  }
742
578
 
743
- handleImportStatusErrors(importResult.statusCode, entry.assistantId);
579
+ if (
580
+ importResult.statusCode === 502 ||
581
+ importResult.statusCode === 503 ||
582
+ importResult.statusCode === 504
583
+ ) {
584
+ console.error(
585
+ `Assistant is unreachable. Try 'vellum wake ${entry.assistantId}'.`,
586
+ );
587
+ process.exit(1);
588
+ }
589
+
590
+ if (importResult.statusCode !== 202 && importResult.statusCode !== 200) {
591
+ console.error(`Error: Import failed (${importResult.statusCode})`);
592
+ process.exit(1);
593
+ }
594
+
595
+ let finalBody: Record<string, unknown> = importResult.body;
744
596
 
745
597
  if (importResult.statusCode === 202) {
746
598
  const jobId = (importResult.body as { job_id?: string }).job_id;
@@ -749,74 +601,107 @@ async function importToAssistant(
749
601
  process.exit(1);
750
602
  }
751
603
 
752
- const POLL_INTERVAL_MS = 5_000;
753
- const TIMEOUT_MS = 10 * 60 * 1_000; // 10 minutes (platform staleness is 930s)
754
- const startTime = Date.now();
755
- const deadline = startTime + TIMEOUT_MS;
756
-
757
- while (Date.now() < deadline) {
758
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
759
-
760
- let status: {
761
- status: string;
762
- result?: Record<string, unknown>;
763
- error?: string;
764
- };
765
- try {
766
- status = await platformPollImportStatus(
767
- jobId,
768
- token,
769
- entry.runtimeUrl,
770
- );
771
- } catch (err) {
772
- const msg = err instanceof Error ? err.message : String(err);
773
- if (msg.includes("not found")) {
774
- throw err;
775
- }
776
- // Re-throw permanent 4xx errors (auth, forbidden, etc.)
777
- // but retry transient 5xx errors
778
- const statusMatch = msg.match(/status check failed: (\d+)/);
779
- if (statusMatch) {
780
- const statusCode = parseInt(statusMatch[1], 10);
781
- if (statusCode >= 400 && statusCode < 500) {
782
- throw err;
783
- }
604
+ let importPlatformToken = platformToken;
605
+ const terminal = await pollJobUntilDone({
606
+ label: "platform import",
607
+ poll: () =>
608
+ platformPollJobStatus(jobId, importPlatformToken, entry.runtimeUrl),
609
+ refreshOn401: async () => {
610
+ const refreshed = readPlatformToken();
611
+ if (!refreshed) {
612
+ throw new Error(
613
+ "Platform auth expired during import and no credential was found on disk. Run 'vellum login' and retry.",
614
+ );
784
615
  }
785
- // Transient error — retry
786
- console.warn(`Polling failed, retrying... (${msg})`);
787
- continue;
788
- }
789
-
790
- if (status.status === "complete") {
791
- importResult = { statusCode: 200, body: status.result ?? {} };
792
- break;
793
- }
794
-
795
- if (status.status === "failed") {
796
- console.error(`Import failed: ${status.error ?? "unknown error"}`);
797
- process.exit(1);
798
- }
799
-
800
- const elapsed = Math.round((Date.now() - startTime) / 1000);
801
- process.stdout.write(`\rImporting... ${elapsed}s elapsed`);
802
- }
803
-
804
- // Clear the progress line
805
- process.stdout.write("\r" + " ".repeat(40) + "\r");
616
+ importPlatformToken = refreshed;
617
+ },
618
+ });
806
619
 
807
- if (importResult.statusCode === 202) {
808
- console.error("Import timed out after 10 minutes.");
620
+ if (terminal.status === "failed") {
621
+ console.error(`Import failed: ${terminal.error}`);
809
622
  process.exit(1);
810
623
  }
624
+
625
+ finalBody = (terminal.result as Record<string, unknown>) ?? {};
811
626
  }
812
627
 
813
- const result = importResult.body as unknown as ImportResponse;
628
+ const result = finalBody as unknown as ImportResponse;
814
629
  printImportSummary(result);
815
630
  return;
816
631
  }
817
632
 
818
633
  if (cloud === "local" || cloud === "docker") {
819
- await importViaHttp(entry, bundleData, dryRun);
634
+ if (dryRun) {
635
+ // TODO(cli): support dry-run against local targets
636
+ console.error(
637
+ "Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
638
+ );
639
+ process.exit(1);
640
+ }
641
+
642
+ // Ask the platform for a signed download URL and hand it to the local
643
+ // runtime. The runtime streams the bundle straight out of GCS — the CLI
644
+ // never touches the bytes. The URL must target the same platform the
645
+ // bundle was uploaded to; otherwise the object won't exist on this
646
+ // platform's GCS bucket.
647
+ const { url: bundleUrl } = await platformRequestSignedUrl(
648
+ { operation: "download", bundleKey },
649
+ platformToken,
650
+ bundlePlatformUrl,
651
+ );
652
+
653
+ console.log("Importing data...");
654
+
655
+ let jobId: string;
656
+ let accessToken: string;
657
+ try {
658
+ const result = await callRuntimeWithAuthRetry(
659
+ entry.runtimeUrl,
660
+ entry.assistantId,
661
+ async (token) => {
662
+ const r = await localRuntimeImportFromGcs(entry.runtimeUrl, token, {
663
+ bundleUrl,
664
+ });
665
+ return { jobId: r.jobId, token };
666
+ },
667
+ );
668
+ jobId = result.jobId;
669
+ accessToken = result.token;
670
+ } catch (err) {
671
+ if (err instanceof MigrationInProgressError) {
672
+ // Fail fast — the existing job is importing someone else's bundle
673
+ // (the original caller's), not ours. Polling it would report success
674
+ // on an import that wasn't the one we just kicked off.
675
+ console.error(
676
+ `Error: Another teleport import is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish or check its status, then re-run.`,
677
+ );
678
+ process.exit(1);
679
+ }
680
+ throw err;
681
+ }
682
+
683
+ const terminal = await pollJobUntilDone({
684
+ label: "local-runtime import",
685
+ poll: () =>
686
+ localRuntimePollJobStatus(entry.runtimeUrl, accessToken, jobId),
687
+ refreshOn401: async () => {
688
+ accessToken = await getAccessToken(
689
+ entry.runtimeUrl,
690
+ entry.assistantId,
691
+ entry.assistantId,
692
+ { forceRefresh: true },
693
+ );
694
+ },
695
+ });
696
+
697
+ if (terminal.status === "failed") {
698
+ console.error(`Import failed: ${terminal.error}`);
699
+ process.exit(1);
700
+ }
701
+
702
+ const result = ((terminal.result as Record<string, unknown>) ??
703
+ {}) as unknown as ImportResponse;
704
+ printImportSummary(result);
820
705
  return;
821
706
  }
822
707
 
@@ -952,68 +837,6 @@ export async function resolveOrHatchTarget(
952
837
  process.exit(1);
953
838
  }
954
839
 
955
- // ---------------------------------------------------------------------------
956
- // Error handling helpers
957
- // ---------------------------------------------------------------------------
958
-
959
- function handleLocalResponseErrors(
960
- response: Response,
961
- assistantName: string,
962
- ): void {
963
- if (response.status === 401 || response.status === 403) {
964
- console.error("Authentication failed.");
965
- process.exit(1);
966
- }
967
-
968
- if (response.status === 404) {
969
- console.error("Assistant not found or not running.");
970
- process.exit(1);
971
- }
972
-
973
- if (
974
- response.status === 502 ||
975
- response.status === 503 ||
976
- response.status === 504
977
- ) {
978
- console.error(
979
- `Assistant is unreachable. Try 'vellum wake ${assistantName}'.`,
980
- );
981
- process.exit(1);
982
- }
983
-
984
- if (!response.ok) {
985
- console.error(`Error: Request failed (${response.status})`);
986
- process.exit(1);
987
- }
988
- }
989
-
990
- function handleImportStatusErrors(
991
- statusCode: number,
992
- assistantName: string,
993
- ): void {
994
- if (statusCode === 401 || statusCode === 403) {
995
- console.error("Authentication failed. Run 'vellum login' to refresh.");
996
- process.exit(1);
997
- }
998
-
999
- if (statusCode === 404) {
1000
- console.error("Assistant not found or not running.");
1001
- process.exit(1);
1002
- }
1003
-
1004
- if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
1005
- console.error(
1006
- `Assistant is unreachable. Try 'vellum wake ${assistantName}'.`,
1007
- );
1008
- process.exit(1);
1009
- }
1010
-
1011
- if (statusCode < 200 || statusCode >= 300) {
1012
- console.error(`Error: Import failed (${statusCode})`);
1013
- process.exit(1);
1014
- }
1015
- }
1016
-
1017
840
  // ---------------------------------------------------------------------------
1018
841
  // Summary printing — matches restore.ts format
1019
842
  // ---------------------------------------------------------------------------
@@ -1270,6 +1093,19 @@ export async function teleport(): Promise<void> {
1270
1093
  process.exit(1);
1271
1094
  }
1272
1095
 
1096
+ // Dry-run feasibility check — reject local/docker targets BEFORE any
1097
+ // export work. The local runtime has no preflight-from-gcs endpoint yet,
1098
+ // so we can't actually run a dry-run against it; burning a GCS upload
1099
+ // just to fail afterwards would be wasteful.
1100
+ // TODO(cli): support dry-run against local targets (needs a
1101
+ // preflight-from-gcs endpoint on the runtime).
1102
+ if (toCloud === "local" || toCloud === "docker") {
1103
+ console.error(
1104
+ "Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
1105
+ );
1106
+ process.exit(1);
1107
+ }
1108
+
1273
1109
  // Version guard: block platform→non-platform when target is behind
1274
1110
  if (fromCloud === "vellum" && toCloud !== "vellum") {
1275
1111
  const [sourceVersion, targetVersion] = await Promise.all([
@@ -1292,41 +1128,49 @@ export async function teleport(): Promise<void> {
1292
1128
  }
1293
1129
  }
1294
1130
 
1131
+ // Pin both upload and download to the same platform instance. For
1132
+ // platform targets the bundle is owned by the target platform; for
1133
+ // platform sources it's owned by the source platform. Only one of
1134
+ // these branches applies at a time (same-env was rejected earlier).
1135
+ const bundlePlatformUrl =
1136
+ toCloud === "vellum"
1137
+ ? existingTarget.runtimeUrl
1138
+ : fromCloud === "vellum"
1139
+ ? fromEntry.runtimeUrl
1140
+ : undefined;
1141
+
1295
1142
  console.log(`Exporting from ${from} (${fromCloud})...`);
1296
- const bundleData = await exportFromAssistant(fromEntry, fromCloud);
1143
+ const { bundleKey } = await exportFromAssistant(
1144
+ fromEntry,
1145
+ fromCloud,
1146
+ bundlePlatformUrl,
1147
+ );
1297
1148
  console.log(`Importing to ${existingTarget.assistantId} (${toCloud})...`);
1298
- await importToAssistant(existingTarget, toCloud, bundleData, true);
1149
+ await importToAssistant(
1150
+ existingTarget,
1151
+ toCloud,
1152
+ bundleKey,
1153
+ true,
1154
+ bundlePlatformUrl,
1155
+ );
1299
1156
  } else {
1300
1157
  // No existing target — just describe what would happen
1301
1158
  console.log("Dry run summary:");
1302
1159
  console.log(` Would export data from: ${from} (${fromCloud})`);
1303
- if (targetEnv === "platform") {
1304
- // For platform targets, reflect the reordered flow
1305
- console.log(` Would upload bundle via signed URL (if available)`);
1306
- console.log(
1307
- ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1308
- );
1309
- console.log(` Would import data into the new assistant`);
1310
- } else {
1311
- console.log(
1312
- ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1313
- );
1314
- console.log(` Would import data into the new assistant`);
1315
- }
1160
+ console.log(` Would upload bundle via signed URL`);
1161
+ console.log(
1162
+ ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1163
+ );
1164
+ console.log(` Would import data into the new assistant`);
1316
1165
  }
1317
1166
 
1318
1167
  console.log(`Dry run complete — no changes were made.`);
1319
1168
  return;
1320
1169
  }
1321
1170
 
1322
- // Export from source
1323
- console.log(`Exporting from ${from} (${fromCloud})...`);
1324
- const bundleData = await exportFromAssistant(fromEntry, fromCloud);
1325
-
1326
1171
  // Platform target: reordered flow — upload to GCS before hatching so that
1327
- // if upload fails, no empty assistant is left dangling on the platform.
1172
+ // if export/upload fails, no empty assistant is left dangling on the platform.
1328
1173
  if (targetEnv === "platform") {
1329
- // Step B — Auth
1330
1174
  const token = readPlatformToken();
1331
1175
  if (!token) {
1332
1176
  console.error("Not logged in. Run 'vellum login' first.");
@@ -1334,7 +1178,7 @@ export async function teleport(): Promise<void> {
1334
1178
  }
1335
1179
 
1336
1180
  // If targeting an existing assistant, validate cloud match early — before
1337
- // uploading — so we don't waste a GCS upload on an invalid command.
1181
+ // exporting — so we don't waste work on an invalid command.
1338
1182
  const existingTarget = targetName ? findAssistantByName(targetName) : null;
1339
1183
  if (existingTarget) {
1340
1184
  const existingCloud = resolveCloud(existingTarget);
@@ -1347,12 +1191,12 @@ export async function teleport(): Promise<void> {
1347
1191
  }
1348
1192
  }
1349
1193
 
1350
- // Use the existing target's runtimeUrl for all platform calls so upload
1351
- // and import hit the same instance.
1194
+ // Use the existing target's runtimeUrl for all platform calls so the
1195
+ // export, upload, and import all hit the same instance.
1352
1196
  const targetPlatformUrl = existingTarget?.runtimeUrl;
1353
1197
 
1354
- // Step B2 — Pre-check: block if the user already has a platform assistant.
1355
- // This runs BEFORE the expensive GCS upload so we don't waste bandwidth.
1198
+ // Pre-check: block if the user already has a platform assistant. This
1199
+ // runs BEFORE the expensive export so we don't waste the upload.
1356
1200
  if (!existingTarget) {
1357
1201
  const existing = await checkExistingPlatformAssistant(
1358
1202
  token,
@@ -1376,43 +1220,40 @@ export async function teleport(): Promise<void> {
1376
1220
  }
1377
1221
  }
1378
1222
 
1379
- // Step C Upload to GCS
1380
- // bundleKey: string = uploaded successfully, null = tried but unavailable,
1381
- // undefined would mean "never tried" (not used here).
1382
- let bundleKey: string | null = null;
1383
- try {
1384
- const { uploadUrl, bundleKey: key } = await platformRequestUploadUrl(
1385
- token,
1386
- targetPlatformUrl,
1387
- );
1388
- bundleKey = key;
1389
- console.log("Uploading bundle to GCS...");
1390
- await platformUploadToSignedUrl(uploadUrl, bundleData);
1391
- } catch (err) {
1392
- // If signed uploads unavailable (503), fall back to inline upload later
1393
- const msg = err instanceof Error ? err.message : String(err);
1394
- if (msg.includes("not available")) {
1395
- bundleKey = null;
1396
- } else {
1397
- throw err;
1398
- }
1399
- }
1223
+ // Exportfor local/docker sources this uploads straight into GCS via
1224
+ // the platform's signed URL; for platform sources this runs a server-side
1225
+ // export and we read the resulting bundle_key.
1226
+ // The signed upload URL must be requested from the same platform instance
1227
+ // where the import will run. For existing targets that's the lockfile's
1228
+ // runtimeUrl; for fresh hatches it's getPlatformUrl() (which is what
1229
+ // resolveOrHatchTarget writes to the new entry).
1230
+ console.log(`Exporting from ${from} (${fromCloud})...`);
1231
+ const bundlePlatformUrl = targetPlatformUrl ?? getPlatformUrl();
1232
+ const { bundleKey } = await exportFromAssistant(
1233
+ fromEntry,
1234
+ fromCloud,
1235
+ bundlePlatformUrl,
1236
+ );
1400
1237
 
1401
- // Step D — Hatch (upload succeeded or fallback to inline safe to hatch)
1238
+ // Hatch (export succeeded safe to create the target)
1402
1239
  const toEntry = await resolveOrHatchTarget(targetEnv, targetName);
1403
1240
  const toCloud = resolveCloud(toEntry);
1404
1241
 
1405
- // Step E — Import from GCS (or inline fallback)
1406
- // Pass bundleKey (string) or null to signal "already tried, use inline".
1242
+ // Import from GCS
1407
1243
  console.log(`Importing to ${toEntry.assistantId} (${toCloud})...`);
1408
- await importToAssistant(toEntry, toCloud, bundleData, false, bundleKey);
1244
+ await importToAssistant(
1245
+ toEntry,
1246
+ toCloud,
1247
+ bundleKey,
1248
+ false,
1249
+ bundlePlatformUrl,
1250
+ );
1409
1251
 
1410
- // Success summary
1411
1252
  console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
1412
1253
  return;
1413
1254
  }
1414
1255
 
1415
- // Non-platform targets (local/docker): existing flow unchanged
1256
+ // Non-platform targets (local/docker)
1416
1257
  // For local<->docker transfers, stop (sleep) the source to free up ports
1417
1258
  // before hatching the target. We do NOT retire yet — if hatch or import
1418
1259
  // fails, the user can recover by running `vellum wake <source>`.
@@ -1448,16 +1289,34 @@ export async function teleport(): Promise<void> {
1448
1289
  }
1449
1290
  }
1450
1291
 
1292
+ // Pin the bundle's platform instance so upload and download land on the
1293
+ // same GCS bucket. For platform sources the bundle is owned by the source
1294
+ // platform. For local/docker→local/docker the bundle lives on whatever
1295
+ // platform getPlatformUrl() currently resolves to — we resolve it once
1296
+ // here so a lockfile change mid-teleport can't split export and import.
1297
+ const bundlePlatformUrl =
1298
+ fromCloud === "vellum" ? fromEntry.runtimeUrl : getPlatformUrl();
1299
+
1300
+ // Export from source (bundle lives in GCS after this returns).
1301
+ console.log(`Exporting from ${from} (${fromCloud})...`);
1302
+ const { bundleKey } = await exportFromAssistant(
1303
+ fromEntry,
1304
+ fromCloud,
1305
+ bundlePlatformUrl,
1306
+ );
1307
+
1451
1308
  if (sourceIsLocalOrDocker && targetIsLocalOrDocker && !keepSource) {
1452
1309
  console.log(`Stopping source assistant '${from}' to free ports...`);
1453
1310
  if (fromCloud === "docker") {
1454
1311
  const res = dockerResourceNames(fromEntry.assistantId);
1455
1312
  await sleepContainers(res);
1456
1313
  } else if (fromEntry.resources) {
1457
- const pidFile = fromEntry.resources.pidFile;
1458
1314
  const vellumDir = join(fromEntry.resources.instanceDir, ".vellum");
1459
1315
  const gatewayPidFile = join(vellumDir, "gateway.pid");
1460
- await stopProcessByPidFile(pidFile, "assistant");
1316
+ await stopProcessByPidFile(
1317
+ getDaemonPidPath(fromEntry.resources),
1318
+ "assistant",
1319
+ );
1461
1320
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1462
1321
  }
1463
1322
  console.log(`Source assistant '${from}' stopped.`);
@@ -1513,9 +1372,15 @@ export async function teleport(): Promise<void> {
1513
1372
  }
1514
1373
  }
1515
1374
 
1516
- // Import to target
1375
+ // Import to target (also GCS-driven)
1517
1376
  console.log(`Importing to ${toEntry.assistantId} (${toCloud})...`);
1518
- await importToAssistant(toEntry, toCloud, bundleData, false);
1377
+ await importToAssistant(
1378
+ toEntry,
1379
+ toCloud,
1380
+ bundleKey,
1381
+ false,
1382
+ bundlePlatformUrl,
1383
+ );
1519
1384
 
1520
1385
  // After successful import, inject fresh platform credentials if the
1521
1386
  // user is logged in — replaces the source's stale vellum:* credentials
@@ -1540,6 +1405,5 @@ export async function teleport(): Promise<void> {
1540
1405
  }
1541
1406
  }
1542
1407
 
1543
- // Success summary
1544
1408
  console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
1545
1409
  }