@vellumai/cli 0.7.0 → 0.7.1

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 (40) hide show
  1. package/README.md +49 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/backup.test.ts +475 -0
  4. package/src/__tests__/config-utils.test.ts +35 -48
  5. package/src/__tests__/teleport.test.ts +86 -28
  6. package/src/commands/backup.ts +117 -71
  7. package/src/commands/client.ts +10 -9
  8. package/src/commands/exec.ts +21 -8
  9. package/src/commands/hatch.ts +2 -6
  10. package/src/commands/login.ts +15 -33
  11. package/src/commands/logs.ts +2 -7
  12. package/src/commands/ps.ts +41 -6
  13. package/src/commands/restore.ts +26 -47
  14. package/src/commands/ssh.ts +2 -5
  15. package/src/commands/teleport.ts +38 -24
  16. package/src/commands/tunnel.ts +2 -7
  17. package/src/commands/upgrade.ts +108 -7
  18. package/src/components/DefaultMainScreen.tsx +25 -3
  19. package/src/index.ts +2 -7
  20. package/src/lib/__tests__/local-runtime-client.test.ts +122 -25
  21. package/src/lib/__tests__/platform-client-signed-url.test.ts +2 -2
  22. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  23. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  24. package/src/lib/assistant-client.ts +5 -21
  25. package/src/lib/assistant-config.ts +34 -16
  26. package/src/lib/cli-error.ts +1 -0
  27. package/src/lib/client-identity.ts +1 -1
  28. package/src/lib/config-utils.ts +1 -97
  29. package/src/lib/docker.ts +2 -2
  30. package/src/lib/job-polling.ts +1 -1
  31. package/src/lib/local-runtime-client.ts +81 -28
  32. package/src/lib/local.ts +27 -58
  33. package/src/lib/platform-client.ts +1 -220
  34. package/src/lib/platform-releases.ts +23 -0
  35. package/src/lib/runtime-url.ts +30 -0
  36. package/src/lib/sync-cloud-assistants.ts +126 -0
  37. package/src/lib/terminal-client.ts +6 -1
  38. package/src/lib/terminal-session.ts +127 -48
  39. package/src/lib/tui-log.ts +60 -0
  40. package/src/lib/xdg-log.ts +10 -4
@@ -3,12 +3,10 @@ import { spawn } from "child_process";
3
3
  import { randomBytes } from "crypto";
4
4
 
5
5
  import {
6
- findAssistantByName,
7
6
  getActiveAssistant,
8
- loadLatestAssistant,
7
+ resolveAssistant,
9
8
  loadAllAssistants,
10
9
  removeAssistantEntry,
11
- saveAssistantEntry,
12
10
  setActiveAssistant,
13
11
  } from "../lib/assistant-config";
14
12
  import { computeDeviceId } from "../lib/guardian-token";
@@ -26,6 +24,7 @@ import {
26
24
  reprovisionAssistantApiKey,
27
25
  savePlatformToken,
28
26
  } from "../lib/platform-client";
27
+ import { syncCloudAssistants } from "../lib/sync-cloud-assistants";
29
28
 
30
29
  const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
31
30
 
@@ -205,10 +204,7 @@ export async function login(): Promise<void> {
205
204
  // Register the local assistant with the platform (non-fatal).
206
205
  // Mirrors the desktop app's LocalAssistantBootstrapService flow.
207
206
  try {
208
- const activeName = getActiveAssistant();
209
- const entry = activeName
210
- ? findAssistantByName(activeName)
211
- : loadLatestAssistant();
207
+ const entry = resolveAssistant();
212
208
 
213
209
  // Skip managed ("vellum") assistants — they are handled by the platform.
214
210
  if (entry && entry.cloud !== "vellum") {
@@ -282,36 +278,22 @@ export async function login(): Promise<void> {
282
278
  // This ensures `vellum ps` shows managed assistants immediately
283
279
  // after login (e.g. after a retire-and-rehatch cycle).
284
280
  try {
285
- const platformAssistants = await fetchPlatformAssistants(token);
286
- const existingIds = new Set(
287
- loadAllAssistants()
288
- .filter((a) => a.cloud === "vellum")
289
- .map((a) => a.assistantId),
290
- );
291
-
292
- let synced = 0;
293
- for (const pa of platformAssistants) {
294
- if (!existingIds.has(pa.id)) {
295
- saveAssistantEntry({
296
- assistantId: pa.id,
297
- runtimeUrl: getPlatformUrl(),
298
- cloud: "vellum",
299
- species: "vellum",
300
- hatchedAt: new Date().toISOString(),
301
- });
302
- synced++;
281
+ const result = await syncCloudAssistants();
282
+ if (result) {
283
+ const total = result.added + result.removed;
284
+ if (total > 0) {
285
+ console.log(
286
+ `Synced cloud assistants (${result.added} added, ${result.removed} removed).`,
287
+ );
303
288
  }
304
289
  }
305
290
 
306
- if (synced > 0) {
307
- console.log(
308
- `Synced ${synced} cloud assistant${synced > 1 ? "s" : ""} to local lockfile.`,
309
- );
310
- }
311
-
312
291
  // If no active assistant is set, activate the first cloud one.
313
- if (!getActiveAssistant() && platformAssistants.length > 0) {
314
- setActiveAssistant(platformAssistants[0].id);
292
+ if (!getActiveAssistant()) {
293
+ const platformAssistants = await fetchPlatformAssistants(token);
294
+ if (platformAssistants.length > 0) {
295
+ setActiveAssistant(platformAssistants[0].id);
296
+ }
315
297
  }
316
298
  } catch {
317
299
  // Non-fatal — login succeeded even if sync fails
@@ -4,10 +4,7 @@ import { createInterface } from "readline";
4
4
  import { watch } from "fs";
5
5
  import { join } from "path";
6
6
 
7
- import {
8
- findAssistantByName,
9
- loadLatestAssistant,
10
- } from "../lib/assistant-config";
7
+ import { resolveAssistant } from "../lib/assistant-config";
11
8
  import type { AssistantEntry } from "../lib/assistant-config";
12
9
  import { dockerResourceNames } from "../lib/docker";
13
10
  import { getLogDir } from "../lib/xdg-log";
@@ -593,9 +590,7 @@ async function showAwsLogs(
593
590
  export async function logs(): Promise<void> {
594
591
  const opts = parseArgs();
595
592
 
596
- const entry = opts.name
597
- ? findAssistantByName(opts.name)
598
- : loadLatestAssistant();
593
+ const entry = resolveAssistant(opts.name);
599
594
 
600
595
  if (!entry) {
601
596
  if (opts.name) {
@@ -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,14 @@ 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
+ const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
184
+ { operation: "upload" },
185
+ token,
186
+ entry.runtimeUrl,
187
+ );
188
+ console.log("Uploading bundle...");
189
+ await platformUploadToSignedUrl(uploadUrl, new Uint8Array(bundleData));
202
190
 
203
191
  // Step 2 — Dry-run path
204
192
  if (opts.dryRun) {
@@ -213,17 +201,11 @@ async function restorePlatform(
213
201
 
214
202
  let preflightResult: { statusCode: number; body: Record<string, unknown> };
215
203
  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
- );
204
+ preflightResult = await platformImportPreflightFromGcs(
205
+ bundleKey,
206
+ token,
207
+ entry.runtimeUrl,
208
+ );
227
209
  } catch (err) {
228
210
  if (err instanceof Error && err.name === "TimeoutError") {
229
211
  console.error("Error: Preflight request timed out after 2 minutes.");
@@ -353,13 +335,11 @@ async function restorePlatform(
353
335
 
354
336
  let importResult: { statusCode: number; body: Record<string, unknown> };
355
337
  try {
356
- importResult = bundleKey
357
- ? await platformImportBundleFromGcs(bundleKey, token, entry.runtimeUrl)
358
- : await platformImportBundle(
359
- new Uint8Array(bundleData),
360
- token,
361
- entry.runtimeUrl,
362
- );
338
+ importResult = await platformImportBundleFromGcs(
339
+ bundleKey,
340
+ token,
341
+ entry.runtimeUrl,
342
+ );
363
343
  } catch (err) {
364
344
  if (err instanceof Error && err.name === "TimeoutError") {
365
345
  console.error("Error: Import request timed out after 5 minutes.");
@@ -420,13 +400,9 @@ async function restorePlatform(
420
400
  while (Date.now() < deadline) {
421
401
  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
422
402
 
423
- let status: {
424
- status: string;
425
- result?: Record<string, unknown>;
426
- error?: string;
427
- };
403
+ let status: Awaited<ReturnType<typeof platformPollJobStatus>>;
428
404
  try {
429
- status = await platformPollImportStatus(jobId, token, entry.runtimeUrl);
405
+ status = await platformPollJobStatus(jobId, token, entry.runtimeUrl);
430
406
  } catch (err) {
431
407
  const msg = err instanceof Error ? err.message : String(err);
432
408
  if (msg.includes("not found")) {
@@ -451,7 +427,10 @@ async function restorePlatform(
451
427
  }
452
428
 
453
429
  if (status.status === "complete") {
454
- importResult = { statusCode: 200, body: status.result ?? {} };
430
+ importResult = {
431
+ statusCode: 200,
432
+ body: (status.result as Record<string, unknown>) ?? {},
433
+ };
455
434
  break;
456
435
  }
457
436
 
@@ -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,7 +17,6 @@ import {
17
17
  getPlatformUrl,
18
18
  hatchAssistant,
19
19
  checkExistingPlatformAssistant,
20
- platformInitiateExport,
21
20
  platformPollJobStatus,
22
21
  platformImportBundleFromGcs,
23
22
  platformImportPreflightFromGcs,
@@ -391,7 +390,7 @@ async function exportFromAssistant(
391
390
  entry.runtimeUrl,
392
391
  entry.assistantId,
393
392
  async (token) => {
394
- const r = await localRuntimeExportToGcs(entry.runtimeUrl, token, {
393
+ const r = await localRuntimeExportToGcs(entry, token, {
395
394
  uploadUrl,
396
395
  description: "teleport export",
397
396
  });
@@ -418,8 +417,7 @@ async function exportFromAssistant(
418
417
 
419
418
  const terminal = await pollJobUntilDone({
420
419
  label: "local-runtime export",
421
- poll: () =>
422
- localRuntimePollJobStatus(entry.runtimeUrl, accessToken, jobId),
420
+ poll: () => localRuntimePollJobStatus(entry, accessToken, jobId),
423
421
  // Large exports can take longer than a guardian-token lease. If the
424
422
  // runtime returns 401 mid-poll, re-lease a fresh token and rebind the
425
423
  // closure variable so the next poll uses it.
@@ -442,22 +440,46 @@ async function exportFromAssistant(
442
440
  }
443
441
 
444
442
  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(
443
+ // Platform source — request a signed upload URL on the same platform
444
+ // instance the bundle will eventually be imported from, then ask the
445
+ // managed runtime to export directly to GCS. The runtime endpoint is
446
+ // reached via the platform's wildcard runtime proxy at
447
+ // `/v1/assistants/<id>/migrations/export-to-gcs` — the
448
+ // `localRuntimeExportToGcs` helper uses `resolveRuntimeMigrationUrl` to
449
+ // pick that shape for `cloud === "vellum"` and `migrationRequestHeaders`
450
+ // to send platform-token auth (no guardian-token bootstrap).
451
+ const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
452
+ { operation: "upload" },
449
453
  platformToken,
450
- "teleport export",
451
- entry.runtimeUrl,
454
+ bundlePlatformUrl,
452
455
  );
453
456
 
457
+ let jobId: string;
458
+ let exportPlatformToken = platformToken;
459
+ try {
460
+ ({ jobId } = await localRuntimeExportToGcs(entry, exportPlatformToken, {
461
+ uploadUrl,
462
+ description: "teleport export",
463
+ }));
464
+ } catch (err) {
465
+ if (err instanceof MigrationInProgressError) {
466
+ console.error(
467
+ `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.`,
468
+ );
469
+ process.exit(1);
470
+ }
471
+ throw err;
472
+ }
473
+
454
474
  console.log(`Export started (job ${jobId})...`);
455
475
 
456
- let exportPlatformToken = platformToken;
476
+ // Polling also goes through the wildcard proxy — `localRuntimePollJobStatus`
477
+ // builds `/v1/assistants/<id>/migrations/jobs/<jobId>` for `cloud === "vellum"`
478
+ // (the dedicated `/v1/migrations/jobs/{id}/` endpoint queries platform-side
479
+ // ImportJob records and 404s on runtime-created job IDs).
457
480
  const terminal = await pollJobUntilDone({
458
481
  label: "platform export",
459
- poll: () =>
460
- platformPollJobStatus(jobId, exportPlatformToken, entry.runtimeUrl),
482
+ poll: () => localRuntimePollJobStatus(entry, exportPlatformToken, jobId),
461
483
  // The platform token is normally static per-process, but re-reading the
462
484
  // on-disk credential covers the case where the user ran `vellum login`
463
485
  // in another terminal during a long migration. A persistent 401 after
@@ -478,14 +500,7 @@ async function exportFromAssistant(
478
500
  process.exit(1);
479
501
  }
480
502
 
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 };
503
+ return { bundleKey };
489
504
  }
490
505
 
491
506
  console.error(
@@ -659,7 +674,7 @@ async function importToAssistant(
659
674
  entry.runtimeUrl,
660
675
  entry.assistantId,
661
676
  async (token) => {
662
- const r = await localRuntimeImportFromGcs(entry.runtimeUrl, token, {
677
+ const r = await localRuntimeImportFromGcs(entry, token, {
663
678
  bundleUrl,
664
679
  });
665
680
  return { jobId: r.jobId, token };
@@ -682,8 +697,7 @@ async function importToAssistant(
682
697
 
683
698
  const terminal = await pollJobUntilDone({
684
699
  label: "local-runtime import",
685
- poll: () =>
686
- localRuntimePollJobStatus(entry.runtimeUrl, accessToken, jobId),
700
+ poll: () => localRuntimePollJobStatus(entry, accessToken, jobId),
687
701
  refreshOn401: async () => {
688
702
  accessToken = await getAccessToken(
689
703
  entry.runtimeUrl,
@@ -1,7 +1,4 @@
1
- import {
2
- findAssistantByName,
3
- loadLatestAssistant,
4
- } from "../lib/assistant-config";
1
+ import { resolveAssistant } from "../lib/assistant-config";
5
2
  import { runNgrokTunnel } from "../lib/ngrok";
6
3
 
7
4
  const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
@@ -63,9 +60,7 @@ function parseArgs(): TunnelArgs {
63
60
  export async function tunnel(): Promise<void> {
64
61
  const { assistantName, provider } = parseArgs();
65
62
 
66
- const entry = assistantName
67
- ? findAssistantByName(assistantName)
68
- : loadLatestAssistant();
63
+ const entry = resolveAssistant(assistantName ?? undefined);
69
64
 
70
65
  if (!entry) {
71
66
  if (assistantName) {
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "crypto";
2
+ import { spawnSync } from "child_process";
2
3
 
3
4
  import cliPkg from "../../package.json";
4
5
 
@@ -16,7 +17,10 @@ import {
16
17
  startContainers,
17
18
  stopContainers,
18
19
  } from "../lib/docker";
19
- import { resolveImageRefs } from "../lib/platform-releases";
20
+ import {
21
+ fetchLatestStableVersion,
22
+ resolveImageRefs,
23
+ } from "../lib/platform-releases";
20
24
  import {
21
25
  authHeaders,
22
26
  getPlatformUrl,
@@ -47,6 +51,7 @@ import { compareVersions } from "../lib/version-compat.js";
47
51
  interface UpgradeArgs {
48
52
  name: string | null;
49
53
  version: string | null;
54
+ latest: boolean;
50
55
  prepare: boolean;
51
56
  finalize: boolean;
52
57
  }
@@ -55,6 +60,7 @@ function parseArgs(): UpgradeArgs {
55
60
  const args = process.argv.slice(3);
56
61
  let name: string | null = null;
57
62
  let version: string | null = null;
63
+ let latest = false;
58
64
  let prepare = false;
59
65
  let finalize = false;
60
66
 
@@ -73,7 +79,10 @@ function parseArgs(): UpgradeArgs {
73
79
  console.log("");
74
80
  console.log("Options:");
75
81
  console.log(
76
- " --version <version> Target version to upgrade to (default: latest)",
82
+ " --version <version> Target version to upgrade to (default: CLI version)",
83
+ );
84
+ console.log(
85
+ " --latest Upgrade to the latest stable release, updating the CLI first if needed",
77
86
  );
78
87
  console.log(
79
88
  " --prepare Run pre-upgrade steps only (backup, notify) without swapping versions",
@@ -84,7 +93,10 @@ function parseArgs(): UpgradeArgs {
84
93
  console.log("");
85
94
  console.log("Examples:");
86
95
  console.log(
87
- " vellum upgrade # Upgrade the active assistant to the latest version",
96
+ " vellum upgrade # Upgrade the active assistant to the CLI's version",
97
+ );
98
+ console.log(
99
+ " vellum upgrade --latest # Upgrade CLI + assistant to the latest stable release",
88
100
  );
89
101
  console.log(
90
102
  " vellum upgrade my-assistant # Upgrade a specific assistant by name",
@@ -102,6 +114,8 @@ function parseArgs(): UpgradeArgs {
102
114
  }
103
115
  version = next;
104
116
  i++;
117
+ } else if (arg === "--latest") {
118
+ latest = true;
105
119
  } else if (arg === "--prepare") {
106
120
  prepare = true;
107
121
  } else if (arg === "--finalize") {
@@ -121,7 +135,13 @@ function parseArgs(): UpgradeArgs {
121
135
  process.exit(1);
122
136
  }
123
137
 
124
- return { name, version, prepare, finalize };
138
+ if (latest && version) {
139
+ console.error("Error: --latest and --version are mutually exclusive.");
140
+ emitCliError("UNKNOWN", "--latest and --version are mutually exclusive");
141
+ process.exit(1);
142
+ }
143
+
144
+ return { name, version, latest, prepare, finalize };
125
145
  }
126
146
 
127
147
  function resolveCloud(entry: AssistantEntry): string {
@@ -867,8 +887,80 @@ async function upgradeFinalize(
867
887
  );
868
888
  }
869
889
 
890
+ /**
891
+ * When `--latest` is passed, resolve the latest stable version from the
892
+ * platform API. If the running CLI is older than that version, self-update
893
+ * the CLI via `bun install -g` and re-exec so the new CLI's upgrade logic
894
+ * (and its cliPkg.version) drives the rest of the upgrade.
895
+ *
896
+ * Returns the resolved latest version string (e.g. "v0.7.0") for callers
897
+ * that need it. If the CLI was updated and re-exec'd, this function never
898
+ * returns — the process is replaced.
899
+ */
900
+ async function resolveLatestAndMaybeSelfUpdate(
901
+ name: string | null,
902
+ ): Promise<string> {
903
+ console.log("🔍 Fetching latest stable release...");
904
+ const latestVersion = await fetchLatestStableVersion();
905
+ if (!latestVersion) {
906
+ console.error(
907
+ "Error: Could not determine the latest stable release from the platform API.",
908
+ );
909
+ emitCliError(
910
+ "UNKNOWN",
911
+ "Could not determine the latest stable release from the platform API",
912
+ );
913
+ process.exit(1);
914
+ }
915
+
916
+ const latestTag = latestVersion.startsWith("v")
917
+ ? latestVersion
918
+ : `v${latestVersion}`;
919
+ const currentTag = cliPkg.version ? `v${cliPkg.version}` : null;
920
+
921
+ console.log(` Latest stable: ${latestTag}`);
922
+ console.log(` CLI version: ${currentTag ?? "unknown"}\n`);
923
+
924
+ // Check if the CLI needs updating
925
+ const cmp = currentTag ? compareVersions(latestTag, currentTag) : null;
926
+ if (cmp !== null && cmp > 0) {
927
+ console.log(`🔄 Updating CLI to ${latestTag}...`);
928
+ const installResult = spawnSync(
929
+ "bun",
930
+ ["install", "-g", `vellum@${latestVersion}`],
931
+ { stdio: "inherit" },
932
+ );
933
+ if (installResult.error || installResult.status !== 0) {
934
+ const detail =
935
+ installResult.error?.message ?? `exited with code ${installResult.status}`;
936
+ console.error(`\n❌ CLI self-update failed: ${detail}`);
937
+ emitCliError("CLI_UPDATE_FAILED", "CLI self-update failed", detail);
938
+ process.exit(1);
939
+ }
940
+ console.log(`✅ CLI updated to ${latestTag}\n`);
941
+
942
+ // Re-exec with the updated CLI. Pass --version instead of --latest
943
+ // to avoid re-fetching and to prevent infinite re-exec loops.
944
+ const reexecArgs = ["upgrade"];
945
+ if (name) reexecArgs.push(name);
946
+ reexecArgs.push("--version", latestTag);
947
+
948
+ console.log(`🚀 Re-running upgrade with updated CLI...\n`);
949
+ const reexecResult = spawnSync("vellum", reexecArgs, {
950
+ stdio: "inherit",
951
+ });
952
+ process.exit(reexecResult.status ?? 1);
953
+ }
954
+
955
+ if (cmp !== null && cmp === 0) {
956
+ console.log(`✅ CLI is already on the latest version (${latestTag})\n`);
957
+ }
958
+
959
+ return latestTag;
960
+ }
961
+
870
962
  export async function upgrade(): Promise<void> {
871
- const { name, version, prepare, finalize } = parseArgs();
963
+ const { name, version, latest, prepare, finalize } = parseArgs();
872
964
  const entry = resolveTargetAssistant(name);
873
965
 
874
966
  if (prepare) {
@@ -881,16 +973,25 @@ export async function upgrade(): Promise<void> {
881
973
  return;
882
974
  }
883
975
 
976
+ // When --latest is passed, resolve the target from the platform API and
977
+ // self-update the CLI if it's behind. The resolved version is then used
978
+ // as the explicit target for the rest of the upgrade flow.
979
+ let effectiveVersion = version;
980
+ if (latest) {
981
+ const latestTag = await resolveLatestAndMaybeSelfUpdate(name);
982
+ effectiveVersion = latestTag;
983
+ }
984
+
884
985
  const cloud = resolveCloud(entry);
885
986
 
886
987
  try {
887
988
  if (cloud === "docker") {
888
- await upgradeDocker(entry, version);
989
+ await upgradeDocker(entry, effectiveVersion);
889
990
  return;
890
991
  }
891
992
 
892
993
  if (cloud === "vellum") {
893
- await upgradePlatform(entry, version);
994
+ await upgradePlatform(entry, effectiveVersion);
894
995
  return;
895
996
  }
896
997
  } catch (err) {