@vellumai/cli 0.7.0 → 0.7.2

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 (54) hide show
  1. package/AGENTS.md +3 -11
  2. package/README.md +49 -0
  3. package/bun.lock +0 -15
  4. package/package.json +1 -6
  5. package/src/__tests__/backup.test.ts +591 -0
  6. package/src/__tests__/config-utils.test.ts +35 -48
  7. package/src/__tests__/teleport.test.ts +597 -37
  8. package/src/commands/backup.ts +149 -70
  9. package/src/commands/client.ts +56 -14
  10. package/src/commands/events.ts +3 -0
  11. package/src/commands/exec.ts +34 -12
  12. package/src/commands/hatch.ts +3 -7
  13. package/src/commands/login.ts +15 -33
  14. package/src/commands/logs.ts +2 -7
  15. package/src/commands/ps.ts +41 -6
  16. package/src/commands/restore.ts +32 -47
  17. package/src/commands/setup.ts +38 -73
  18. package/src/commands/ssh.ts +2 -5
  19. package/src/commands/teleport.ts +148 -34
  20. package/src/commands/tunnel.ts +2 -7
  21. package/src/commands/upgrade.ts +114 -7
  22. package/src/commands/wake.ts +5 -16
  23. package/src/components/DefaultMainScreen.tsx +65 -129
  24. package/src/index.ts +2 -13
  25. package/src/lib/__tests__/docker.test.ts +50 -32
  26. package/src/lib/__tests__/local-runtime-client.test.ts +308 -25
  27. package/src/lib/__tests__/platform-client-signed-url.test.ts +237 -2
  28. package/src/lib/__tests__/runtime-url.test.ts +125 -0
  29. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  30. package/src/lib/assistant-client.ts +18 -26
  31. package/src/lib/assistant-config.ts +34 -41
  32. package/src/lib/backup-ops.ts +43 -17
  33. package/src/lib/cli-error.ts +1 -0
  34. package/src/lib/client-identity.ts +1 -1
  35. package/src/lib/config-utils.ts +1 -97
  36. package/src/lib/docker-statefulset.ts +381 -0
  37. package/src/lib/docker.ts +8 -247
  38. package/src/lib/guardian-token.ts +56 -6
  39. package/src/lib/hatch-local.ts +3 -26
  40. package/src/lib/job-polling.ts +1 -1
  41. package/src/lib/local-runtime-client.ts +162 -28
  42. package/src/lib/local.ts +35 -64
  43. package/src/lib/ngrok.ts +36 -26
  44. package/src/lib/platform-client.ts +97 -221
  45. package/src/lib/platform-releases.ts +23 -0
  46. package/src/lib/retire-local.ts +2 -2
  47. package/src/lib/runtime-url.ts +52 -0
  48. package/src/lib/sync-cloud-assistants.ts +126 -0
  49. package/src/lib/terminal-client.ts +6 -1
  50. package/src/lib/terminal-session.ts +127 -48
  51. package/src/lib/tui-log.ts +60 -0
  52. package/src/lib/upgrade-lifecycle.ts +65 -0
  53. package/src/lib/xdg-log.ts +10 -4
  54. package/src/commands/pair.ts +0 -212
@@ -28,6 +28,10 @@ import { pgrepExact } from "../lib/pgrep";
28
28
  import { probePort } from "../lib/port-probe";
29
29
  import { withStatusEmoji } from "../lib/status-emoji";
30
30
  import { execOutput } from "../lib/step-runner";
31
+ import {
32
+ syncCloudAssistants,
33
+ type SyncLogger,
34
+ } from "../lib/sync-cloud-assistants";
31
35
 
32
36
  // ── Table formatting helpers ────────────────────────────────────
33
37
 
@@ -468,7 +472,7 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
468
472
 
469
473
  // ── List all assistants (no arg) ────────────────────────────────
470
474
 
471
- async function listAllAssistants(): Promise<void> {
475
+ async function listAllAssistants(verbose: boolean): Promise<void> {
472
476
  const { name: envName, source: envSource } = resolveEnvironmentSource();
473
477
  const sourceLabels: Record<typeof envSource, string> = {
474
478
  flag: "--environment flag",
@@ -476,7 +480,31 @@ async function listAllAssistants(): Promise<void> {
476
480
  config: "~/.config/vellum/environment",
477
481
  default: "default",
478
482
  };
479
- console.log(`Environment: ${envName} (${sourceLabels[envSource]})\n`);
483
+ console.log(`Environment: ${envName} (${sourceLabels[envSource]})`);
484
+
485
+ const log: SyncLogger | undefined = verbose
486
+ ? (msg) => console.log(` [verbose] ${msg}`)
487
+ : undefined;
488
+
489
+ // Refresh cloud assistants from the platform before listing.
490
+ const syncResult = await syncCloudAssistants({ log });
491
+
492
+ // Show platform login status
493
+ if (syncResult) {
494
+ const parts = [`Platform: logged in`];
495
+ if (syncResult.email) parts[0] += ` as ${syncResult.email}`;
496
+ if (syncResult.added > 0 || syncResult.removed > 0) {
497
+ const changes: string[] = [];
498
+ if (syncResult.added > 0) changes.push(`${syncResult.added} added`);
499
+ if (syncResult.removed > 0)
500
+ changes.push(`${syncResult.removed} removed`);
501
+ parts.push(`(${changes.join(", ")})`);
502
+ }
503
+ console.log(parts.join(" "));
504
+ } else {
505
+ console.log("Platform: not logged in");
506
+ }
507
+ console.log("");
480
508
 
481
509
  const assistants = loadAllAssistants();
482
510
  const activeId = getActiveAssistant();
@@ -599,21 +627,28 @@ async function listAllAssistants(): Promise<void> {
599
627
  export async function ps(): Promise<void> {
600
628
  const args = process.argv.slice(3);
601
629
  if (args.includes("--help") || args.includes("-h")) {
602
- console.log("Usage: vellum ps [<name>]");
630
+ console.log("Usage: vellum ps [<name>] [--verbose]");
603
631
  console.log("");
604
632
  console.log(
605
633
  "List all assistants, or show processes for a specific assistant.",
606
634
  );
607
635
  console.log("");
608
636
  console.log("Arguments:");
609
- console.log(" <name> Show processes for the named assistant");
637
+ console.log(" <name> Show processes for the named assistant");
638
+ console.log("");
639
+ console.log("Options:");
640
+ console.log(
641
+ " --verbose Show diagnostic logs (platform sync, auth issues)",
642
+ );
610
643
  process.exit(0);
611
644
  }
612
645
 
613
- const assistantId = process.argv[3];
646
+ const verbose = args.includes("--verbose");
647
+ const positional = args.filter((a) => !a.startsWith("--"));
648
+ const assistantId = positional[0];
614
649
 
615
650
  if (!assistantId) {
616
- await listAllAssistants();
651
+ await listAllAssistants(verbose);
617
652
  return;
618
653
  }
619
654
 
@@ -9,13 +9,11 @@ import {
9
9
  import {
10
10
  readPlatformToken,
11
11
  rollbackPlatformAssistant,
12
- platformImportPreflight,
13
- platformImportBundle,
14
- platformRequestUploadUrl,
12
+ platformRequestSignedUrl,
15
13
  platformUploadToSignedUrl,
16
14
  platformImportPreflightFromGcs,
17
15
  platformImportBundleFromGcs,
18
- platformPollImportStatus,
16
+ platformPollJobStatus,
19
17
  } from "../lib/platform-client.js";
20
18
  import { performDockerRollback } from "../lib/upgrade-lifecycle.js";
21
19
 
@@ -181,24 +179,20 @@ async function restorePlatform(
181
179
  process.exit(1);
182
180
  }
183
181
 
184
- // Step 1.5 — Upload to GCS via signed URL (with fallback to inline)
185
- let bundleKey: string | null = null;
186
- try {
187
- const { uploadUrl, bundleKey: key } = await platformRequestUploadUrl(
188
- token,
189
- entry.runtimeUrl,
190
- );
191
- bundleKey = key;
192
- console.log("Uploading bundle...");
193
- await platformUploadToSignedUrl(uploadUrl, new Uint8Array(bundleData));
194
- } catch (err) {
195
- const msg = err instanceof Error ? err.message : String(err);
196
- if (msg.includes("not available")) {
197
- bundleKey = null;
198
- } else {
199
- throw err;
200
- }
201
- }
182
+ // Step 1.5 — Upload to GCS via signed URL.
183
+ // We deliberately omit min/max runtime version here: restore uploads an
184
+ // arbitrary .vbundle from disk (often produced by a different runtime
185
+ // than the one we'd query right now), and the bundle's own manifest is
186
+ // the authority on its compatibility band. The platform skips the
187
+ // version gate when these fields are absent and re-derives compat from
188
+ // the manifest when it processes the import.
189
+ const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
190
+ { operation: "upload" },
191
+ token,
192
+ entry.runtimeUrl,
193
+ );
194
+ console.log("Uploading bundle...");
195
+ await platformUploadToSignedUrl(uploadUrl, new Uint8Array(bundleData));
202
196
 
203
197
  // Step 2 — Dry-run path
204
198
  if (opts.dryRun) {
@@ -213,17 +207,11 @@ async function restorePlatform(
213
207
 
214
208
  let preflightResult: { statusCode: number; body: Record<string, unknown> };
215
209
  try {
216
- preflightResult = bundleKey
217
- ? await platformImportPreflightFromGcs(
218
- bundleKey,
219
- token,
220
- entry.runtimeUrl,
221
- )
222
- : await platformImportPreflight(
223
- new Uint8Array(bundleData),
224
- token,
225
- entry.runtimeUrl,
226
- );
210
+ preflightResult = await platformImportPreflightFromGcs(
211
+ bundleKey,
212
+ token,
213
+ entry.runtimeUrl,
214
+ );
227
215
  } catch (err) {
228
216
  if (err instanceof Error && err.name === "TimeoutError") {
229
217
  console.error("Error: Preflight request timed out after 2 minutes.");
@@ -353,13 +341,11 @@ async function restorePlatform(
353
341
 
354
342
  let importResult: { statusCode: number; body: Record<string, unknown> };
355
343
  try {
356
- importResult = bundleKey
357
- ? await platformImportBundleFromGcs(bundleKey, token, entry.runtimeUrl)
358
- : await platformImportBundle(
359
- new Uint8Array(bundleData),
360
- token,
361
- entry.runtimeUrl,
362
- );
344
+ importResult = await platformImportBundleFromGcs(
345
+ bundleKey,
346
+ token,
347
+ entry.runtimeUrl,
348
+ );
363
349
  } catch (err) {
364
350
  if (err instanceof Error && err.name === "TimeoutError") {
365
351
  console.error("Error: Import request timed out after 5 minutes.");
@@ -420,13 +406,9 @@ async function restorePlatform(
420
406
  while (Date.now() < deadline) {
421
407
  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
422
408
 
423
- let status: {
424
- status: string;
425
- result?: Record<string, unknown>;
426
- error?: string;
427
- };
409
+ let status: Awaited<ReturnType<typeof platformPollJobStatus>>;
428
410
  try {
429
- status = await platformPollImportStatus(jobId, token, entry.runtimeUrl);
411
+ status = await platformPollJobStatus(jobId, token, entry.runtimeUrl);
430
412
  } catch (err) {
431
413
  const msg = err instanceof Error ? err.message : String(err);
432
414
  if (msg.includes("not found")) {
@@ -451,7 +433,10 @@ async function restorePlatform(
451
433
  }
452
434
 
453
435
  if (status.status === "complete") {
454
- importResult = { statusCode: 200, body: status.result ?? {} };
436
+ importResult = {
437
+ statusCode: 200,
438
+ body: (status.result as Record<string, unknown>) ?? {},
439
+ };
455
440
  break;
456
441
  }
457
442
 
@@ -1,43 +1,6 @@
1
1
  import { createInterface } from "readline";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
- import { homedir } from "os";
4
- import { dirname, join } from "path";
5
2
 
6
- function getVellumDir(): string {
7
- const base = process.env.BASE_DATA_DIR?.trim() || homedir();
8
- return join(base, ".vellum");
9
- }
10
-
11
- function getEnvFilePath(): string {
12
- return join(getVellumDir(), ".env");
13
- }
14
-
15
- function readEnvFile(): Record<string, string> {
16
- const envPath = getEnvFilePath();
17
- const vars: Record<string, string> = {};
18
- if (!existsSync(envPath)) return vars;
19
-
20
- const content = readFileSync(envPath, "utf-8");
21
- for (const line of content.split("\n")) {
22
- const trimmed = line.trim();
23
- if (!trimmed || trimmed.startsWith("#")) continue;
24
- const eqIdx = trimmed.indexOf("=");
25
- if (eqIdx === -1) continue;
26
- const key = trimmed.slice(0, eqIdx).trim();
27
- const value = trimmed.slice(eqIdx + 1).trim();
28
- vars[key] = value;
29
- }
30
- return vars;
31
- }
32
-
33
- function writeEnvFile(vars: Record<string, string>): void {
34
- const envPath = getEnvFilePath();
35
- const dir = dirname(envPath);
36
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
37
-
38
- const lines = Object.entries(vars).map(([k, v]) => `${k}=${v}`);
39
- writeFileSync(envPath, lines.join("\n") + "\n", { mode: 0o600 });
40
- }
3
+ import { resolveAssistant } from "../lib/assistant-config.js";
41
4
 
42
5
  async function promptMasked(prompt: string): Promise<string> {
43
6
  return new Promise((resolve) => {
@@ -46,7 +9,6 @@ async function promptMasked(prompt: string): Promise<string> {
46
9
  output: process.stdout,
47
10
  });
48
11
 
49
- // Disable echoing by writing the prompt manually and intercepting keystrokes
50
12
  process.stdout.write(prompt);
51
13
 
52
14
  const stdin = process.stdin;
@@ -60,7 +22,6 @@ async function promptMasked(prompt: string): Promise<string> {
60
22
  const char = key.toString("utf-8");
61
23
 
62
24
  if (char === "\r" || char === "\n") {
63
- // Enter pressed
64
25
  stdin.removeListener("data", onData);
65
26
  if (stdin.isTTY) {
66
27
  stdin.setRawMode(wasRaw ?? false);
@@ -69,11 +30,9 @@ async function promptMasked(prompt: string): Promise<string> {
69
30
  rl.close();
70
31
  resolve(input);
71
32
  } else if (char === "\u0003") {
72
- // Ctrl+C
73
33
  process.stdout.write("\n");
74
34
  process.exit(1);
75
35
  } else if (char === "\u007F" || char === "\b") {
76
- // Backspace
77
36
  if (input.length > 0) {
78
37
  input = input.slice(0, -1);
79
38
  process.stdout.write("\b \b");
@@ -111,39 +70,23 @@ export async function setup(): Promise<void> {
111
70
  console.log("");
112
71
  console.log("Interactive wizard to configure API keys.");
113
72
  console.log(
114
- "Keys are validated against their APIs and saved to <BASE_DATA_DIR>/.vellum/.env.",
73
+ "Injects secrets into your running assistant via the gateway API.",
115
74
  );
116
75
  process.exit(0);
117
76
  }
118
77
 
119
- console.log("Vellum Setup");
120
- console.log("============\n");
121
-
122
- const existingVars = readEnvFile();
123
- const hasExistingKey = !!existingVars.ANTHROPIC_API_KEY;
78
+ const entry = resolveAssistant();
79
+ if (!entry) {
80
+ console.error(
81
+ "Error: No active assistant found. Run `vellum hatch` first.",
82
+ );
83
+ process.exit(1);
84
+ }
124
85
 
125
- if (hasExistingKey) {
126
- const masked =
127
- existingVars.ANTHROPIC_API_KEY.slice(0, 7) +
128
- "..." +
129
- existingVars.ANTHROPIC_API_KEY.slice(-4);
130
- console.log(`Anthropic API key is already configured (${masked}).`);
86
+ const gatewayUrl = entry.localUrl ?? entry.runtimeUrl;
131
87
 
132
- const rl = createInterface({
133
- input: process.stdin,
134
- output: process.stdout,
135
- });
136
- const answer = await new Promise<string>((resolve) => {
137
- rl.question("Overwrite? [y/N] ", resolve);
138
- });
139
- rl.close();
140
-
141
- if (answer.trim().toLowerCase() !== "y") {
142
- console.log("\nSetup complete. No changes made.");
143
- return;
144
- }
145
- console.log("");
146
- }
88
+ console.log("Vellum Setup");
89
+ console.log("============\n");
147
90
 
148
91
  const apiKey = await promptMasked(
149
92
  "Enter your Anthropic API key (sk-ant-...): ",
@@ -164,9 +107,31 @@ export async function setup(): Promise<void> {
164
107
  process.exit(1);
165
108
  }
166
109
 
167
- existingVars.ANTHROPIC_API_KEY = apiKey.trim();
168
- writeEnvFile(existingVars);
110
+ const headers: Record<string, string> = {
111
+ "Content-Type": "application/json",
112
+ Accept: "application/json",
113
+ };
114
+ if (entry.bearerToken) {
115
+ headers["Authorization"] = `Bearer ${entry.bearerToken}`;
116
+ }
117
+
118
+ const response = await fetch(`${gatewayUrl}/v1/secrets`, {
119
+ method: "POST",
120
+ headers,
121
+ body: JSON.stringify({
122
+ type: "credential",
123
+ name: "ANTHROPIC_API_KEY",
124
+ value: apiKey.trim(),
125
+ }),
126
+ signal: AbortSignal.timeout(10_000),
127
+ });
128
+
129
+ if (!response.ok) {
130
+ console.error(
131
+ `Error: Failed to store API key in assistant (${response.status}).`,
132
+ );
133
+ process.exit(1);
134
+ }
169
135
 
170
- console.log(`\nAPI key saved to ${getEnvFilePath()}`);
171
- console.log("Setup complete.");
136
+ console.log("\nAPI key saved to assistant. Setup complete.");
172
137
  }
@@ -1,9 +1,6 @@
1
1
  import { spawn } from "child_process";
2
2
 
3
- import {
4
- findAssistantByName,
5
- loadLatestAssistant,
6
- } from "../lib/assistant-config";
3
+ import { resolveAssistant } from "../lib/assistant-config";
7
4
  import type { AssistantEntry } from "../lib/assistant-config";
8
5
  import { dockerResourceNames } from "../lib/docker";
9
6
  import { getPlatformUrl, readPlatformToken } from "../lib/platform-client";
@@ -58,7 +55,7 @@ export async function ssh(): Promise<void> {
58
55
  }
59
56
 
60
57
  const name = process.argv[3];
61
- const entry = name ? findAssistantByName(name) : loadLatestAssistant();
58
+ const entry = resolveAssistant(name);
62
59
 
63
60
  if (!entry) {
64
61
  if (name) {
@@ -17,11 +17,11 @@ import {
17
17
  getPlatformUrl,
18
18
  hatchAssistant,
19
19
  checkExistingPlatformAssistant,
20
- platformInitiateExport,
21
20
  platformPollJobStatus,
22
21
  platformImportBundleFromGcs,
23
22
  platformImportPreflightFromGcs,
24
23
  platformRequestSignedUrl,
24
+ VersionMismatchError,
25
25
  ensureSelfHostedLocalRegistration,
26
26
  readGatewayCredential,
27
27
  reprovisionAssistantApiKey,
@@ -31,6 +31,7 @@ import {
31
31
  } from "../lib/platform-client.js";
32
32
  import {
33
33
  localRuntimeExportToGcs,
34
+ localRuntimeIdentity,
34
35
  localRuntimeImportFromGcs,
35
36
  localRuntimePollJobStatus,
36
37
  MigrationInProgressError,
@@ -255,13 +256,17 @@ async function getAccessToken(
255
256
 
256
257
  /**
257
258
  * 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.
259
+ * `localRuntimeImportFromGcs` / `localRuntimeIdentity`. They throw Error
260
+ * with a message of the form `"Local runtime <op> failed (401): ..."` or
261
+ * `"Failed to fetch runtime identity: 401 ..."` when the gateway rejects
262
+ * the cached guardian token.
261
263
  */
262
264
  function isRuntime401(err: unknown): boolean {
263
265
  const msg = err instanceof Error ? err.message : String(err);
264
- return /Local runtime [^(]*failed \(401\)/.test(msg);
266
+ return (
267
+ /Local runtime [^(]*failed \(401\)/.test(msg) ||
268
+ /Failed to fetch runtime identity: 401\b/.test(msg)
269
+ );
265
270
  }
266
271
 
267
272
  /**
@@ -368,13 +373,40 @@ async function exportFromAssistant(
368
373
  }
369
374
 
370
375
  if (cloud === "local" || cloud === "docker") {
376
+ // Ask the source runtime which version it's running before requesting
377
+ // the signed upload URL. The bundle is produced by the daemon (not the
378
+ // CLI), so the daemon's version is what defines the bundle's
379
+ // `min_runtime_version`. Stamping with `cliPkg.version` instead would
380
+ // record an inaccurate compatibility band whenever the CLI/daemon have
381
+ // drifted (a normal case in real usage — `vellum upgrade` swaps the
382
+ // daemon, the CLI is updated separately).
383
+ let sourceRuntimeVersion: string;
384
+ try {
385
+ const identity = await callRuntimeWithAuthRetry(
386
+ entry.runtimeUrl,
387
+ entry.assistantId,
388
+ async (token) => localRuntimeIdentity(entry, token),
389
+ );
390
+ sourceRuntimeVersion = identity.version;
391
+ } catch (err) {
392
+ const msg = err instanceof Error ? err.message : String(err);
393
+ console.error(
394
+ `Error: Could not fetch runtime identity from '${entry.assistantId}': ${msg}`,
395
+ );
396
+ process.exit(1);
397
+ }
398
+
371
399
  // Request a signed upload URL from the platform instance that will
372
400
  // eventually own the bundle (i.e. the one the importer will read from).
373
401
  // Passing the target's runtime URL here keeps upload and download on
374
402
  // the same platform — otherwise a non-default/stale platform URL would
375
403
  // cause the import to look at an empty object.
376
404
  const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
377
- { operation: "upload" },
405
+ {
406
+ operation: "upload",
407
+ minRuntimeVersion: sourceRuntimeVersion,
408
+ maxRuntimeVersion: null,
409
+ },
378
410
  platformToken,
379
411
  bundlePlatformUrl,
380
412
  );
@@ -391,7 +423,7 @@ async function exportFromAssistant(
391
423
  entry.runtimeUrl,
392
424
  entry.assistantId,
393
425
  async (token) => {
394
- const r = await localRuntimeExportToGcs(entry.runtimeUrl, token, {
426
+ const r = await localRuntimeExportToGcs(entry, token, {
395
427
  uploadUrl,
396
428
  description: "teleport export",
397
429
  });
@@ -418,8 +450,7 @@ async function exportFromAssistant(
418
450
 
419
451
  const terminal = await pollJobUntilDone({
420
452
  label: "local-runtime export",
421
- poll: () =>
422
- localRuntimePollJobStatus(entry.runtimeUrl, accessToken, jobId),
453
+ poll: () => localRuntimePollJobStatus(entry, accessToken, jobId),
423
454
  // Large exports can take longer than a guardian-token lease. If the
424
455
  // runtime returns 401 mid-poll, re-lease a fresh token and rebind the
425
456
  // closure variable so the next poll uses it.
@@ -442,22 +473,68 @@ async function exportFromAssistant(
442
473
  }
443
474
 
444
475
  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(
476
+ // Ask the managed runtime which version it's running so the signed-URL
477
+ // request records the bundle's actual `min_runtime_version`. The
478
+ // platform-managed runtime is the exporter; the CLI version is
479
+ // unrelated. Routed via the wildcard proxy with platform-token auth
480
+ // (resolveRuntimeUrl + migrationRequestHeaders inside
481
+ // localRuntimeIdentity).
482
+ let sourceRuntimeVersion: string;
483
+ try {
484
+ const identity = await localRuntimeIdentity(entry, platformToken);
485
+ sourceRuntimeVersion = identity.version;
486
+ } catch (err) {
487
+ const msg = err instanceof Error ? err.message : String(err);
488
+ console.error(
489
+ `Error: Could not fetch runtime identity from '${entry.assistantId}': ${msg}`,
490
+ );
491
+ process.exit(1);
492
+ }
493
+
494
+ // Platform source — request a signed upload URL on the same platform
495
+ // instance the bundle will eventually be imported from, then ask the
496
+ // managed runtime to export directly to GCS. The runtime endpoint is
497
+ // reached via the platform's wildcard runtime proxy at
498
+ // `/v1/assistants/<id>/migrations/export-to-gcs` — the
499
+ // `localRuntimeExportToGcs` helper uses `resolveRuntimeMigrationUrl` to
500
+ // pick that shape for `cloud === "vellum"` and `migrationRequestHeaders`
501
+ // to send platform-token auth (no guardian-token bootstrap).
502
+ const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
503
+ {
504
+ operation: "upload",
505
+ minRuntimeVersion: sourceRuntimeVersion,
506
+ maxRuntimeVersion: null,
507
+ },
449
508
  platformToken,
450
- "teleport export",
451
- entry.runtimeUrl,
509
+ bundlePlatformUrl,
452
510
  );
453
511
 
512
+ let jobId: string;
513
+ let exportPlatformToken = platformToken;
514
+ try {
515
+ ({ jobId } = await localRuntimeExportToGcs(entry, exportPlatformToken, {
516
+ uploadUrl,
517
+ description: "teleport export",
518
+ }));
519
+ } catch (err) {
520
+ if (err instanceof MigrationInProgressError) {
521
+ console.error(
522
+ `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.`,
523
+ );
524
+ process.exit(1);
525
+ }
526
+ throw err;
527
+ }
528
+
454
529
  console.log(`Export started (job ${jobId})...`);
455
530
 
456
- let exportPlatformToken = platformToken;
531
+ // Polling also goes through the wildcard proxy — `localRuntimePollJobStatus`
532
+ // builds `/v1/assistants/<id>/migrations/jobs/<jobId>` for `cloud === "vellum"`
533
+ // (the dedicated `/v1/migrations/jobs/{id}/` endpoint queries platform-side
534
+ // ImportJob records and 404s on runtime-created job IDs).
457
535
  const terminal = await pollJobUntilDone({
458
536
  label: "platform export",
459
- poll: () =>
460
- platformPollJobStatus(jobId, exportPlatformToken, entry.runtimeUrl),
537
+ poll: () => localRuntimePollJobStatus(entry, exportPlatformToken, jobId),
461
538
  // The platform token is normally static per-process, but re-reading the
462
539
  // on-disk credential covers the case where the user ran `vellum login`
463
540
  // in another terminal during a long migration. A persistent 401 after
@@ -478,14 +555,7 @@ async function exportFromAssistant(
478
555
  process.exit(1);
479
556
  }
480
557
 
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 };
558
+ return { bundleKey };
489
559
  }
490
560
 
491
561
  console.error(
@@ -644,11 +714,56 @@ async function importToAssistant(
644
714
  // never touches the bytes. The URL must target the same platform the
645
715
  // bundle was uploaded to; otherwise the object won't exist on this
646
716
  // platform's GCS bucket.
647
- const { url: bundleUrl } = await platformRequestSignedUrl(
648
- { operation: "download", bundleKey },
649
- platformToken,
650
- bundlePlatformUrl,
651
- );
717
+ //
718
+ // The platform's vbundle version gate compares the **target runtime's**
719
+ // version against the bundle's compatibility range. The CLI and the
720
+ // target assistant's daemon can diverge (assistants upgrade
721
+ // independently), so we MUST query the target runtime's `/v1/identity`
722
+ // for its version rather than sending `cliPkg.version`. Sending the CLI
723
+ // version here would falsely 422 a valid import (or pass a bundle the
724
+ // target can't actually load) whenever the two drift apart.
725
+ let targetRuntimeVersion: string;
726
+ try {
727
+ const identity = await callRuntimeWithAuthRetry(
728
+ entry.runtimeUrl,
729
+ entry.assistantId,
730
+ (token) => localRuntimeIdentity(entry, token),
731
+ );
732
+ targetRuntimeVersion = identity.version;
733
+ } catch (err) {
734
+ // Surface and abort — silently falling back to `cliPkg.version` would
735
+ // re-introduce the bug this code is fixing. If the runtime is
736
+ // unreachable, the import would fail downstream anyway.
737
+ const msg = err instanceof Error ? err.message : String(err);
738
+ console.error(
739
+ `Error: Could not read target runtime version from '${entry.assistantId}': ${msg}`,
740
+ );
741
+ console.error(`Try: vellum wake ${entry.assistantId}`);
742
+ process.exit(1);
743
+ }
744
+
745
+ let bundleUrl: string;
746
+ try {
747
+ const result = await platformRequestSignedUrl(
748
+ {
749
+ operation: "download",
750
+ bundleKey,
751
+ targetRuntimeVersion,
752
+ },
753
+ platformToken,
754
+ bundlePlatformUrl,
755
+ );
756
+ bundleUrl = result.url;
757
+ } catch (err) {
758
+ if (err instanceof VersionMismatchError) {
759
+ // 422 version_mismatch is terminal — the bundle's runtime range and
760
+ // the target runtime's version don't overlap. Surface the
761
+ // platform-formatted message and exit; do NOT retry.
762
+ console.error(`Error: ${err.message}`);
763
+ process.exit(1);
764
+ }
765
+ throw err;
766
+ }
652
767
 
653
768
  console.log("Importing data...");
654
769
 
@@ -659,7 +774,7 @@ async function importToAssistant(
659
774
  entry.runtimeUrl,
660
775
  entry.assistantId,
661
776
  async (token) => {
662
- const r = await localRuntimeImportFromGcs(entry.runtimeUrl, token, {
777
+ const r = await localRuntimeImportFromGcs(entry, token, {
663
778
  bundleUrl,
664
779
  });
665
780
  return { jobId: r.jobId, token };
@@ -682,8 +797,7 @@ async function importToAssistant(
682
797
 
683
798
  const terminal = await pollJobUntilDone({
684
799
  label: "local-runtime import",
685
- poll: () =>
686
- localRuntimePollJobStatus(entry.runtimeUrl, accessToken, jobId),
800
+ poll: () => localRuntimePollJobStatus(entry, accessToken, jobId),
687
801
  refreshOn401: async () => {
688
802
  accessToken = await getAccessToken(
689
803
  entry.runtimeUrl,