@vellumai/cli 0.6.0 → 0.6.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.
@@ -68,19 +68,78 @@ export function clearPlatformToken(): void {
68
68
  const VAK_PREFIX = "vak_";
69
69
 
70
70
  /**
71
- * Returns the appropriate auth header for the given platform token.
71
+ * Sync helper returns only the token-based auth header.
72
72
  *
73
- * - `vak_`-prefixed tokens are long-lived platform API keys and use
74
- * `Authorization: Bearer`.
75
- * - All other tokens are allauth session tokens and use `X-Session-Token`.
73
+ * Used internally by {@link fetchOrganizationId} (which cannot call the
74
+ * async {@link authHeaders} without creating a cycle) and by functions
75
+ * that already have an org ID in hand.
76
76
  */
77
- export function authHeaders(token: string): Record<string, string> {
77
+ function tokenAuthHeader(token: string): Record<string, string> {
78
78
  if (token.startsWith(VAK_PREFIX)) {
79
79
  return { Authorization: `Bearer ${token}` };
80
80
  }
81
81
  return { "X-Session-Token": token };
82
82
  }
83
83
 
84
+ /** Module-level cache for org IDs to avoid redundant fetches in polling loops. */
85
+ const orgIdCache = new Map<string, { orgId: string; expiresAt: number }>();
86
+ const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds
87
+
88
+ /**
89
+ * Returns the full set of headers needed for an authenticated platform
90
+ * API request:
91
+ *
92
+ * - `Content-Type: application/json`
93
+ * - The appropriate auth header (`Authorization: Bearer` for `vak_`
94
+ * API keys, `X-Session-Token` for session tokens).
95
+ * - `Vellum-Organization-Id` – fetched from the platform. Only
96
+ * included for session-token callers; API keys are already org-scoped.
97
+ *
98
+ * The org ID is cached per (token, platformUrl) for 60 seconds to avoid
99
+ * redundant HTTP requests in tight polling loops.
100
+ *
101
+ * Auth errors (401 / 403) from the org-ID fetch are logged with a
102
+ * user-friendly message before re-throwing, so callers don't need to
103
+ * repeat that logic.
104
+ */
105
+ export async function authHeaders(
106
+ token: string,
107
+ platformUrl?: string,
108
+ ): Promise<Record<string, string>> {
109
+ const base: Record<string, string> = {
110
+ "Content-Type": "application/json",
111
+ ...tokenAuthHeader(token),
112
+ };
113
+
114
+ if (token.startsWith(VAK_PREFIX)) {
115
+ // API keys are org-scoped – no need to fetch the org ID.
116
+ return base;
117
+ }
118
+
119
+ const cacheKey = `${token}::${platformUrl ?? ""}`;
120
+ const cached = orgIdCache.get(cacheKey);
121
+ if (cached && Date.now() < cached.expiresAt) {
122
+ return { ...base, "Vellum-Organization-Id": cached.orgId };
123
+ }
124
+
125
+ try {
126
+ const orgId = await fetchOrganizationId(token, platformUrl);
127
+ orgIdCache.set(cacheKey, {
128
+ orgId,
129
+ expiresAt: Date.now() + ORG_ID_CACHE_TTL_MS,
130
+ });
131
+ return { ...base, "Vellum-Organization-Id": orgId };
132
+ } catch (err) {
133
+ const msg = err instanceof Error ? err.message : String(err);
134
+ if (msg.includes("401") || msg.includes("403")) {
135
+ console.error("Authentication failed. Run 'vellum login' to refresh.");
136
+ } else {
137
+ console.error(`Failed to fetch organization: ${msg}`);
138
+ }
139
+ throw err;
140
+ }
141
+ }
142
+
84
143
  export interface HatchedAssistant {
85
144
  id: string;
86
145
  name: string;
@@ -89,7 +148,6 @@ export interface HatchedAssistant {
89
148
 
90
149
  export async function hatchAssistant(
91
150
  token: string,
92
- orgId: string,
93
151
  platformUrl?: string,
94
152
  ): Promise<HatchedAssistant> {
95
153
  const resolvedUrl = platformUrl || getPlatformUrl();
@@ -97,11 +155,7 @@ export async function hatchAssistant(
97
155
 
98
156
  const response = await fetch(url, {
99
157
  method: "POST",
100
- headers: {
101
- "Content-Type": "application/json",
102
- ...authHeaders(token),
103
- "Vellum-Organization-Id": orgId,
104
- },
158
+ headers: await authHeaders(token, platformUrl),
105
159
  body: JSON.stringify({}),
106
160
  });
107
161
 
@@ -149,7 +203,7 @@ export async function fetchOrganizationId(
149
203
  const resolvedUrl = platformUrl || getPlatformUrl();
150
204
  const url = `${resolvedUrl}/v1/organizations/`;
151
205
  const response = await fetch(url, {
152
- headers: { ...authHeaders(token) },
206
+ headers: { ...tokenAuthHeader(token) },
153
207
  });
154
208
 
155
209
  if (!response.ok) {
@@ -210,18 +264,13 @@ export async function fetchCurrentUser(
210
264
 
211
265
  export async function rollbackPlatformAssistant(
212
266
  token: string,
213
- orgId: string,
214
267
  version?: string,
215
268
  platformUrl?: string,
216
269
  ): Promise<{ detail: string; version: string | null }> {
217
270
  const resolvedUrl = platformUrl || getPlatformUrl();
218
271
  const response = await fetch(`${resolvedUrl}/v1/assistants/rollback/`, {
219
272
  method: "POST",
220
- headers: {
221
- "Content-Type": "application/json",
222
- ...authHeaders(token),
223
- "Vellum-Organization-Id": orgId,
224
- },
273
+ headers: await authHeaders(token, platformUrl),
225
274
  body: JSON.stringify(version ? { version } : {}),
226
275
  });
227
276
 
@@ -255,18 +304,13 @@ export async function rollbackPlatformAssistant(
255
304
 
256
305
  export async function platformInitiateExport(
257
306
  token: string,
258
- orgId: string,
259
307
  description?: string,
260
308
  platformUrl?: string,
261
309
  ): Promise<{ jobId: string; status: string }> {
262
310
  const resolvedUrl = platformUrl || getPlatformUrl();
263
311
  const response = await fetch(`${resolvedUrl}/v1/migrations/export/`, {
264
312
  method: "POST",
265
- headers: {
266
- "Content-Type": "application/json",
267
- ...authHeaders(token),
268
- "Vellum-Organization-Id": orgId,
269
- },
313
+ headers: await authHeaders(token, platformUrl),
270
314
  body: JSON.stringify({ description: description ?? "CLI backup" }),
271
315
  });
272
316
 
@@ -290,17 +334,13 @@ export async function platformInitiateExport(
290
334
  export async function platformPollExportStatus(
291
335
  jobId: string,
292
336
  token: string,
293
- orgId: string,
294
337
  platformUrl?: string,
295
338
  ): Promise<{ status: string; downloadUrl?: string; error?: string }> {
296
339
  const resolvedUrl = platformUrl || getPlatformUrl();
297
340
  const response = await fetch(
298
341
  `${resolvedUrl}/v1/migrations/export/${jobId}/status/`,
299
342
  {
300
- headers: {
301
- ...authHeaders(token),
302
- "Vellum-Organization-Id": orgId,
303
- },
343
+ headers: await authHeaders(token, platformUrl),
304
344
  },
305
345
  );
306
346
 
@@ -345,7 +385,6 @@ export async function platformDownloadExport(
345
385
  export async function platformImportPreflight(
346
386
  bundleData: Uint8Array<ArrayBuffer>,
347
387
  token: string,
348
- orgId: string,
349
388
  platformUrl?: string,
350
389
  ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
351
390
  const resolvedUrl = platformUrl || getPlatformUrl();
@@ -354,9 +393,8 @@ export async function platformImportPreflight(
354
393
  {
355
394
  method: "POST",
356
395
  headers: {
396
+ ...(await authHeaders(token, platformUrl)),
357
397
  "Content-Type": "application/octet-stream",
358
- ...authHeaders(token),
359
- "Vellum-Organization-Id": orgId,
360
398
  },
361
399
  body: new Blob([bundleData]),
362
400
  signal: AbortSignal.timeout(120_000),
@@ -373,19 +411,17 @@ export async function platformImportPreflight(
373
411
  export async function platformImportBundle(
374
412
  bundleData: Uint8Array<ArrayBuffer>,
375
413
  token: string,
376
- orgId: string,
377
414
  platformUrl?: string,
378
415
  ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
379
416
  const resolvedUrl = platformUrl || getPlatformUrl();
380
417
  const response = await fetch(`${resolvedUrl}/v1/migrations/import/`, {
381
418
  method: "POST",
382
419
  headers: {
420
+ ...(await authHeaders(token, platformUrl)),
383
421
  "Content-Type": "application/octet-stream",
384
- ...authHeaders(token),
385
- "Vellum-Organization-Id": orgId,
386
422
  },
387
423
  body: new Blob([bundleData]),
388
- signal: AbortSignal.timeout(120_000),
424
+ signal: AbortSignal.timeout(300_000),
389
425
  });
390
426
 
391
427
  const body = (await response.json().catch(() => ({}))) as Record<
@@ -401,17 +437,12 @@ export async function platformImportBundle(
401
437
 
402
438
  export async function platformRequestUploadUrl(
403
439
  token: string,
404
- orgId: string,
405
440
  platformUrl?: string,
406
441
  ): Promise<{ uploadUrl: string; bundleKey: string; expiresAt: string }> {
407
442
  const resolvedUrl = platformUrl || getPlatformUrl();
408
443
  const response = await fetch(`${resolvedUrl}/v1/migrations/upload-url/`, {
409
444
  method: "POST",
410
- headers: {
411
- "Content-Type": "application/json",
412
- ...authHeaders(token),
413
- "Vellum-Organization-Id": orgId,
414
- },
445
+ headers: await authHeaders(token, platformUrl),
415
446
  body: JSON.stringify({ content_type: "application/octet-stream" }),
416
447
  });
417
448
 
@@ -466,7 +497,6 @@ export async function platformUploadToSignedUrl(
466
497
  export async function platformImportPreflightFromGcs(
467
498
  bundleKey: string,
468
499
  token: string,
469
- orgId: string,
470
500
  platformUrl?: string,
471
501
  ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
472
502
  const resolvedUrl = platformUrl || getPlatformUrl();
@@ -474,13 +504,9 @@ export async function platformImportPreflightFromGcs(
474
504
  `${resolvedUrl}/v1/migrations/import-preflight-from-gcs/`,
475
505
  {
476
506
  method: "POST",
477
- headers: {
478
- "Content-Type": "application/json",
479
- ...authHeaders(token),
480
- "Vellum-Organization-Id": orgId,
481
- },
482
- body: JSON.stringify({ bundle_key: bundleKey }),
507
+ headers: await authHeaders(token, platformUrl),
483
508
  signal: AbortSignal.timeout(120_000),
509
+ body: JSON.stringify({ bundle_key: bundleKey }),
484
510
  },
485
511
  );
486
512
 
@@ -494,7 +520,6 @@ export async function platformImportPreflightFromGcs(
494
520
  export async function platformImportBundleFromGcs(
495
521
  bundleKey: string,
496
522
  token: string,
497
- orgId: string,
498
523
  platformUrl?: string,
499
524
  ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
500
525
  const resolvedUrl = platformUrl || getPlatformUrl();
@@ -502,13 +527,9 @@ export async function platformImportBundleFromGcs(
502
527
  `${resolvedUrl}/v1/migrations/import-from-gcs/`,
503
528
  {
504
529
  method: "POST",
505
- headers: {
506
- "Content-Type": "application/json",
507
- ...authHeaders(token),
508
- "Vellum-Organization-Id": orgId,
509
- },
530
+ headers: await authHeaders(token, platformUrl),
510
531
  body: JSON.stringify({ bundle_key: bundleKey }),
511
- signal: AbortSignal.timeout(120_000),
532
+ signal: AbortSignal.timeout(300_000),
512
533
  },
513
534
  );
514
535
 
@@ -16,7 +16,7 @@ import { loadGuardianToken } from "./guardian-token.js";
16
16
  import { getPlatformUrl } from "./platform-client.js";
17
17
  import { resolveImageRefs } from "./platform-releases.js";
18
18
  import { exec, execOutput } from "./step-runner.js";
19
- import { parseVersion } from "./version-compat.js";
19
+ import { compareVersions } from "./version-compat.js";
20
20
 
21
21
  // ---------------------------------------------------------------------------
22
22
  // Shared constants & builders for upgrade / rollback lifecycle events
@@ -318,26 +318,16 @@ export async function performDockerRollback(
318
318
 
319
319
  // Validate target version < current version
320
320
  if (currentVersion) {
321
- const current = parseVersion(currentVersion);
322
- const target = parseVersion(targetVersion);
323
- if (current && target) {
324
- const isNewer = (() => {
325
- if (target.major !== current.major) return target.major > current.major;
326
- if (target.minor !== current.minor) return target.minor > current.minor;
327
- return target.patch > current.patch;
328
- })();
329
- if (isNewer) {
321
+ const cmp = compareVersions(targetVersion, currentVersion);
322
+ if (cmp !== null) {
323
+ if (cmp > 0) {
330
324
  const msg =
331
325
  "Cannot roll back to a newer version. Use `vellum upgrade` instead.";
332
326
  console.error(msg);
333
327
  emitCliError("VERSION_DIRECTION", msg);
334
328
  process.exit(1);
335
329
  }
336
- const isSame =
337
- target.major === current.major &&
338
- target.minor === current.minor &&
339
- target.patch === current.patch;
340
- if (isSame) {
330
+ if (cmp === 0) {
341
331
  const msg = `Already on version ${targetVersion}. Nothing to roll back to.`;
342
332
  console.error(msg);
343
333
  emitCliError("VERSION_DIRECTION", msg);
@@ -1,13 +1,16 @@
1
1
  /**
2
- * Parse a version string into { major, minor, patch } components.
3
- * Handles optional `v` prefix (e.g., "v1.2.3" or "1.2.3").
2
+ * Parse a version string into { major, minor, patch, pre } components.
3
+ * Handles optional `v`/`V` prefix (e.g., "v1.2.3" or "1.2.3").
4
+ * Pre-release suffixes are captured (e.g., "0.6.0-staging.5" → pre: "staging.5").
4
5
  * Returns null if the string cannot be parsed as semver.
5
6
  */
6
7
  export function parseVersion(
7
8
  version: string,
8
- ): { major: number; minor: number; patch: number } | null {
9
+ ): { major: number; minor: number; patch: number; pre: string | null } | null {
9
10
  const stripped = version.replace(/^[vV]/, "");
10
- const segments = stripped.split(".");
11
+ const [core, ...rest] = stripped.split("-");
12
+ const pre = rest.length > 0 ? rest.join("-") : null;
13
+ const segments = (core ?? "").split(".");
11
14
 
12
15
  if (segments.length < 2) {
13
16
  return null;
@@ -21,7 +24,66 @@ export function parseVersion(
21
24
  return null;
22
25
  }
23
26
 
24
- return { major, minor, patch };
27
+ return { major, minor, patch, pre };
28
+ }
29
+
30
+ /**
31
+ * Compare two pre-release strings per semver §11:
32
+ * - Dot-separated identifiers compared left to right.
33
+ * - Both numeric → compare as integers.
34
+ * - Both non-numeric → compare lexically.
35
+ * - Numeric vs non-numeric → numeric sorts lower (§11.4.4).
36
+ * - Fewer identifiers sorts earlier when all preceding are equal.
37
+ */
38
+ function comparePreRelease(a: string, b: string): number {
39
+ const pa = a.split(".");
40
+ const pb = b.split(".");
41
+ const len = Math.max(pa.length, pb.length);
42
+ for (let i = 0; i < len; i++) {
43
+ if (i >= pa.length) return -1; // a has fewer fields → a < b
44
+ if (i >= pb.length) return 1;
45
+ const aIsNum = /^\d+$/.test(pa[i]);
46
+ const bIsNum = /^\d+$/.test(pb[i]);
47
+ if (aIsNum && bIsNum) {
48
+ const diff = Number(pa[i]) - Number(pb[i]);
49
+ if (diff !== 0) return diff;
50
+ } else if (aIsNum !== bIsNum) {
51
+ return aIsNum ? -1 : 1; // numeric < non-numeric per §11.4.4
52
+ } else {
53
+ const cmp = (pa[i] ?? "").localeCompare(pb[i] ?? "");
54
+ if (cmp !== 0) return cmp;
55
+ }
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ /**
61
+ * Compare two semver version strings.
62
+ * Returns negative if a < b, 0 if equal, positive if a > b.
63
+ *
64
+ * Handles pre-release suffixes per semver spec:
65
+ * - `0.6.0-staging.1 < 0.6.0` (pre-release < release)
66
+ * - `0.6.0-staging.1 < 0.6.0-staging.2` (numeric postfix comparison)
67
+ *
68
+ * Returns null if either version cannot be parsed.
69
+ */
70
+ export function compareVersions(a: string, b: string): number | null {
71
+ const pa = parseVersion(a);
72
+ const pb = parseVersion(b);
73
+ if (pa === null || pb === null) return null;
74
+
75
+ const majorDiff = pa.major - pb.major;
76
+ if (majorDiff !== 0) return majorDiff;
77
+ const minorDiff = pa.minor - pb.minor;
78
+ if (minorDiff !== 0) return minorDiff;
79
+ const patchDiff = pa.patch - pb.patch;
80
+ if (patchDiff !== 0) return patchDiff;
81
+
82
+ // Same major.minor.patch — compare pre-release
83
+ if (pa.pre === null && pb.pre === null) return 0;
84
+ if (pa.pre !== null && pb.pre === null) return -1; // pre-release < release
85
+ if (pa.pre === null && pb.pre !== null) return 1;
86
+ return comparePreRelease(pa.pre!, pb.pre!);
25
87
  }
26
88
 
27
89
  /**