@vellumai/cli 0.5.16 → 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.
- package/bun.lock +46 -52
- package/package.json +1 -1
- package/src/__tests__/teleport.test.ts +430 -4
- package/src/__tests__/version-compat.test.ts +206 -0
- package/src/commands/backup.ts +1 -15
- package/src/commands/events.ts +146 -0
- package/src/commands/message.ts +105 -0
- package/src/commands/restore.ts +1 -21
- package/src/commands/retire.ts +2 -7
- package/src/commands/rollback.ts +14 -37
- package/src/commands/teleport.ts +125 -65
- package/src/commands/upgrade.ts +50 -43
- package/src/index.ts +6 -0
- package/src/lib/arg-utils.ts +13 -0
- package/src/lib/assistant-client.ts +228 -0
- package/src/lib/aws.ts +2 -1
- package/src/lib/constants.ts +0 -11
- package/src/lib/docker.ts +168 -62
- package/src/lib/gcp.ts +2 -5
- package/src/lib/hatch-local.ts +5 -2
- package/src/lib/health-check.ts +3 -8
- package/src/lib/ngrok.ts +11 -1
- package/src/lib/platform-client.ts +191 -36
- package/src/lib/upgrade-lifecycle.ts +13 -15
- package/src/lib/version-compat.ts +67 -5
- package/src/shared/provider-env-vars.ts +19 -0
package/src/lib/hatch-local.ts
CHANGED
|
@@ -308,10 +308,13 @@ export async function hatchLocal(
|
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
// Lease a guardian token so the desktop app can import it on first launch
|
|
311
|
-
// instead of hitting /v1/guardian/init itself.
|
|
311
|
+
// instead of hitting /v1/guardian/init itself. Use loopback to satisfy
|
|
312
|
+
// the daemon's local-only check — the mDNS runtimeUrl resolves to a LAN
|
|
313
|
+
// IP which the daemon rejects as non-loopback.
|
|
312
314
|
emitProgress(6, 7, "Securing connection...");
|
|
315
|
+
const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
313
316
|
try {
|
|
314
|
-
await leaseGuardianToken(
|
|
317
|
+
await leaseGuardianToken(loopbackUrl, instanceName);
|
|
315
318
|
} catch (err) {
|
|
316
319
|
console.error(`⚠️ Guardian token lease failed: ${err}`);
|
|
317
320
|
}
|
package/src/lib/health-check.ts
CHANGED
|
@@ -25,10 +25,10 @@ export async function checkManagedHealth(
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
let
|
|
28
|
+
let headers: Record<string, string>;
|
|
29
29
|
try {
|
|
30
|
-
const {
|
|
31
|
-
|
|
30
|
+
const { authHeaders } = await import("./platform-client.js");
|
|
31
|
+
headers = await authHeaders(token, runtimeUrl);
|
|
32
32
|
} catch (err) {
|
|
33
33
|
return {
|
|
34
34
|
status: "error (auth)",
|
|
@@ -44,11 +44,6 @@ export async function checkManagedHealth(
|
|
|
44
44
|
HEALTH_CHECK_TIMEOUT_MS,
|
|
45
45
|
);
|
|
46
46
|
|
|
47
|
-
const headers: Record<string, string> = {
|
|
48
|
-
"X-Session-Token": token,
|
|
49
|
-
"Vellum-Organization-Id": orgId,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
47
|
const response = await fetch(url, {
|
|
53
48
|
signal: controller.signal,
|
|
54
49
|
headers,
|
package/src/lib/ngrok.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import {
|
|
3
|
+
closeSync,
|
|
3
4
|
existsSync,
|
|
4
5
|
mkdirSync,
|
|
5
6
|
openSync,
|
|
@@ -130,10 +131,11 @@ export function startNgrokProcess(
|
|
|
130
131
|
logFilePath?: string,
|
|
131
132
|
): ChildProcess {
|
|
132
133
|
let stdio: ("ignore" | "pipe" | number)[] = ["ignore", "pipe", "pipe"];
|
|
134
|
+
let fd: number | undefined;
|
|
133
135
|
if (logFilePath) {
|
|
134
136
|
const dir = dirname(logFilePath);
|
|
135
137
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
136
|
-
|
|
138
|
+
fd = openSync(logFilePath, "a");
|
|
137
139
|
stdio = ["ignore", fd, fd];
|
|
138
140
|
}
|
|
139
141
|
|
|
@@ -141,6 +143,14 @@ export function startNgrokProcess(
|
|
|
141
143
|
detached: true,
|
|
142
144
|
stdio,
|
|
143
145
|
});
|
|
146
|
+
|
|
147
|
+
// The child process inherits a duplicate of the fd via dup2, so the
|
|
148
|
+
// parent's copy is no longer needed. Close it to avoid leaking the
|
|
149
|
+
// file descriptor for the lifetime of the parent process.
|
|
150
|
+
if (fd !== undefined) {
|
|
151
|
+
closeSync(fd);
|
|
152
|
+
}
|
|
153
|
+
|
|
144
154
|
return child;
|
|
145
155
|
}
|
|
146
156
|
|
|
@@ -68,34 +68,94 @@ export function clearPlatformToken(): void {
|
|
|
68
68
|
const VAK_PREFIX = "vak_";
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
*
|
|
71
|
+
* Sync helper – returns only the token-based auth header.
|
|
72
72
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
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;
|
|
87
146
|
status: string;
|
|
88
147
|
}
|
|
89
148
|
|
|
90
|
-
export async function hatchAssistant(
|
|
91
|
-
|
|
149
|
+
export async function hatchAssistant(
|
|
150
|
+
token: string,
|
|
151
|
+
platformUrl?: string,
|
|
152
|
+
): Promise<HatchedAssistant> {
|
|
153
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
154
|
+
const url = `${resolvedUrl}/v1/assistants/hatch/`;
|
|
92
155
|
|
|
93
156
|
const response = await fetch(url, {
|
|
94
157
|
method: "POST",
|
|
95
|
-
headers:
|
|
96
|
-
"Content-Type": "application/json",
|
|
97
|
-
...authHeaders(token),
|
|
98
|
-
},
|
|
158
|
+
headers: await authHeaders(token, platformUrl),
|
|
99
159
|
body: JSON.stringify({}),
|
|
100
160
|
});
|
|
101
161
|
|
|
@@ -143,7 +203,7 @@ export async function fetchOrganizationId(
|
|
|
143
203
|
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
144
204
|
const url = `${resolvedUrl}/v1/organizations/`;
|
|
145
205
|
const response = await fetch(url, {
|
|
146
|
-
headers: {
|
|
206
|
+
headers: { ...tokenAuthHeader(token) },
|
|
147
207
|
});
|
|
148
208
|
|
|
149
209
|
if (!response.ok) {
|
|
@@ -204,18 +264,13 @@ export async function fetchCurrentUser(
|
|
|
204
264
|
|
|
205
265
|
export async function rollbackPlatformAssistant(
|
|
206
266
|
token: string,
|
|
207
|
-
orgId: string,
|
|
208
267
|
version?: string,
|
|
209
268
|
platformUrl?: string,
|
|
210
269
|
): Promise<{ detail: string; version: string | null }> {
|
|
211
270
|
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
212
271
|
const response = await fetch(`${resolvedUrl}/v1/assistants/rollback/`, {
|
|
213
272
|
method: "POST",
|
|
214
|
-
headers:
|
|
215
|
-
"Content-Type": "application/json",
|
|
216
|
-
"X-Session-Token": token,
|
|
217
|
-
"Vellum-Organization-Id": orgId,
|
|
218
|
-
},
|
|
273
|
+
headers: await authHeaders(token, platformUrl),
|
|
219
274
|
body: JSON.stringify(version ? { version } : {}),
|
|
220
275
|
});
|
|
221
276
|
|
|
@@ -249,18 +304,13 @@ export async function rollbackPlatformAssistant(
|
|
|
249
304
|
|
|
250
305
|
export async function platformInitiateExport(
|
|
251
306
|
token: string,
|
|
252
|
-
orgId: string,
|
|
253
307
|
description?: string,
|
|
254
308
|
platformUrl?: string,
|
|
255
309
|
): Promise<{ jobId: string; status: string }> {
|
|
256
310
|
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
257
311
|
const response = await fetch(`${resolvedUrl}/v1/migrations/export/`, {
|
|
258
312
|
method: "POST",
|
|
259
|
-
headers:
|
|
260
|
-
"Content-Type": "application/json",
|
|
261
|
-
"X-Session-Token": token,
|
|
262
|
-
"Vellum-Organization-Id": orgId,
|
|
263
|
-
},
|
|
313
|
+
headers: await authHeaders(token, platformUrl),
|
|
264
314
|
body: JSON.stringify({ description: description ?? "CLI backup" }),
|
|
265
315
|
});
|
|
266
316
|
|
|
@@ -284,17 +334,13 @@ export async function platformInitiateExport(
|
|
|
284
334
|
export async function platformPollExportStatus(
|
|
285
335
|
jobId: string,
|
|
286
336
|
token: string,
|
|
287
|
-
orgId: string,
|
|
288
337
|
platformUrl?: string,
|
|
289
338
|
): Promise<{ status: string; downloadUrl?: string; error?: string }> {
|
|
290
339
|
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
291
340
|
const response = await fetch(
|
|
292
341
|
`${resolvedUrl}/v1/migrations/export/${jobId}/status/`,
|
|
293
342
|
{
|
|
294
|
-
headers:
|
|
295
|
-
"X-Session-Token": token,
|
|
296
|
-
"Vellum-Organization-Id": orgId,
|
|
297
|
-
},
|
|
343
|
+
headers: await authHeaders(token, platformUrl),
|
|
298
344
|
},
|
|
299
345
|
);
|
|
300
346
|
|
|
@@ -339,7 +385,6 @@ export async function platformDownloadExport(
|
|
|
339
385
|
export async function platformImportPreflight(
|
|
340
386
|
bundleData: Uint8Array<ArrayBuffer>,
|
|
341
387
|
token: string,
|
|
342
|
-
orgId: string,
|
|
343
388
|
platformUrl?: string,
|
|
344
389
|
): Promise<{ statusCode: number; body: Record<string, unknown> }> {
|
|
345
390
|
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
@@ -348,9 +393,8 @@ export async function platformImportPreflight(
|
|
|
348
393
|
{
|
|
349
394
|
method: "POST",
|
|
350
395
|
headers: {
|
|
396
|
+
...(await authHeaders(token, platformUrl)),
|
|
351
397
|
"Content-Type": "application/octet-stream",
|
|
352
|
-
"X-Session-Token": token,
|
|
353
|
-
"Vellum-Organization-Id": orgId,
|
|
354
398
|
},
|
|
355
399
|
body: new Blob([bundleData]),
|
|
356
400
|
signal: AbortSignal.timeout(120_000),
|
|
@@ -367,19 +411,17 @@ export async function platformImportPreflight(
|
|
|
367
411
|
export async function platformImportBundle(
|
|
368
412
|
bundleData: Uint8Array<ArrayBuffer>,
|
|
369
413
|
token: string,
|
|
370
|
-
orgId: string,
|
|
371
414
|
platformUrl?: string,
|
|
372
415
|
): Promise<{ statusCode: number; body: Record<string, unknown> }> {
|
|
373
416
|
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
374
417
|
const response = await fetch(`${resolvedUrl}/v1/migrations/import/`, {
|
|
375
418
|
method: "POST",
|
|
376
419
|
headers: {
|
|
420
|
+
...(await authHeaders(token, platformUrl)),
|
|
377
421
|
"Content-Type": "application/octet-stream",
|
|
378
|
-
"X-Session-Token": token,
|
|
379
|
-
"Vellum-Organization-Id": orgId,
|
|
380
422
|
},
|
|
381
423
|
body: new Blob([bundleData]),
|
|
382
|
-
signal: AbortSignal.timeout(
|
|
424
|
+
signal: AbortSignal.timeout(300_000),
|
|
383
425
|
});
|
|
384
426
|
|
|
385
427
|
const body = (await response.json().catch(() => ({}))) as Record<
|
|
@@ -388,3 +430,116 @@ export async function platformImportBundle(
|
|
|
388
430
|
>;
|
|
389
431
|
return { statusCode: response.status, body };
|
|
390
432
|
}
|
|
433
|
+
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// Signed-URL upload flow
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
export async function platformRequestUploadUrl(
|
|
439
|
+
token: string,
|
|
440
|
+
platformUrl?: string,
|
|
441
|
+
): Promise<{ uploadUrl: string; bundleKey: string; expiresAt: string }> {
|
|
442
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
443
|
+
const response = await fetch(`${resolvedUrl}/v1/migrations/upload-url/`, {
|
|
444
|
+
method: "POST",
|
|
445
|
+
headers: await authHeaders(token, platformUrl),
|
|
446
|
+
body: JSON.stringify({ content_type: "application/octet-stream" }),
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
if (response.status === 201) {
|
|
450
|
+
const body = (await response.json()) as {
|
|
451
|
+
upload_url: string;
|
|
452
|
+
bundle_key: string;
|
|
453
|
+
expires_at: string;
|
|
454
|
+
};
|
|
455
|
+
return {
|
|
456
|
+
uploadUrl: body.upload_url,
|
|
457
|
+
bundleKey: body.bundle_key,
|
|
458
|
+
expiresAt: body.expires_at,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (response.status === 404 || response.status === 503) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
"Signed uploads are not available on this platform instance",
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const errorBody = (await response.json().catch(() => ({}))) as {
|
|
469
|
+
detail?: string;
|
|
470
|
+
};
|
|
471
|
+
throw new Error(
|
|
472
|
+
errorBody.detail ??
|
|
473
|
+
`Failed to request upload URL: ${response.status} ${response.statusText}`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export async function platformUploadToSignedUrl(
|
|
478
|
+
uploadUrl: string,
|
|
479
|
+
bundleData: Uint8Array<ArrayBuffer>,
|
|
480
|
+
): Promise<void> {
|
|
481
|
+
const response = await fetch(uploadUrl, {
|
|
482
|
+
method: "PUT",
|
|
483
|
+
headers: {
|
|
484
|
+
"Content-Type": "application/octet-stream",
|
|
485
|
+
},
|
|
486
|
+
body: new Blob([bundleData]),
|
|
487
|
+
signal: AbortSignal.timeout(600_000),
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (!response.ok) {
|
|
491
|
+
throw new Error(
|
|
492
|
+
`Upload to signed URL failed: ${response.status} ${response.statusText}`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function platformImportPreflightFromGcs(
|
|
498
|
+
bundleKey: string,
|
|
499
|
+
token: string,
|
|
500
|
+
platformUrl?: string,
|
|
501
|
+
): Promise<{ statusCode: number; body: Record<string, unknown> }> {
|
|
502
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
503
|
+
const response = await fetch(
|
|
504
|
+
`${resolvedUrl}/v1/migrations/import-preflight-from-gcs/`,
|
|
505
|
+
{
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: await authHeaders(token, platformUrl),
|
|
508
|
+
signal: AbortSignal.timeout(120_000),
|
|
509
|
+
body: JSON.stringify({ bundle_key: bundleKey }),
|
|
510
|
+
},
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
const body = (await response.json().catch(() => ({}))) as Record<
|
|
514
|
+
string,
|
|
515
|
+
unknown
|
|
516
|
+
>;
|
|
517
|
+
return { statusCode: response.status, body };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export async function platformImportBundleFromGcs(
|
|
521
|
+
bundleKey: string,
|
|
522
|
+
token: string,
|
|
523
|
+
platformUrl?: string,
|
|
524
|
+
): Promise<{ statusCode: number; body: Record<string, unknown> }> {
|
|
525
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
526
|
+
const response = await fetch(
|
|
527
|
+
`${resolvedUrl}/v1/migrations/import-from-gcs/`,
|
|
528
|
+
{
|
|
529
|
+
method: "POST",
|
|
530
|
+
headers: await authHeaders(token, platformUrl),
|
|
531
|
+
body: JSON.stringify({ bundle_key: bundleKey }),
|
|
532
|
+
signal: AbortSignal.timeout(300_000),
|
|
533
|
+
},
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
if (response.status === 413) {
|
|
537
|
+
throw new Error("Bundle too large to import");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const body = (await response.json().catch(() => ({}))) as Record<
|
|
541
|
+
string,
|
|
542
|
+
unknown
|
|
543
|
+
>;
|
|
544
|
+
return { statusCode: response.status, body };
|
|
545
|
+
}
|
|
@@ -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 {
|
|
19
|
+
import { compareVersions } from "./version-compat.js";
|
|
20
20
|
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
// Shared constants & builders for upgrade / rollback lifecycle events
|
|
@@ -90,6 +90,7 @@ export function buildUpgradeCommitMessage(options: {
|
|
|
90
90
|
*/
|
|
91
91
|
export const CONTAINER_ENV_EXCLUDE_KEYS: ReadonlySet<string> = new Set([
|
|
92
92
|
"CES_SERVICE_TOKEN",
|
|
93
|
+
"GUARDIAN_BOOTSTRAP_SECRET",
|
|
93
94
|
"VELLUM_ASSISTANT_NAME",
|
|
94
95
|
"RUNTIME_HTTP_HOST",
|
|
95
96
|
"PATH",
|
|
@@ -317,26 +318,16 @@ export async function performDockerRollback(
|
|
|
317
318
|
|
|
318
319
|
// Validate target version < current version
|
|
319
320
|
if (currentVersion) {
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const isNewer = (() => {
|
|
324
|
-
if (target.major !== current.major) return target.major > current.major;
|
|
325
|
-
if (target.minor !== current.minor) return target.minor > current.minor;
|
|
326
|
-
return target.patch > current.patch;
|
|
327
|
-
})();
|
|
328
|
-
if (isNewer) {
|
|
321
|
+
const cmp = compareVersions(targetVersion, currentVersion);
|
|
322
|
+
if (cmp !== null) {
|
|
323
|
+
if (cmp > 0) {
|
|
329
324
|
const msg =
|
|
330
325
|
"Cannot roll back to a newer version. Use `vellum upgrade` instead.";
|
|
331
326
|
console.error(msg);
|
|
332
327
|
emitCliError("VERSION_DIRECTION", msg);
|
|
333
328
|
process.exit(1);
|
|
334
329
|
}
|
|
335
|
-
|
|
336
|
-
target.major === current.major &&
|
|
337
|
-
target.minor === current.minor &&
|
|
338
|
-
target.patch === current.patch;
|
|
339
|
-
if (isSame) {
|
|
330
|
+
if (cmp === 0) {
|
|
340
331
|
const msg = `Already on version ${targetVersion}. Nothing to roll back to.`;
|
|
341
332
|
console.error(msg);
|
|
342
333
|
emitCliError("VERSION_DIRECTION", msg);
|
|
@@ -467,6 +458,11 @@ export async function performDockerRollback(
|
|
|
467
458
|
` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
|
|
468
459
|
);
|
|
469
460
|
|
|
461
|
+
// Capture GUARDIAN_BOOTSTRAP_SECRET from the gateway container (it is only
|
|
462
|
+
// set on gateway, not assistant) so it persists across container restarts.
|
|
463
|
+
const gatewayEnv = await captureContainerEnv(res.gatewayContainer);
|
|
464
|
+
const bootstrapSecret = gatewayEnv["GUARDIAN_BOOTSTRAP_SECRET"];
|
|
465
|
+
|
|
470
466
|
const cesServiceToken =
|
|
471
467
|
capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
|
|
472
468
|
|
|
@@ -575,6 +571,7 @@ export async function performDockerRollback(
|
|
|
575
571
|
await startContainers(
|
|
576
572
|
{
|
|
577
573
|
signingKey,
|
|
574
|
+
bootstrapSecret,
|
|
578
575
|
cesServiceToken,
|
|
579
576
|
extraAssistantEnv,
|
|
580
577
|
gatewayPort,
|
|
@@ -695,6 +692,7 @@ export async function performDockerRollback(
|
|
|
695
692
|
await startContainers(
|
|
696
693
|
{
|
|
697
694
|
signingKey,
|
|
695
|
+
bootstrapSecret,
|
|
698
696
|
cesServiceToken,
|
|
699
697
|
extraAssistantEnv,
|
|
700
698
|
gatewayPort,
|
|
@@ -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
|
|
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
|
/**
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider API key environment variable names, keyed by provider ID.
|
|
3
|
+
*
|
|
4
|
+
* Keep in sync with:
|
|
5
|
+
* - assistant/src/shared/provider-env-vars.ts
|
|
6
|
+
* - meta/provider-env-vars.json (consumed by the macOS client build)
|
|
7
|
+
*
|
|
8
|
+
* Once a consolidated shared package exists in packages/, all three
|
|
9
|
+
* copies can be replaced by a single import.
|
|
10
|
+
*/
|
|
11
|
+
export const PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
|
|
12
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
13
|
+
openai: "OPENAI_API_KEY",
|
|
14
|
+
gemini: "GEMINI_API_KEY",
|
|
15
|
+
fireworks: "FIREWORKS_API_KEY",
|
|
16
|
+
openrouter: "OPENROUTER_API_KEY",
|
|
17
|
+
brave: "BRAVE_API_KEY",
|
|
18
|
+
perplexity: "PERPLEXITY_API_KEY",
|
|
19
|
+
};
|