@vellumai/cli 0.10.4 → 0.10.5-dev.202607022329.d89bf2f

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.
@@ -20,7 +20,9 @@ import { getConfigDir } from "../lib/environments/paths.js";
20
20
  import { getCurrentEnvironment } from "../lib/environments/resolve.js";
21
21
  import {
22
22
  authHeaders,
23
+ authHeadersForKnownOrganization,
23
24
  getPlatformUrl,
25
+ readGatewayCredential,
24
26
  readPlatformToken,
25
27
  } from "../lib/platform-client.js";
26
28
  import { retireInstance as retireAwsInstance } from "../lib/aws.js";
@@ -45,6 +47,180 @@ interface RetireArgs {
45
47
  yes: boolean;
46
48
  }
47
49
 
50
+ interface SelfHostedPlatformRegistration {
51
+ platformAssistantId: string;
52
+ platformBaseUrl: string | null;
53
+ organizationId: string | null;
54
+ }
55
+
56
+ const PLATFORM_TOKEN_ENV = "VELLUM_PLATFORM_TOKEN";
57
+
58
+ function readPlatformRetireToken(): string | null {
59
+ const envToken = process.env[PLATFORM_TOKEN_ENV]?.trim();
60
+ if (envToken) return envToken;
61
+ return readPlatformToken();
62
+ }
63
+
64
+ function platformOrigin(platformUrl: string): string | null {
65
+ try {
66
+ return new URL(platformUrl).origin;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function resolveTrustedSelfHostedPlatformUrl(
73
+ storedPlatformUrl: string | null,
74
+ ): string | null {
75
+ const configuredPlatformUrl = getPlatformUrl();
76
+ if (!storedPlatformUrl) return configuredPlatformUrl;
77
+
78
+ const storedOrigin = platformOrigin(storedPlatformUrl);
79
+ const configuredOrigin = platformOrigin(configuredPlatformUrl);
80
+ if (storedOrigin && configuredOrigin && storedOrigin === configuredOrigin) {
81
+ return configuredPlatformUrl;
82
+ }
83
+
84
+ console.warn(
85
+ "⚠️ Stored platform URL does not match the configured platform URL; skipping platform unregister.",
86
+ );
87
+ return null;
88
+ }
89
+
90
+ function nonEmptyString(value: string | null | undefined): string | null {
91
+ const trimmed = value?.trim();
92
+ return trimmed ? trimmed : null;
93
+ }
94
+
95
+ function lockfileSelfHostedPlatformRegistration(
96
+ entry: AssistantEntry,
97
+ ): SelfHostedPlatformRegistration | null {
98
+ const platformAssistantId = nonEmptyString(entry.platformAssistantId);
99
+ if (!platformAssistantId) return null;
100
+
101
+ return {
102
+ platformAssistantId,
103
+ platformBaseUrl: nonEmptyString(entry.platformBaseUrl),
104
+ organizationId: nonEmptyString(entry.platformOrganizationId),
105
+ };
106
+ }
107
+
108
+ async function deletePlatformAssistant(
109
+ assistantId: string,
110
+ options: {
111
+ token: string;
112
+ platformUrl?: string;
113
+ organizationId?: string;
114
+ label: string;
115
+ successMessage: string;
116
+ alreadyGoneMessage: string;
117
+ },
118
+ ): Promise<void> {
119
+ const platformUrl = options.platformUrl || getPlatformUrl();
120
+ const url = `${platformUrl}/v1/assistants/${encodeURIComponent(assistantId)}/retire/`;
121
+ const headers = options.organizationId
122
+ ? authHeadersForKnownOrganization(options.token, options.organizationId)
123
+ : await authHeaders(options.token, platformUrl);
124
+
125
+ const response = await loopbackSafeFetch(url, {
126
+ method: "DELETE",
127
+ headers,
128
+ });
129
+
130
+ if (!response.ok && response.status !== 404) {
131
+ const body = await response.text();
132
+ throw new Error(`${options.label} failed (${response.status}): ${body}`);
133
+ }
134
+
135
+ if (response.status === 404) {
136
+ console.log(options.alreadyGoneMessage);
137
+ } else {
138
+ console.log(options.successMessage);
139
+ }
140
+ }
141
+
142
+ async function readSelfHostedPlatformRegistration(
143
+ entry: AssistantEntry,
144
+ ): Promise<SelfHostedPlatformRegistration | null> {
145
+ const fallback = lockfileSelfHostedPlatformRegistration(entry);
146
+ const gatewayUrl = entry.localUrl ?? entry.runtimeUrl;
147
+ const platformAssistant = await readGatewayCredential(
148
+ gatewayUrl,
149
+ "vellum:platform_assistant_id",
150
+ entry.bearerToken,
151
+ );
152
+ if (platformAssistant.unreachable) {
153
+ if (fallback) {
154
+ console.warn(
155
+ "\u26a0\ufe0f Could not read platform registration from the local assistant; using saved platform registration.",
156
+ );
157
+ return fallback;
158
+ }
159
+ console.warn(
160
+ "\u26a0\ufe0f Could not read platform registration from the local assistant and no saved platform registration is available; skipping platform unregister.",
161
+ );
162
+ return null;
163
+ }
164
+
165
+ const platformAssistantId =
166
+ nonEmptyString(platformAssistant.value) ?? fallback?.platformAssistantId;
167
+ if (!platformAssistantId) return null;
168
+
169
+ const [platformBaseUrl, organizationId] = await Promise.all([
170
+ readGatewayCredential(
171
+ gatewayUrl,
172
+ "vellum:platform_base_url",
173
+ entry.bearerToken,
174
+ ),
175
+ readGatewayCredential(
176
+ gatewayUrl,
177
+ "vellum:platform_organization_id",
178
+ entry.bearerToken,
179
+ ),
180
+ ]);
181
+
182
+ return {
183
+ platformAssistantId,
184
+ platformBaseUrl:
185
+ nonEmptyString(platformBaseUrl.value) ??
186
+ fallback?.platformBaseUrl ??
187
+ null,
188
+ organizationId:
189
+ nonEmptyString(organizationId.value) ?? fallback?.organizationId ?? null,
190
+ };
191
+ }
192
+
193
+ async function unregisterSelfHostedLocalPlatformRecord(
194
+ entry: AssistantEntry,
195
+ ): Promise<void> {
196
+ const token = readPlatformRetireToken();
197
+ if (!token) return;
198
+
199
+ const registration = await readSelfHostedPlatformRegistration(entry);
200
+ if (!registration) return;
201
+
202
+ const platformUrl = resolveTrustedSelfHostedPlatformUrl(
203
+ registration.platformBaseUrl,
204
+ );
205
+ if (!platformUrl) return;
206
+ if (!registration.organizationId) {
207
+ console.warn(
208
+ "⚠️ Could not read platform organization ID from the local assistant; skipping platform unregister.",
209
+ );
210
+ return;
211
+ }
212
+
213
+ await deletePlatformAssistant(registration.platformAssistantId, {
214
+ token,
215
+ platformUrl,
216
+ organizationId: registration.organizationId,
217
+ label: "Platform self-hosted unregister",
218
+ successMessage: "\u2705 Platform self-hosted registration unregistered.",
219
+ alreadyGoneMessage:
220
+ "\u2705 Platform self-hosted registration already unregistered (404).",
221
+ });
222
+ }
223
+
48
224
  async function retireCustom(entry: AssistantEntry): Promise<void> {
49
225
  const host = extractHostFromUrl(entry.runtimeUrl);
50
226
  const sshUser = entry.sshUser ?? "root";
@@ -86,7 +262,7 @@ async function retireVellum(
86
262
  ): Promise<void> {
87
263
  console.log("\u{1F5D1}\ufe0f Retiring platform-hosted instance...\n");
88
264
 
89
- const token = readPlatformToken();
265
+ const token = readPlatformRetireToken();
90
266
  if (!token) {
91
267
  console.error(
92
268
  "Error: Not logged in. Run `vellum login --token <token>` first.",
@@ -94,33 +270,21 @@ async function retireVellum(
94
270
  process.exit(1);
95
271
  }
96
272
 
97
- const platformUrl = runtimeUrl || getPlatformUrl();
98
- const url = `${platformUrl}/v1/assistants/${encodeURIComponent(assistantId)}/retire/`;
99
- const response = await loopbackSafeFetch(url, {
100
- method: "DELETE",
101
- headers: await authHeaders(token, runtimeUrl),
102
- });
103
-
104
- // Treat 404 as success: the assistant is already gone from the platform
105
- // (previously retired, deleted from the web UI, or retired from another
106
- // device) so the caller's job is done. Falling through to the lockfile
107
- // cleanup avoids leaving a stale entry that would otherwise wedge the
108
- // macOS app in a permanent health-check loop.
109
- if (!response.ok && response.status !== 404) {
110
- const body = await response.text();
273
+ try {
274
+ await deletePlatformAssistant(assistantId, {
275
+ token,
276
+ platformUrl: runtimeUrl,
277
+ label: "Platform retire",
278
+ successMessage: "\u2705 Platform-hosted instance retired.",
279
+ alreadyGoneMessage:
280
+ "\u2705 Platform-hosted instance already retired (404) \u2014 cleaning up local state.",
281
+ });
282
+ } catch (error) {
111
283
  console.error(
112
- `Error: Platform retire failed (${response.status}): ${body}`,
284
+ `Error: ${error instanceof Error ? error.message : String(error)}`,
113
285
  );
114
286
  process.exit(1);
115
287
  }
116
-
117
- if (response.status === 404) {
118
- console.log(
119
- "\u2705 Platform-hosted instance already retired (404) — cleaning up local state.",
120
- );
121
- } else {
122
- console.log("\u2705 Platform-hosted instance retired.");
123
- }
124
288
  }
125
289
 
126
290
  function parseRetireArgs(args: string[]): RetireArgs {
@@ -303,6 +467,15 @@ async function retireInner(): Promise<void> {
303
467
  } else if (cloud === "docker") {
304
468
  await retireDocker(assistantId);
305
469
  } else if (cloud === "local") {
470
+ try {
471
+ await unregisterSelfHostedLocalPlatformRecord(entry);
472
+ } catch (error) {
473
+ console.warn(
474
+ `⚠️ Platform self-hosted unregister failed; continuing local retire: ${
475
+ error instanceof Error ? error.message : String(error)
476
+ }`,
477
+ );
478
+ }
306
479
  await retireLocal(assistantId, entry);
307
480
  } else if (cloud === "custom") {
308
481
  await retireCustom(entry);
@@ -1123,6 +1123,7 @@ async function tryInjectPlatformCredentials(
1123
1123
  fetchCurrentVersion(entry.runtimeUrl),
1124
1124
  fetchAssistantIngressUrl(entry.runtimeUrl, entry.bearerToken),
1125
1125
  ]);
1126
+ const platformUrl = getPlatformUrl();
1126
1127
  const registration = await ensureSelfHostedLocalRegistration(
1127
1128
  token,
1128
1129
  orgId,
@@ -1130,9 +1131,15 @@ async function tryInjectPlatformCredentials(
1130
1131
  entry.assistantId,
1131
1132
  "cli",
1132
1133
  assistantVersion,
1133
- getPlatformUrl(),
1134
+ platformUrl,
1134
1135
  ingressUrl,
1135
1136
  );
1137
+ saveAssistantEntry({
1138
+ ...entry,
1139
+ platformAssistantId: registration.assistant.id,
1140
+ platformBaseUrl: platformUrl,
1141
+ platformOrganizationId: orgId,
1142
+ });
1136
1143
 
1137
1144
  // Resolve the API key: 1) fresh from registration, 2) existing from
1138
1145
  // daemon credential store, 3) reprovision as last resort (revokes old key).
@@ -1164,7 +1171,7 @@ async function tryInjectPlatformCredentials(
1164
1171
  bearerToken: entry.bearerToken,
1165
1172
  assistantApiKey,
1166
1173
  platformAssistantId: registration.assistant.id,
1167
- platformBaseUrl: getPlatformUrl(),
1174
+ platformBaseUrl: platformUrl,
1168
1175
  organizationId: orgId,
1169
1176
  userId: user.id,
1170
1177
  webhookSecret: registration.webhook_secret,
package/src/lib/docker.ts CHANGED
@@ -35,8 +35,9 @@ import {
35
35
  loadImageViaHost,
36
36
  } from "./host-image-loader.js";
37
37
  import {
38
- fetchLatestStableVersion,
38
+ fetchLatestVersion,
39
39
  resolveImageRefs,
40
+ type ReleaseChannel,
40
41
  } from "./platform-releases.js";
41
42
  import {
42
43
  configureHatchProviderApiKey,
@@ -321,6 +322,15 @@ export interface HatchDockerParams {
321
322
  * connection.
322
323
  */
323
324
  assistantCaCertPath?: string;
325
+ /**
326
+ * Release channel to resolve published images from when hatching without a
327
+ * local source tree (the image-pull fallback). `stable` (default) keeps the
328
+ * latest-stable behavior; `preview` pulls the latest preview release. Only
329
+ * affects the pull path — a local source build ignores it. Falls back to
330
+ * the `VELLUM_HATCH_CHANNEL` env var when unset, so callers that hatch via
331
+ * env (e.g. evals) can opt in without changing the invocation.
332
+ */
333
+ channel?: ReleaseChannel;
324
334
  }
325
335
 
326
336
  export type DockerProviderCredentialSetupAction =
@@ -1093,6 +1103,14 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1093
1103
  flagEnvVars = {},
1094
1104
  } = params;
1095
1105
  let watch = params.watch ?? false;
1106
+ // Resolve the release channel for the image-pull fallback: explicit param
1107
+ // wins, then the VELLUM_HATCH_CHANNEL env var, else stable. Any value other
1108
+ // than "preview" (case-insensitive) is treated as stable.
1109
+ const channel: ReleaseChannel =
1110
+ params.channel ??
1111
+ (process.env.VELLUM_HATCH_CHANNEL?.trim().toLowerCase() === "preview"
1112
+ ? "preview"
1113
+ : "stable");
1096
1114
 
1097
1115
  resetLogFile("hatch.log");
1098
1116
  const provider =
@@ -1250,8 +1268,8 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1250
1268
  // Resolve image refs from a remote source that may have dev/local
1251
1269
  // builds. If resolution is unavailable, fall back to the CLI's own
1252
1270
  // version so a default tag can still be resolved.
1253
- log("🔍 Fetching latest stable release...");
1254
- const latestVersion = await fetchLatestStableVersion();
1271
+ log(`🔍 Fetching latest ${channel} release...`);
1272
+ const latestVersion = await fetchLatestVersion(channel);
1255
1273
  let versionTag: string;
1256
1274
  if (latestVersion) {
1257
1275
  versionTag = latestVersion.startsWith("v")
@@ -1265,7 +1283,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1265
1283
  );
1266
1284
  }
1267
1285
  log("🔍 Resolving image references...");
1268
- const resolved = await resolveImageRefs(versionTag, log);
1286
+ const resolved = await resolveImageRefs(versionTag, log, channel);
1269
1287
  imageTags.assistant = resolved.imageTags.assistant;
1270
1288
  imageTags.gateway = resolved.imageTags.gateway;
1271
1289
  imageTags["credential-executor"] =
@@ -96,6 +96,20 @@ function tokenAuthHeader(token: string): Record<string, string> {
96
96
  return { "X-Session-Token": token };
97
97
  }
98
98
 
99
+ export function authHeadersForKnownOrganization(
100
+ token: string,
101
+ organizationId: string,
102
+ ): Record<string, string> {
103
+ const headers: Record<string, string> = {
104
+ "Content-Type": "application/json",
105
+ ...tokenAuthHeader(token),
106
+ };
107
+ if (!token.startsWith(VAK_PREFIX)) {
108
+ headers["Vellum-Organization-Id"] = organizationId;
109
+ }
110
+ return headers;
111
+ }
112
+
99
113
  /** Module-level cache for org IDs to avoid redundant fetches in polling loops. */
100
114
  const orgIdCache = new Map<string, { orgId: string; expiresAt: number }>();
101
115
  const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds
@@ -9,6 +9,25 @@ export interface ResolvedImageRefs {
9
9
  source: "platform" | "dockerhub";
10
10
  }
11
11
 
12
+ /**
13
+ * Release channel to resolve images from. `stable` is the default and maps
14
+ * to the server's `stable=true` filter (unchanged behavior). `preview` opts
15
+ * into the platform's preview channel — the newest release regardless of
16
+ * stability — used by callers (e.g. evals) that want to always hatch the
17
+ * latest preview image instead of latest-stable.
18
+ */
19
+ export type ReleaseChannel = "stable" | "preview";
20
+
21
+ /**
22
+ * Map a channel to `fetchReleases` options. `stable` passes no `channel`
23
+ * param so the server keeps its `stable=true` default (we don't rely on an
24
+ * unverified `channel=stable` filter); `preview` requests the preview
25
+ * channel explicitly.
26
+ */
27
+ function releasesOptsFor(channel: ReleaseChannel): { channel?: "preview" } {
28
+ return channel === "preview" ? { channel: "preview" } : {};
29
+ }
30
+
12
31
  export interface ReleaseListItem {
13
32
  version: string;
14
33
  is_stable?: boolean;
@@ -51,15 +70,26 @@ export async function fetchReleases(opts?: {
51
70
  }
52
71
 
53
72
  /**
54
- * Fetch the latest stable release version from the platform API.
73
+ * Fetch the latest release version for a channel from the platform API.
55
74
  * Returns the version string (e.g. "0.7.0") or null if unavailable.
56
- * The releases endpoint returns entries ordered newest-first.
75
+ * The releases endpoint returns entries ordered newest-first, so the first
76
+ * entry is the latest for the requested channel.
57
77
  */
58
- export async function fetchLatestStableVersion(): Promise<string | null> {
59
- const releases = await fetchReleases();
78
+ export async function fetchLatestVersion(
79
+ channel: ReleaseChannel = "stable",
80
+ ): Promise<string | null> {
81
+ const releases = await fetchReleases(releasesOptsFor(channel));
60
82
  return releases?.[0]?.version ?? null;
61
83
  }
62
84
 
85
+ /**
86
+ * Fetch the latest stable release version. Thin back-compat wrapper around
87
+ * {@link fetchLatestVersion} for existing callers.
88
+ */
89
+ export async function fetchLatestStableVersion(): Promise<string | null> {
90
+ return fetchLatestVersion("stable");
91
+ }
92
+
63
93
  export type ImageRefResolution =
64
94
  | { status: "platform"; imageTags: Record<ServiceName, string> }
65
95
  | { status: "dockerhub-fallback"; imageTags: Record<ServiceName, string> }
@@ -87,10 +117,11 @@ function dockerhubImageTags(version: string): Record<ServiceName, string> {
87
117
  export async function resolveImageRefsDetailed(
88
118
  version: string,
89
119
  log?: (msg: string) => void,
120
+ channel: ReleaseChannel = "stable",
90
121
  ): Promise<ImageRefResolution> {
91
122
  log?.("Resolving image references...");
92
123
 
93
- const releases = await fetchReleases();
124
+ const releases = await fetchReleases(releasesOptsFor(channel));
94
125
  if (releases === null) {
95
126
  log?.("Platform unreachable — falling back to DockerHub tags");
96
127
  return {
@@ -151,8 +182,9 @@ export async function resolveImageRefsDetailed(
151
182
  export async function resolveImageRefs(
152
183
  version: string,
153
184
  log?: (msg: string) => void,
185
+ channel: ReleaseChannel = "stable",
154
186
  ): Promise<ResolvedImageRefs> {
155
- const resolution = await resolveImageRefsDetailed(version, log);
187
+ const resolution = await resolveImageRefsDetailed(version, log, channel);
156
188
  if (resolution.status === "platform") {
157
189
  log?.("Resolved image refs from platform API");
158
190
  return { imageTags: resolution.imageTags, source: "platform" };
@@ -33,7 +33,6 @@ export interface AuthorizeUrlOptions {
33
33
  challenge: string;
34
34
  state: string;
35
35
  loginHint?: string;
36
- providerHint?: string;
37
36
  }
38
37
 
39
38
  export function buildAuthorizeUrl(options: AuthorizeUrlOptions): string {
@@ -46,7 +45,7 @@ export function buildAuthorizeUrl(options: AuthorizeUrlOptions): string {
46
45
  url.searchParams.set("code_challenge_method", "S256");
47
46
  url.searchParams.set("state", options.state);
48
47
  // No `prompt`: lets the browser's existing IdP session be reused.
49
- url.searchParams.set("provider", options.providerHint || "authkit");
48
+ url.searchParams.set("provider", "authkit");
50
49
  if (options.loginHint) url.searchParams.set("login_hint", options.loginHint);
51
50
  return url.toString();
52
51
  }