@vellumai/cli 0.6.1 → 0.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -728,7 +728,6 @@ describe("resolveOrHatchTarget", () => {
728
728
  null,
729
729
  false,
730
730
  false,
731
- false,
732
731
  {},
733
732
  );
734
733
  expect(result).toBe(newEntry);
@@ -176,7 +176,6 @@ interface HatchArgs {
176
176
  keepAlive: boolean;
177
177
  name: string | null;
178
178
  remote: RemoteHost;
179
- restart: boolean;
180
179
  watch: boolean;
181
180
  configValues: Record<string, string>;
182
181
  }
@@ -188,7 +187,6 @@ function parseArgs(): HatchArgs {
188
187
  let keepAlive = false;
189
188
  let name: string | null = null;
190
189
  let remote: RemoteHost = DEFAULT_REMOTE;
191
- let restart = false;
192
190
  let watch = false;
193
191
  const configValues: Record<string, string> = {};
194
192
 
@@ -209,9 +207,6 @@ function parseArgs(): HatchArgs {
209
207
  console.log(
210
208
  " --remote <host> Remote host (local, gcp, aws, docker, custom, vellum)",
211
209
  );
212
- console.log(
213
- " --restart Restart processes without onboarding side effects",
214
- );
215
210
  console.log(
216
211
  " --watch Run assistant and gateway in watch mode (hot reload on source changes)",
217
212
  );
@@ -224,8 +219,6 @@ function parseArgs(): HatchArgs {
224
219
  process.exit(0);
225
220
  } else if (arg === "-d") {
226
221
  detached = true;
227
- } else if (arg === "--restart") {
228
- restart = true;
229
222
  } else if (arg === "--watch") {
230
223
  watch = true;
231
224
  } else if (arg === "--keep-alive") {
@@ -277,7 +270,7 @@ function parseArgs(): HatchArgs {
277
270
  species = arg as Species;
278
271
  } else {
279
272
  console.error(
280
- `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --restart, --watch, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>, --config <key=value>`,
273
+ `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --watch, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>, --config <key=value>`,
281
274
  );
282
275
  process.exit(1);
283
276
  }
@@ -289,7 +282,6 @@ function parseArgs(): HatchArgs {
289
282
  keepAlive,
290
283
  name,
291
284
  remote,
292
- restart,
293
285
  watch,
294
286
  configValues,
295
287
  };
@@ -516,23 +508,8 @@ export async function hatch(): Promise<void> {
516
508
  const cliVersion = getCliVersion();
517
509
  console.log(`@vellumai/cli v${cliVersion}`);
518
510
 
519
- const {
520
- species,
521
- detached,
522
- keepAlive,
523
- name,
524
- remote,
525
- restart,
526
- watch,
527
- configValues,
528
- } = parseArgs();
529
-
530
- if (restart && remote !== "local") {
531
- console.error(
532
- "Error: --restart is only supported for local hatch targets.",
533
- );
534
- process.exit(1);
535
- }
511
+ const { species, detached, keepAlive, name, remote, watch, configValues } =
512
+ parseArgs();
536
513
 
537
514
  if (watch && remote !== "local" && remote !== "docker") {
538
515
  console.error(
@@ -542,7 +519,7 @@ export async function hatch(): Promise<void> {
542
519
  }
543
520
 
544
521
  if (remote === "local") {
545
- await hatchLocal(species, name, restart, watch, keepAlive, configValues);
522
+ await hatchLocal(species, name, watch, keepAlive, configValues);
546
523
  return;
547
524
  }
548
525
 
@@ -11,6 +11,11 @@ import {
11
11
  rollbackPlatformAssistant,
12
12
  platformImportPreflight,
13
13
  platformImportBundle,
14
+ platformRequestUploadUrl,
15
+ platformUploadToSignedUrl,
16
+ platformImportPreflightFromGcs,
17
+ platformImportBundleFromGcs,
18
+ platformPollImportStatus,
14
19
  } from "../lib/platform-client.js";
15
20
  import { performDockerRollback } from "../lib/upgrade-lifecycle.js";
16
21
 
@@ -176,6 +181,25 @@ async function restorePlatform(
176
181
  process.exit(1);
177
182
  }
178
183
 
184
+ // Step 1.5 — Upload to GCS via signed URL (with fallback to inline)
185
+ let bundleKey: string | null = null;
186
+ try {
187
+ const { uploadUrl, bundleKey: key } = await platformRequestUploadUrl(
188
+ token,
189
+ entry.runtimeUrl,
190
+ );
191
+ bundleKey = key;
192
+ console.log("Uploading bundle...");
193
+ await platformUploadToSignedUrl(uploadUrl, new Uint8Array(bundleData));
194
+ } catch (err) {
195
+ const msg = err instanceof Error ? err.message : String(err);
196
+ if (msg.includes("not available")) {
197
+ bundleKey = null;
198
+ } else {
199
+ throw err;
200
+ }
201
+ }
202
+
179
203
  // Step 2 — Dry-run path
180
204
  if (opts.dryRun) {
181
205
  if (opts.version) {
@@ -189,11 +213,17 @@ async function restorePlatform(
189
213
 
190
214
  let preflightResult: { statusCode: number; body: Record<string, unknown> };
191
215
  try {
192
- preflightResult = await platformImportPreflight(
193
- new Uint8Array(bundleData),
194
- token,
195
- entry.runtimeUrl,
196
- );
216
+ preflightResult = bundleKey
217
+ ? await platformImportPreflightFromGcs(
218
+ bundleKey,
219
+ token,
220
+ entry.runtimeUrl,
221
+ )
222
+ : await platformImportPreflight(
223
+ new Uint8Array(bundleData),
224
+ token,
225
+ entry.runtimeUrl,
226
+ );
197
227
  } catch (err) {
198
228
  if (err instanceof Error && err.name === "TimeoutError") {
199
229
  console.error("Error: Preflight request timed out after 2 minutes.");
@@ -323,14 +353,16 @@ async function restorePlatform(
323
353
 
324
354
  let importResult: { statusCode: number; body: Record<string, unknown> };
325
355
  try {
326
- importResult = await platformImportBundle(
327
- new Uint8Array(bundleData),
328
- token,
329
- entry.runtimeUrl,
330
- );
356
+ importResult = bundleKey
357
+ ? await platformImportBundleFromGcs(bundleKey, token, entry.runtimeUrl)
358
+ : await platformImportBundle(
359
+ new Uint8Array(bundleData),
360
+ token,
361
+ entry.runtimeUrl,
362
+ );
331
363
  } catch (err) {
332
364
  if (err instanceof Error && err.name === "TimeoutError") {
333
- console.error("Error: Import request timed out after 2 minutes.");
365
+ console.error("Error: Import request timed out after 5 minutes.");
334
366
  process.exit(1);
335
367
  }
336
368
  throw err;
@@ -364,11 +396,83 @@ async function restorePlatform(
364
396
  process.exit(1);
365
397
  }
366
398
 
367
- if (importResult.statusCode < 200 || importResult.statusCode >= 300) {
399
+ if (
400
+ importResult.statusCode !== 202 &&
401
+ (importResult.statusCode < 200 || importResult.statusCode >= 300)
402
+ ) {
368
403
  console.error(`Error: Import failed (${importResult.statusCode})`);
369
404
  process.exit(1);
370
405
  }
371
406
 
407
+ // Async import — poll until complete
408
+ if (importResult.statusCode === 202) {
409
+ const jobId = (importResult.body as { job_id?: string }).job_id;
410
+ if (!jobId) {
411
+ console.error("Error: Import accepted but no job ID returned.");
412
+ process.exit(1);
413
+ }
414
+
415
+ const POLL_INTERVAL_MS = 5_000;
416
+ const TIMEOUT_MS = 10 * 60 * 1_000; // 10 minutes
417
+ const startTime = Date.now();
418
+ const deadline = startTime + TIMEOUT_MS;
419
+
420
+ while (Date.now() < deadline) {
421
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
422
+
423
+ let status: {
424
+ status: string;
425
+ result?: Record<string, unknown>;
426
+ error?: string;
427
+ };
428
+ try {
429
+ status = await platformPollImportStatus(jobId, token, entry.runtimeUrl);
430
+ } catch (err) {
431
+ const msg = err instanceof Error ? err.message : String(err);
432
+ if (msg.includes("not found")) {
433
+ throw err;
434
+ }
435
+ // Fail fast on auth errors from authHeaders() which don't
436
+ // match the "status check failed: NNN" format
437
+ if (msg.includes("401") || msg.includes("403")) {
438
+ throw err;
439
+ }
440
+ // Re-throw permanent 4xx errors, retry transient 5xx
441
+ const statusMatch = msg.match(/status check failed: (\d+)/);
442
+ if (statusMatch) {
443
+ const statusCode = parseInt(statusMatch[1], 10);
444
+ if (statusCode >= 400 && statusCode < 500) {
445
+ throw err;
446
+ }
447
+ }
448
+ // Transient error (5xx, network) — retry
449
+ console.warn(`Polling failed, retrying... (${msg})`);
450
+ continue;
451
+ }
452
+
453
+ if (status.status === "complete") {
454
+ importResult = { statusCode: 200, body: status.result ?? {} };
455
+ break;
456
+ }
457
+
458
+ if (status.status === "failed") {
459
+ console.error(`Import failed: ${status.error ?? "unknown error"}`);
460
+ process.exit(1);
461
+ }
462
+
463
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
464
+ process.stdout.write(`\rImporting... ${elapsed}s elapsed`);
465
+ }
466
+
467
+ // Clear the progress line
468
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
469
+
470
+ if (importResult.statusCode === 202) {
471
+ console.error("Import timed out after 10 minutes.");
472
+ process.exit(1);
473
+ }
474
+ }
475
+
372
476
  const result = importResult.body as unknown as ImportResponse;
373
477
 
374
478
  if (!result.success) {
@@ -200,6 +200,13 @@ async function retireInner(): Promise<void> {
200
200
  const source = parseSource();
201
201
  const cloud = resolveCloud(entry);
202
202
 
203
+ if (cloud === "apple-container") {
204
+ console.error(
205
+ `Error: '${name}' uses the Apple Containers runtime. Its lifecycle is managed by the macOS app — use the app to retire it.`,
206
+ );
207
+ process.exit(1);
208
+ }
209
+
203
210
  if (cloud === "gcp") {
204
211
  const project = entry.project;
205
212
  const zone = entry.zone;
@@ -72,6 +72,13 @@ export async function sleep(): Promise<void> {
72
72
  return;
73
73
  }
74
74
 
75
+ if (entry.cloud === "apple-container") {
76
+ console.error(
77
+ `Error: '${entry.assistantId}' uses the Apple Containers runtime. Its lifecycle is managed by the macOS app — use the app to stop it.`,
78
+ );
79
+ process.exit(1);
80
+ }
81
+
75
82
  if (entry.cloud && entry.cloud !== "local") {
76
83
  console.error(
77
84
  `Error: 'vellum sleep' only works with local and docker assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
@@ -23,6 +23,7 @@ import {
23
23
  platformUploadToSignedUrl,
24
24
  platformImportPreflightFromGcs,
25
25
  platformImportBundleFromGcs,
26
+ platformPollImportStatus,
26
27
  } from "../lib/platform-client.js";
27
28
  import {
28
29
  hatchDocker,
@@ -512,6 +513,16 @@ async function exportFromAssistant(
512
513
  if (msg.includes("not found")) {
513
514
  throw err;
514
515
  }
516
+ // Re-throw permanent 4xx errors (auth, forbidden, etc.)
517
+ // but retry transient 5xx errors
518
+ const statusMatch = msg.match(/status check failed: (\d+)/);
519
+ if (statusMatch) {
520
+ const statusCode = parseInt(statusMatch[1], 10);
521
+ if (statusCode >= 400 && statusCode < 500) {
522
+ throw err;
523
+ }
524
+ }
525
+ // Transient error — retry
515
526
  console.warn(`Polling failed, retrying... (${msg})`);
516
527
  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
517
528
  continue;
@@ -706,7 +717,7 @@ async function importToAssistant(
706
717
  : await platformImportBundle(bundleData, token, entry.runtimeUrl);
707
718
  } catch (err) {
708
719
  if (err instanceof Error && err.name === "TimeoutError") {
709
- console.error("Error: Import request timed out after 5 minutes.");
720
+ console.error("Error: Import request timed out.");
710
721
  process.exit(1);
711
722
  }
712
723
  throw err;
@@ -714,6 +725,74 @@ async function importToAssistant(
714
725
 
715
726
  handleImportStatusErrors(importResult.statusCode, entry.assistantId);
716
727
 
728
+ if (importResult.statusCode === 202) {
729
+ const jobId = (importResult.body as { job_id?: string }).job_id;
730
+ if (!jobId) {
731
+ console.error("Error: Import accepted but no job ID returned.");
732
+ process.exit(1);
733
+ }
734
+
735
+ const POLL_INTERVAL_MS = 5_000;
736
+ const TIMEOUT_MS = 10 * 60 * 1_000; // 10 minutes (platform staleness is 930s)
737
+ const startTime = Date.now();
738
+ const deadline = startTime + TIMEOUT_MS;
739
+
740
+ while (Date.now() < deadline) {
741
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
742
+
743
+ let status: {
744
+ status: string;
745
+ result?: Record<string, unknown>;
746
+ error?: string;
747
+ };
748
+ try {
749
+ status = await platformPollImportStatus(
750
+ jobId,
751
+ token,
752
+ entry.runtimeUrl,
753
+ );
754
+ } catch (err) {
755
+ const msg = err instanceof Error ? err.message : String(err);
756
+ if (msg.includes("not found")) {
757
+ throw err;
758
+ }
759
+ // Re-throw permanent 4xx errors (auth, forbidden, etc.)
760
+ // but retry transient 5xx errors
761
+ const statusMatch = msg.match(/status check failed: (\d+)/);
762
+ if (statusMatch) {
763
+ const statusCode = parseInt(statusMatch[1], 10);
764
+ if (statusCode >= 400 && statusCode < 500) {
765
+ throw err;
766
+ }
767
+ }
768
+ // Transient error — retry
769
+ console.warn(`Polling failed, retrying... (${msg})`);
770
+ continue;
771
+ }
772
+
773
+ if (status.status === "complete") {
774
+ importResult = { statusCode: 200, body: status.result ?? {} };
775
+ break;
776
+ }
777
+
778
+ if (status.status === "failed") {
779
+ console.error(`Import failed: ${status.error ?? "unknown error"}`);
780
+ process.exit(1);
781
+ }
782
+
783
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
784
+ process.stdout.write(`\rImporting... ${elapsed}s elapsed`);
785
+ }
786
+
787
+ // Clear the progress line
788
+ process.stdout.write("\r" + " ".repeat(40) + "\r");
789
+
790
+ if (importResult.statusCode === 202) {
791
+ console.error("Import timed out after 10 minutes.");
792
+ process.exit(1);
793
+ }
794
+ }
795
+
717
796
  const result = importResult.body as unknown as ImportResponse;
718
797
  printImportSummary(result);
719
798
  return;
@@ -779,7 +858,7 @@ export async function resolveOrHatchTarget(
779
858
  // Hatch a new assistant in the target environment
780
859
  if (targetEnv === "local") {
781
860
  const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
782
- await hatchLocal("vellum", targetName ?? null, false, false, false, {});
861
+ await hatchLocal("vellum", targetName ?? null, false, false, {});
783
862
  const entry = targetName
784
863
  ? findAssistantByName(targetName)
785
864
  : (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
@@ -59,6 +59,13 @@ export async function wake(): Promise<void> {
59
59
  return;
60
60
  }
61
61
 
62
+ if (entry.cloud === "apple-container") {
63
+ console.error(
64
+ `Error: '${entry.assistantId}' uses the Apple Containers runtime. Its lifecycle is managed by the macOS app — use the app to start it.`,
65
+ );
66
+ process.exit(1);
67
+ }
68
+
62
69
  if (entry.cloud && entry.cloud !== "local") {
63
70
  console.error(
64
71
  `Error: 'vellum wake' only works with local and docker assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
@@ -181,6 +181,12 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
181
181
  return false;
182
182
  }
183
183
 
184
+ // Apple-containers entries are fully managed by the macOS app.
185
+ // Skip legacy migration to avoid corrupting their fields.
186
+ if (raw.cloud === "apple-container") {
187
+ return false;
188
+ }
189
+
184
190
  let mutated = false;
185
191
 
186
192
  // Migrate top-level `baseDataDir` → `resources.instanceDir`
package/src/lib/docker.ts CHANGED
@@ -644,6 +644,9 @@ export function serviceDockerRunArgs(opts: {
644
644
  ...(opts.bootstrapSecret
645
645
  ? ["-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`]
646
646
  : []),
647
+ ...(process.env.VELLUM_PLATFORM_URL
648
+ ? ["-e", `VELLUM_PLATFORM_URL=${process.env.VELLUM_PLATFORM_URL}`]
649
+ : []),
647
650
  imageTags.gateway,
648
651
  ],
649
652
  "credential-executor": () => [
@@ -144,18 +144,10 @@ function installCLISymlink(): void {
144
144
  export async function hatchLocal(
145
145
  species: Species,
146
146
  name: string | null,
147
- restart: boolean = false,
148
147
  watch: boolean = false,
149
148
  keepAlive: boolean = false,
150
149
  configValues: Record<string, string> = {},
151
150
  ): Promise<void> {
152
- if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
153
- console.error(
154
- "Error: Cannot restart without a known assistant ID. Provide --name or ensure VELLUM_ASSISTANT_NAME is set.",
155
- );
156
- process.exit(1);
157
- }
158
-
159
151
  const instanceName = generateInstanceName(
160
152
  species,
161
153
  name ?? process.env.VELLUM_ASSISTANT_NAME,
@@ -345,24 +337,22 @@ export async function hatchLocal(
345
337
  resources: { ...resources, signingKey },
346
338
  };
347
339
  emitProgress(7, 7, "Saving configuration...");
348
- if (!restart) {
349
- saveAssistantEntry(localEntry);
350
- setActiveAssistant(instanceName);
351
- syncConfigToLockfile();
352
-
353
- if (process.env.VELLUM_DESKTOP_APP) {
354
- installCLISymlink();
355
- }
340
+ saveAssistantEntry(localEntry);
341
+ setActiveAssistant(instanceName);
342
+ syncConfigToLockfile();
356
343
 
357
- console.log("");
358
- console.log(`✅ Local assistant hatched!`);
359
- console.log("");
360
- console.log("Instance details:");
361
- console.log(` Name: ${instanceName}`);
362
- console.log(` Runtime: ${runtimeUrl}`);
363
- console.log("");
344
+ if (process.env.VELLUM_DESKTOP_APP) {
345
+ installCLISymlink();
364
346
  }
365
347
 
348
+ console.log("");
349
+ console.log(`✅ Local assistant hatched!`);
350
+ console.log("");
351
+ console.log("Instance details:");
352
+ console.log(` Name: ${instanceName}`);
353
+ console.log(` Runtime: ${runtimeUrl}`);
354
+ console.log("");
355
+
366
356
  if (keepAlive) {
367
357
  const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
368
358
  const healthTarget = "Gateway";
@@ -529,7 +529,7 @@ export async function platformImportBundleFromGcs(
529
529
  method: "POST",
530
530
  headers: await authHeaders(token, platformUrl),
531
531
  body: JSON.stringify({ bundle_key: bundleKey }),
532
- signal: AbortSignal.timeout(300_000),
532
+ signal: AbortSignal.timeout(60_000),
533
533
  },
534
534
  );
535
535
 
@@ -543,3 +543,43 @@ export async function platformImportBundleFromGcs(
543
543
  >;
544
544
  return { statusCode: response.status, body };
545
545
  }
546
+
547
+ export async function platformPollImportStatus(
548
+ jobId: string,
549
+ token: string,
550
+ platformUrl?: string,
551
+ ): Promise<{
552
+ status: string;
553
+ result?: Record<string, unknown>;
554
+ error?: string;
555
+ }> {
556
+ const resolvedUrl = platformUrl || getPlatformUrl();
557
+ const response = await fetch(
558
+ `${resolvedUrl}/v1/migrations/import/${jobId}/status/`,
559
+ {
560
+ headers: await authHeaders(token, platformUrl),
561
+ },
562
+ );
563
+
564
+ if (response.status === 404) {
565
+ throw new Error("Import job not found");
566
+ }
567
+
568
+ if (!response.ok) {
569
+ throw new Error(
570
+ `Import status check failed: ${response.status} ${response.statusText}`,
571
+ );
572
+ }
573
+
574
+ const body = (await response.json()) as {
575
+ status: string;
576
+ job_id?: string;
577
+ result?: Record<string, unknown>;
578
+ error?: string;
579
+ };
580
+ return {
581
+ status: body.status,
582
+ result: body.result,
583
+ error: body.error,
584
+ };
585
+ }