@vellumai/cli 0.5.7 → 0.5.9

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/src/lib/docker.ts CHANGED
@@ -15,7 +15,7 @@ import type { AssistantEntry } from "./assistant-config";
15
15
  import { writeInitialConfig } from "./config-utils";
16
16
  import { DEFAULT_GATEWAY_PORT, PROVIDER_ENV_VAR_NAMES } from "./constants";
17
17
  import type { Species } from "./constants";
18
- import { leaseGuardianToken, saveBootstrapSecret } from "./guardian-token";
18
+ import { leaseGuardianToken } from "./guardian-token";
19
19
  import { isVellumProcess, stopProcess } from "./process";
20
20
  import { generateInstanceName } from "./random-name";
21
21
  import { resolveImageRefs } from "./platform-releases.js";
@@ -271,11 +271,30 @@ async function ensureDockerInstalled(): Promise<void> {
271
271
  console.log("🚀 Docker daemon not running. Starting Colima...");
272
272
  try {
273
273
  await exec("colima", ["start"]);
274
- } catch (err) {
275
- const message = err instanceof Error ? err.message : String(err);
276
- throw new Error(
277
- `Failed to start Colima. Please run 'colima start' manually.\n${message}`,
274
+ } catch {
275
+ // Colima may fail if a previous VM instance is in a corrupt state.
276
+ // Attempt to delete the stale instance and retry once.
277
+ console.log(
278
+ "⚠️ Colima start failed — attempting to reset stale VM state...",
278
279
  );
280
+ try {
281
+ await exec("colima", ["stop", "--force"]).catch(() => {});
282
+ await exec("colima", ["delete", "--force"]);
283
+ } catch {
284
+ // If delete also fails, fall through to the retry which will
285
+ // produce a clear error message.
286
+ }
287
+
288
+ try {
289
+ console.log("🔄 Retrying colima start...");
290
+ await exec("colima", ["start"]);
291
+ } catch (retryErr) {
292
+ const message =
293
+ retryErr instanceof Error ? retryErr.message : String(retryErr);
294
+ throw new Error(
295
+ `Failed to start Colima after resetting stale VM state. Please run 'colima start' manually.\n${message}`,
296
+ );
297
+ }
279
298
  }
280
299
  }
281
300
  }
@@ -505,7 +524,7 @@ export function serviceDockerRunArgs(opts: {
505
524
  "-e",
506
525
  "RUNTIME_HTTP_HOST=0.0.0.0",
507
526
  "-e",
508
- "WORKSPACE_DIR=/workspace",
527
+ "VELLUM_WORKSPACE_DIR=/workspace",
509
528
  "-e",
510
529
  `CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
511
530
  "-e",
@@ -556,7 +575,7 @@ export function serviceDockerRunArgs(opts: {
556
575
  "-v",
557
576
  `${res.gatewaySecurityVolume}:/gateway-security`,
558
577
  "-e",
559
- "WORKSPACE_DIR=/workspace",
578
+ "VELLUM_WORKSPACE_DIR=/workspace",
560
579
  "-e",
561
580
  "GATEWAY_SECURITY_DIR=/gateway-security",
562
581
  "-e",
@@ -596,7 +615,7 @@ export function serviceDockerRunArgs(opts: {
596
615
  "-e",
597
616
  "CES_MODE=managed",
598
617
  "-e",
599
- "WORKSPACE_DIR=/workspace",
618
+ "VELLUM_WORKSPACE_DIR=/workspace",
600
619
  "-e",
601
620
  "CES_BOOTSTRAP_SOCKET_DIR=/run/ces-bootstrap",
602
621
  "-e",
@@ -789,7 +808,6 @@ export async function stopContainers(
789
808
  await removeContainer(res.assistantContainer);
790
809
  }
791
810
 
792
-
793
811
  /** Stop containers without removing them (preserves state for `docker start`). */
794
812
  export async function sleepContainers(
795
813
  res: ReturnType<typeof dockerResourceNames>,
@@ -1128,8 +1146,18 @@ export async function hatchDocker(
1128
1146
 
1129
1147
  const cesServiceToken = randomBytes(32).toString("hex");
1130
1148
  const signingKey = randomBytes(32).toString("hex");
1131
- const bootstrapSecret = randomBytes(32).toString("hex");
1132
- saveBootstrapSecret(instanceName, bootstrapSecret);
1149
+
1150
+ // When launched by a remote hatch startup script, the env var
1151
+ // GUARDIAN_BOOTSTRAP_SECRET is already set with the laptop's secret.
1152
+ // Generate a new secret for the local docker hatch caller and append
1153
+ // it so the gateway receives a comma-separated list of all expected
1154
+ // bootstrap secrets.
1155
+ const ownSecret = randomBytes(32).toString("hex");
1156
+ const preExisting = process.env.GUARDIAN_BOOTSTRAP_SECRET;
1157
+ const bootstrapSecret = preExisting
1158
+ ? `${preExisting},${ownSecret}`
1159
+ : ownSecret;
1160
+
1133
1161
  await startContainers(
1134
1162
  {
1135
1163
  signingKey,
@@ -1169,6 +1197,7 @@ export async function hatchDocker(
1169
1197
  setActiveAssistant(instanceName);
1170
1198
 
1171
1199
  const { ready } = await waitForGatewayAndLease({
1200
+ bootstrapSecret: ownSecret,
1172
1201
  containerName: res.assistantContainer,
1173
1202
  detached: watch ? false : detached,
1174
1203
  instanceName,
@@ -1226,13 +1255,21 @@ export async function hatchDocker(
1226
1255
  * lease a guardian token.
1227
1256
  */
1228
1257
  async function waitForGatewayAndLease(opts: {
1258
+ bootstrapSecret: string;
1229
1259
  containerName: string;
1230
1260
  detached: boolean;
1231
1261
  instanceName: string;
1232
1262
  logFd: number | "ignore";
1233
1263
  runtimeUrl: string;
1234
1264
  }): Promise<{ ready: boolean }> {
1235
- const { containerName, detached, instanceName, logFd, runtimeUrl } = opts;
1265
+ const {
1266
+ bootstrapSecret,
1267
+ containerName,
1268
+ detached,
1269
+ instanceName,
1270
+ logFd,
1271
+ runtimeUrl,
1272
+ } = opts;
1236
1273
 
1237
1274
  const log = (msg: string): void => {
1238
1275
  console.log(msg);
@@ -1306,7 +1343,11 @@ async function waitForGatewayAndLease(opts: {
1306
1343
 
1307
1344
  while (Date.now() < leaseDeadline) {
1308
1345
  try {
1309
- const tokenData = await leaseGuardianToken(runtimeUrl, instanceName);
1346
+ const tokenData = await leaseGuardianToken(
1347
+ runtimeUrl,
1348
+ instanceName,
1349
+ bootstrapSecret,
1350
+ );
1310
1351
  const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
1311
1352
  log(
1312
1353
  `Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId}, expiresAt=${tokenData.accessTokenExpiresAt})`,
package/src/lib/gcp.ts CHANGED
@@ -457,7 +457,7 @@ export async function hatchGcp(
457
457
  instanceName: string,
458
458
  cloud: "gcp",
459
459
  configValues?: Record<string, string>,
460
- ) => Promise<string>,
460
+ ) => Promise<{ script: string; laptopBootstrapSecret: string }>,
461
461
  watchHatching: (
462
462
  pollFn: () => Promise<PollResult>,
463
463
  instanceName: string,
@@ -522,14 +522,15 @@ export async function hatchGcp(
522
522
  );
523
523
  process.exit(1);
524
524
  }
525
- const startupScript = await buildStartupScript(
526
- species,
527
- sshUser,
528
- providerApiKeys,
529
- instanceName,
530
- "gcp",
531
- configValues,
532
- );
525
+ const { script: startupScript, laptopBootstrapSecret } =
526
+ await buildStartupScript(
527
+ species,
528
+ sshUser,
529
+ providerApiKeys,
530
+ instanceName,
531
+ "gcp",
532
+ configValues,
533
+ );
533
534
  const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
534
535
  writeFileSync(startupScriptPath, startupScript);
535
536
 
@@ -662,7 +663,11 @@ export async function hatchGcp(
662
663
  }
663
664
 
664
665
  try {
665
- await leaseGuardianToken(runtimeUrl, instanceName);
666
+ await leaseGuardianToken(
667
+ runtimeUrl,
668
+ instanceName,
669
+ laptopBootstrapSecret,
670
+ );
666
671
  } catch (err) {
667
672
  console.warn(
668
673
  `\u26a0\ufe0f Could not lease guardian token: ${err instanceof Error ? err.message : err}`,
@@ -42,46 +42,6 @@ function getPersistedDeviceIdPath(): string {
42
42
  return join(getXdgConfigHome(), "vellum", "device-id");
43
43
  }
44
44
 
45
- function getBootstrapSecretPath(assistantId: string): string {
46
- return join(
47
- getXdgConfigHome(),
48
- "vellum",
49
- "assistants",
50
- assistantId,
51
- "bootstrap-secret",
52
- );
53
- }
54
-
55
- /**
56
- * Load a previously saved bootstrap secret for the given assistant.
57
- * Returns null if the file does not exist or is unreadable.
58
- */
59
- export function loadBootstrapSecret(assistantId: string): string | null {
60
- try {
61
- const raw = readFileSync(getBootstrapSecretPath(assistantId), "utf-8").trim();
62
- return raw.length > 0 ? raw : null;
63
- } catch {
64
- return null;
65
- }
66
- }
67
-
68
- /**
69
- * Persist a bootstrap secret for the given assistant so that the desktop
70
- * client and upgrade/rollback paths can retrieve it later.
71
- */
72
- export function saveBootstrapSecret(
73
- assistantId: string,
74
- secret: string,
75
- ): void {
76
- const path = getBootstrapSecretPath(assistantId);
77
- const dir = dirname(path);
78
- if (!existsSync(dir)) {
79
- mkdirSync(dir, { recursive: true, mode: 0o700 });
80
- }
81
- writeFileSync(path, secret + "\n", { mode: 0o600 });
82
- chmodSync(path, 0o600);
83
- }
84
-
85
45
  function hashWithSalt(input: string): string {
86
46
  return createHash("sha256")
87
47
  .update(input + DEVICE_ID_SALT)
@@ -206,10 +166,12 @@ export function saveGuardianToken(
206
166
  export async function leaseGuardianToken(
207
167
  gatewayUrl: string,
208
168
  assistantId: string,
169
+ bootstrapSecret?: string,
209
170
  ): Promise<GuardianTokenData> {
210
171
  const deviceId = computeDeviceId();
211
- const headers: Record<string, string> = { "Content-Type": "application/json" };
212
- const bootstrapSecret = loadBootstrapSecret(assistantId);
172
+ const headers: Record<string, string> = {
173
+ "Content-Type": "application/json",
174
+ };
213
175
  if (bootstrapSecret) {
214
176
  headers["x-bootstrap-secret"] = bootstrapSecret;
215
177
  }
package/src/lib/local.ts CHANGED
@@ -948,6 +948,7 @@ export async function startLocalDaemon(
948
948
  "USER",
949
949
  "LANG",
950
950
  "VELLUM_DEBUG",
951
+ "VELLUM_DEV",
951
952
  "VELLUM_DESKTOP_APP",
952
953
  ]) {
953
954
  if (process.env[key]) {
@@ -9,8 +9,6 @@ import {
9
9
  import { homedir } from "os";
10
10
  import { join, dirname } from "path";
11
11
 
12
- const DEFAULT_PLATFORM_URL = "";
13
-
14
12
  function getXdgConfigHome(): string {
15
13
  return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
16
14
  }
@@ -20,21 +18,25 @@ function getPlatformTokenPath(): string {
20
18
  }
21
19
 
22
20
  export function getPlatformUrl(): string {
23
- return process.env.VELLUM_PLATFORM_URL ?? DEFAULT_PLATFORM_URL;
24
- }
25
-
26
- /**
27
- * Returns the platform URL, throwing a clear error if it is not configured.
28
- * Use this in functions that need a valid URL to make HTTP requests.
29
- */
30
- function requirePlatformUrl(): string {
31
- const url = getPlatformUrl();
32
- if (!url) {
33
- throw new Error(
34
- "VELLUM_PLATFORM_URL is not configured. Set it in your environment or .env file.",
35
- );
21
+ let configUrl: string | undefined;
22
+ try {
23
+ const base = process.env.BASE_DATA_DIR?.trim() || homedir();
24
+ const configPath = join(base, ".vellum", "workspace", "config.json");
25
+ if (existsSync(configPath)) {
26
+ const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
27
+ string,
28
+ unknown
29
+ >;
30
+ const val = (raw.platform as Record<string, unknown> | undefined)
31
+ ?.baseUrl;
32
+ if (typeof val === "string" && val.trim()) configUrl = val.trim();
33
+ }
34
+ } catch {
35
+ // Config not available — fall through
36
36
  }
37
- return url;
37
+ return (
38
+ configUrl || process.env.VELLUM_PLATFORM_URL || "https://platform.vellum.ai"
39
+ );
38
40
  }
39
41
 
40
42
  export function readPlatformToken(): string | null {
@@ -74,7 +76,7 @@ interface OrganizationListResponse {
74
76
  }
75
77
 
76
78
  export async function fetchOrganizationId(token: string): Promise<string> {
77
- const platformUrl = requirePlatformUrl();
79
+ const platformUrl = getPlatformUrl();
78
80
  const url = `${platformUrl}/v1/organizations/`;
79
81
  const response = await fetch(url, {
80
82
  headers: { "X-Session-Token": token },
@@ -106,7 +108,7 @@ interface AllauthSessionResponse {
106
108
  }
107
109
 
108
110
  export async function fetchCurrentUser(token: string): Promise<PlatformUser> {
109
- const url = `${requirePlatformUrl()}/_allauth/app/v1/auth/session`;
111
+ const url = `${getPlatformUrl()}/_allauth/app/v1/auth/session`;
110
112
  const response = await fetch(url, {
111
113
  headers: { "X-Session-Token": token },
112
114
  });
@@ -127,3 +129,189 @@ export async function fetchCurrentUser(token: string): Promise<PlatformUser> {
127
129
  const body = (await response.json()) as AllauthSessionResponse;
128
130
  return body.data.user;
129
131
  }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Rollback
135
+ // ---------------------------------------------------------------------------
136
+
137
+ export async function rollbackPlatformAssistant(
138
+ token: string,
139
+ orgId: string,
140
+ version?: string,
141
+ ): Promise<{ detail: string; version: string | null }> {
142
+ const platformUrl = getPlatformUrl();
143
+ const response = await fetch(`${platformUrl}/v1/assistants/rollback/`, {
144
+ method: "POST",
145
+ headers: {
146
+ "Content-Type": "application/json",
147
+ "X-Session-Token": token,
148
+ "Vellum-Organization-Id": orgId,
149
+ },
150
+ body: JSON.stringify(version ? { version } : {}),
151
+ });
152
+
153
+ const body = (await response.json().catch(() => ({}))) as {
154
+ detail?: string;
155
+ version?: string | null;
156
+ };
157
+
158
+ if (response.status === 200) {
159
+ return { detail: body.detail ?? "", version: body.version ?? null };
160
+ }
161
+
162
+ if (response.status === 400) {
163
+ throw new Error(body.detail ?? "Rollback failed: bad request");
164
+ }
165
+
166
+ if (response.status === 404) {
167
+ throw new Error(body.detail ?? "Rollback target not found");
168
+ }
169
+
170
+ if (response.status === 502) {
171
+ throw new Error(body.detail ?? "Rollback failed: transport error");
172
+ }
173
+
174
+ throw new Error(`Rollback failed: ${response.status} ${response.statusText}`);
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Migration export
179
+ // ---------------------------------------------------------------------------
180
+
181
+ export async function platformInitiateExport(
182
+ token: string,
183
+ orgId: string,
184
+ description?: string,
185
+ ): Promise<{ jobId: string; status: string }> {
186
+ const platformUrl = getPlatformUrl();
187
+ const response = await fetch(`${platformUrl}/v1/migrations/export/`, {
188
+ method: "POST",
189
+ headers: {
190
+ "Content-Type": "application/json",
191
+ "X-Session-Token": token,
192
+ "Vellum-Organization-Id": orgId,
193
+ },
194
+ body: JSON.stringify({ description: description ?? "CLI backup" }),
195
+ });
196
+
197
+ if (response.status !== 201) {
198
+ const body = (await response.json().catch(() => ({}))) as {
199
+ detail?: string;
200
+ };
201
+ throw new Error(
202
+ body.detail ??
203
+ `Export initiation failed: ${response.status} ${response.statusText}`,
204
+ );
205
+ }
206
+
207
+ const body = (await response.json()) as {
208
+ job_id: string;
209
+ status: string;
210
+ };
211
+ return { jobId: body.job_id, status: body.status };
212
+ }
213
+
214
+ export async function platformPollExportStatus(
215
+ jobId: string,
216
+ token: string,
217
+ orgId: string,
218
+ ): Promise<{ status: string; downloadUrl?: string; error?: string }> {
219
+ const platformUrl = getPlatformUrl();
220
+ const response = await fetch(
221
+ `${platformUrl}/v1/migrations/export/${jobId}/status/`,
222
+ {
223
+ headers: {
224
+ "X-Session-Token": token,
225
+ "Vellum-Organization-Id": orgId,
226
+ },
227
+ },
228
+ );
229
+
230
+ if (response.status === 404) {
231
+ throw new Error("Export job not found");
232
+ }
233
+
234
+ if (!response.ok) {
235
+ throw new Error(
236
+ `Export status check failed: ${response.status} ${response.statusText}`,
237
+ );
238
+ }
239
+
240
+ const body = (await response.json()) as {
241
+ status: string;
242
+ download_url?: string;
243
+ error?: string;
244
+ };
245
+ return {
246
+ status: body.status,
247
+ downloadUrl: body.download_url,
248
+ error: body.error,
249
+ };
250
+ }
251
+
252
+ export async function platformDownloadExport(
253
+ downloadUrl: string,
254
+ ): Promise<Response> {
255
+ const response = await fetch(downloadUrl);
256
+ if (!response.ok) {
257
+ throw new Error(
258
+ `Download failed: ${response.status} ${response.statusText}`,
259
+ );
260
+ }
261
+ return response;
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Migration import
266
+ // ---------------------------------------------------------------------------
267
+
268
+ export async function platformImportPreflight(
269
+ bundleData: Uint8Array<ArrayBuffer>,
270
+ token: string,
271
+ orgId: string,
272
+ ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
273
+ const platformUrl = getPlatformUrl();
274
+ const response = await fetch(
275
+ `${platformUrl}/v1/migrations/import-preflight/`,
276
+ {
277
+ method: "POST",
278
+ headers: {
279
+ "Content-Type": "application/octet-stream",
280
+ "X-Session-Token": token,
281
+ "Vellum-Organization-Id": orgId,
282
+ },
283
+ body: new Blob([bundleData]),
284
+ signal: AbortSignal.timeout(120_000),
285
+ },
286
+ );
287
+
288
+ const body = (await response.json().catch(() => ({}))) as Record<
289
+ string,
290
+ unknown
291
+ >;
292
+ return { statusCode: response.status, body };
293
+ }
294
+
295
+ export async function platformImportBundle(
296
+ bundleData: Uint8Array<ArrayBuffer>,
297
+ token: string,
298
+ orgId: string,
299
+ ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
300
+ const platformUrl = getPlatformUrl();
301
+ const response = await fetch(`${platformUrl}/v1/migrations/import/`, {
302
+ method: "POST",
303
+ headers: {
304
+ "Content-Type": "application/octet-stream",
305
+ "X-Session-Token": token,
306
+ "Vellum-Organization-Id": orgId,
307
+ },
308
+ body: new Blob([bundleData]),
309
+ signal: AbortSignal.timeout(120_000),
310
+ });
311
+
312
+ const body = (await response.json().catch(() => ({}))) as Record<
313
+ string,
314
+ unknown
315
+ >;
316
+ return { statusCode: response.status, body };
317
+ }