@vellumai/cli 0.10.3 → 0.10.4-dev.202607010119.ad15dbb

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);
@@ -33,6 +33,7 @@ import {
33
33
  localRuntimeExportToGcs,
34
34
  localRuntimeIdentity,
35
35
  localRuntimeImportFromGcs,
36
+ localRuntimePreflightFromGcs,
36
37
  localRuntimePollJobStatus,
37
38
  MigrationInProgressError,
38
39
  } from "../lib/local-runtime-client.js";
@@ -707,11 +708,46 @@ async function importToAssistant(
707
708
 
708
709
  if (cloud === "local" || cloud === "docker") {
709
710
  if (dryRun) {
710
- // TODO(cli): support dry-run against local targets
711
- console.error(
712
- "Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
711
+ console.log("Running preflight analysis...\n");
712
+
713
+ // Query the target runtime's version before requesting a signed
714
+ // download URL — the platform uses it for the bundle compatibility check.
715
+ let targetRuntimeVersion: string;
716
+ try {
717
+ const identity = await callRuntimeWithAuthRetry(entry, (token) =>
718
+ localRuntimeIdentity(entry, token),
719
+ );
720
+ targetRuntimeVersion = identity.version;
721
+ } catch (err) {
722
+ const msg = err instanceof Error ? err.message : String(err);
723
+ console.error(
724
+ `Error: Could not read target runtime version from '${entry.assistantId}': ${msg}`,
725
+ );
726
+ console.error(`Try: vellum wake ${entry.assistantId}`);
727
+ process.exit(1);
728
+ }
729
+
730
+ let bundleUrl: string;
731
+ try {
732
+ const result = await platformRequestSignedUrl(
733
+ { operation: "download", bundleKey, targetRuntimeVersion },
734
+ platformToken,
735
+ bundlePlatformUrl,
736
+ );
737
+ bundleUrl = result.url;
738
+ } catch (err) {
739
+ if (err instanceof VersionMismatchError) {
740
+ console.error(`Error: ${err.message}`);
741
+ process.exit(1);
742
+ }
743
+ throw err;
744
+ }
745
+
746
+ const preflightData = await callRuntimeWithAuthRetry(entry, (token) =>
747
+ localRuntimePreflightFromGcs(entry, token, { bundleUrl }),
713
748
  );
714
- process.exit(1);
749
+ printPreflightSummary(preflightData as unknown as PreflightResponse);
750
+ return;
715
751
  }
716
752
 
717
753
  // Ask the platform for a signed download URL and hand it to the local
@@ -1087,6 +1123,7 @@ async function tryInjectPlatformCredentials(
1087
1123
  fetchCurrentVersion(entry.runtimeUrl),
1088
1124
  fetchAssistantIngressUrl(entry.runtimeUrl, entry.bearerToken),
1089
1125
  ]);
1126
+ const platformUrl = getPlatformUrl();
1090
1127
  const registration = await ensureSelfHostedLocalRegistration(
1091
1128
  token,
1092
1129
  orgId,
@@ -1094,9 +1131,15 @@ async function tryInjectPlatformCredentials(
1094
1131
  entry.assistantId,
1095
1132
  "cli",
1096
1133
  assistantVersion,
1097
- getPlatformUrl(),
1134
+ platformUrl,
1098
1135
  ingressUrl,
1099
1136
  );
1137
+ saveAssistantEntry({
1138
+ ...entry,
1139
+ platformAssistantId: registration.assistant.id,
1140
+ platformBaseUrl: platformUrl,
1141
+ platformOrganizationId: orgId,
1142
+ });
1100
1143
 
1101
1144
  // Resolve the API key: 1) fresh from registration, 2) existing from
1102
1145
  // daemon credential store, 3) reprovision as last resort (revokes old key).
@@ -1128,7 +1171,7 @@ async function tryInjectPlatformCredentials(
1128
1171
  bearerToken: entry.bearerToken,
1129
1172
  assistantApiKey,
1130
1173
  platformAssistantId: registration.assistant.id,
1131
- platformBaseUrl: getPlatformUrl(),
1174
+ platformBaseUrl: platformUrl,
1132
1175
  organizationId: orgId,
1133
1176
  userId: user.id,
1134
1177
  webhookSecret: registration.webhook_secret,
@@ -1231,18 +1274,8 @@ export async function teleport(): Promise<void> {
1231
1274
  process.exit(1);
1232
1275
  }
1233
1276
 
1234
- // Dry-run feasibility checkreject local/docker targets BEFORE any
1235
- // export work. The local runtime has no preflight-from-gcs endpoint yet,
1236
- // so we can't actually run a dry-run against it; burning a GCS upload
1237
- // just to fail afterwards would be wasteful.
1238
- // TODO(cli): support dry-run against local targets (needs a
1239
- // preflight-from-gcs endpoint on the runtime).
1240
- if (toCloud === "local" || toCloud === "docker") {
1241
- console.error(
1242
- "Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
1243
- );
1244
- process.exit(1);
1245
- }
1277
+ // Nothing to rejectpreflight-from-gcs is now implemented for all
1278
+ // target topologies including local and docker.
1246
1279
 
1247
1280
  // Version guard: block platform→non-platform when target is behind
1248
1281
  if (fromCloud === "vellum" && toCloud !== "vellum") {
@@ -195,6 +195,44 @@ export async function localRuntimeImportFromGcs(
195
195
  return { jobId: json.job_id };
196
196
  }
197
197
 
198
+ /**
199
+ * Run a dry-run preflight analysis on a .vbundle archive stored at a signed
200
+ * GCS download URL.
201
+ *
202
+ * For local/docker assistants this POSTs to
203
+ * `{runtimeUrl}/v1/migrations/preflight-from-gcs` with guardian-token bearer
204
+ * auth. The daemon fetches the bundle from GCS, validates it, and returns a
205
+ * report of what would change on import — without writing anything to disk.
206
+ *
207
+ * Returns the raw preflight response body. Callers should cast to their
208
+ * local `PreflightResponse` shape (mirrors the `/import-preflight` shape).
209
+ */
210
+ export async function localRuntimePreflightFromGcs(
211
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
212
+ token: string,
213
+ params: { bundleUrl: string },
214
+ ): Promise<Record<string, unknown>> {
215
+ const response = await fetch(
216
+ resolveRuntimeMigrationUrl(entry, "preflight-from-gcs"),
217
+ {
218
+ method: "POST",
219
+ headers: await migrationRequestHeaders(entry, token),
220
+ body: JSON.stringify({ bundle_url: params.bundleUrl }),
221
+ },
222
+ );
223
+
224
+ if (response.status !== 200) {
225
+ const errText = await response.text().catch(() => "");
226
+ throw new Error(
227
+ `Local runtime preflight-from-gcs failed (${response.status}): ${
228
+ errText || response.statusText
229
+ }`,
230
+ );
231
+ }
232
+
233
+ return (await response.json()) as Record<string, unknown>;
234
+ }
235
+
198
236
  /**
199
237
  * Poll the runtime's unified job-status endpoint.
200
238
  *
@@ -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