@vellumai/cli 0.5.15 → 0.6.0

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/local.ts CHANGED
@@ -474,108 +474,6 @@ function resolveGatewayDir(): string {
474
474
  }
475
475
  }
476
476
 
477
- function normalizeIngressUrl(value: unknown): string | undefined {
478
- if (typeof value !== "string") return undefined;
479
- const normalized = value.trim().replace(/\/+$/, "");
480
- return normalized || undefined;
481
- }
482
-
483
- // ── Workspace config helpers ──
484
-
485
- function getWorkspaceConfigPath(instanceDir?: string): string {
486
- const baseDataDir =
487
- instanceDir ??
488
- (process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir()));
489
- return join(baseDataDir, ".vellum", "workspace", "config.json");
490
- }
491
-
492
- function loadWorkspaceConfig(instanceDir?: string): Record<string, unknown> {
493
- const configPath = getWorkspaceConfigPath(instanceDir);
494
- try {
495
- if (!existsSync(configPath)) return {};
496
- return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
497
- string,
498
- unknown
499
- >;
500
- } catch {
501
- return {};
502
- }
503
- }
504
-
505
- function saveWorkspaceConfig(
506
- config: Record<string, unknown>,
507
- instanceDir?: string,
508
- ): void {
509
- const configPath = getWorkspaceConfigPath(instanceDir);
510
- const dir = dirname(configPath);
511
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
512
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
513
- }
514
-
515
- /**
516
- * Write gateway operational settings to the workspace config file so the
517
- * gateway reads them at startup via its config.ts readWorkspaceConfig().
518
- */
519
- function writeGatewayConfig(
520
- instanceDir?: string,
521
- opts?: {
522
- runtimeProxyEnabled?: boolean;
523
- runtimeProxyRequireAuth?: boolean;
524
- unmappedPolicy?: "reject" | "default";
525
- defaultAssistantId?: string;
526
- routingEntries?: Array<{
527
- type: "conversation_id" | "actor_id";
528
- key: string;
529
- assistantId: string;
530
- }>;
531
- },
532
- ): void {
533
- const config = loadWorkspaceConfig(instanceDir);
534
- const gateway = (config.gateway ?? {}) as Record<string, unknown>;
535
-
536
- if (opts?.runtimeProxyEnabled !== undefined) {
537
- gateway.runtimeProxyEnabled = opts.runtimeProxyEnabled;
538
- }
539
- if (opts?.runtimeProxyRequireAuth !== undefined) {
540
- gateway.runtimeProxyRequireAuth = opts.runtimeProxyRequireAuth;
541
- }
542
- if (opts?.unmappedPolicy !== undefined) {
543
- gateway.unmappedPolicy = opts.unmappedPolicy;
544
- }
545
- if (opts?.defaultAssistantId !== undefined) {
546
- gateway.defaultAssistantId = opts.defaultAssistantId;
547
- }
548
- if (opts?.routingEntries !== undefined) {
549
- gateway.routingEntries = opts.routingEntries;
550
- }
551
-
552
- config.gateway = gateway;
553
- saveWorkspaceConfig(config, instanceDir);
554
- }
555
-
556
- function readWorkspaceIngressPublicBaseUrl(
557
- instanceDir?: string,
558
- ): string | undefined {
559
- const baseDataDir =
560
- instanceDir ??
561
- (process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir()));
562
- const workspaceConfigPath = join(
563
- baseDataDir,
564
- ".vellum",
565
- "workspace",
566
- "config.json",
567
- );
568
- try {
569
- const raw = JSON.parse(
570
- readFileSync(workspaceConfigPath, "utf-8"),
571
- ) as Record<string, unknown>;
572
- const ingress = raw.ingress as Record<string, unknown> | undefined;
573
- return normalizeIngressUrl(ingress?.publicBaseUrl);
574
- } catch {
575
- return undefined;
576
- }
577
- }
578
-
579
477
  /**
580
478
  * Check if the daemon is responsive by hitting its HTTP `/healthz` endpoint.
581
479
  * This replaces the socket-based `isSocketResponsive()` check.
@@ -973,6 +871,7 @@ export async function startLocalDaemon(
973
871
  "VELLUM_DEBUG",
974
872
  "VELLUM_DEV",
975
873
  "VELLUM_DESKTOP_APP",
874
+ "VELLUM_WORKSPACE_DIR",
976
875
  ]) {
977
876
  if (process.env[key]) {
978
877
  daemonEnv[key] = process.env[key]!;
@@ -1131,19 +1030,16 @@ export async function startGateway(
1131
1030
  const effectiveDaemonPort =
1132
1031
  resources?.daemonPort ?? Number(process.env.RUNTIME_HTTP_PORT || "7821");
1133
1032
 
1134
- // Write gateway operational settings to workspace config before starting
1135
- // the gateway process. The gateway reads these at startup from config.json.
1136
- writeGatewayConfig(resources?.instanceDir, {
1137
- runtimeProxyEnabled: true,
1138
- runtimeProxyRequireAuth: true,
1139
- unmappedPolicy: "default",
1140
- defaultAssistantId: "self",
1141
- });
1142
-
1143
1033
  const gatewayEnv: Record<string, string> = {
1144
1034
  ...(process.env as Record<string, string>),
1145
1035
  RUNTIME_HTTP_PORT: String(effectiveDaemonPort),
1146
1036
  GATEWAY_PORT: String(effectiveGatewayPort),
1037
+ // Pass gateway operational settings via env vars so the CLI does not
1038
+ // need direct access to the workspace config file.
1039
+ RUNTIME_PROXY_ENABLED: "true",
1040
+ RUNTIME_PROXY_REQUIRE_AUTH: "true",
1041
+ UNMAPPED_POLICY: "default",
1042
+ DEFAULT_ASSISTANT_ID: "self",
1147
1043
  ...(options?.signingKey
1148
1044
  ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
1149
1045
  : {}),
@@ -1152,15 +1048,8 @@ export async function startGateway(
1152
1048
  // workspace config for this instance (mirrors the daemon env setup).
1153
1049
  ...(resources ? { BASE_DATA_DIR: resources.instanceDir } : {}),
1154
1050
  };
1155
- // The gateway reads the ingress URL from the workspace config file via
1156
- // ConfigFileCache — no env var passthrough needed. Log the resolved value
1157
- // for diagnostic visibility during startup.
1158
- const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl(
1159
- resources?.instanceDir,
1160
- );
1161
- const ingressPublicBaseUrl = workspaceIngressPublicBaseUrl ?? publicUrl;
1162
- if (ingressPublicBaseUrl) {
1163
- console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
1051
+ if (publicUrl) {
1052
+ console.log(` Ingress URL: ${publicUrl}`);
1164
1053
  }
1165
1054
 
1166
1055
  let gateway;
@@ -87,14 +87,20 @@ export interface HatchedAssistant {
87
87
  status: string;
88
88
  }
89
89
 
90
- export async function hatchAssistant(token: string): Promise<HatchedAssistant> {
91
- const url = `${getPlatformUrl()}/v1/assistants/hatch/`;
90
+ export async function hatchAssistant(
91
+ token: string,
92
+ orgId: string,
93
+ platformUrl?: string,
94
+ ): Promise<HatchedAssistant> {
95
+ const resolvedUrl = platformUrl || getPlatformUrl();
96
+ const url = `${resolvedUrl}/v1/assistants/hatch/`;
92
97
 
93
98
  const response = await fetch(url, {
94
99
  method: "POST",
95
100
  headers: {
96
101
  "Content-Type": "application/json",
97
102
  ...authHeaders(token),
103
+ "Vellum-Organization-Id": orgId,
98
104
  },
99
105
  body: JSON.stringify({}),
100
106
  });
@@ -143,7 +149,7 @@ export async function fetchOrganizationId(
143
149
  const resolvedUrl = platformUrl || getPlatformUrl();
144
150
  const url = `${resolvedUrl}/v1/organizations/`;
145
151
  const response = await fetch(url, {
146
- headers: { "X-Session-Token": token },
152
+ headers: { ...authHeaders(token) },
147
153
  });
148
154
 
149
155
  if (!response.ok) {
@@ -213,7 +219,7 @@ export async function rollbackPlatformAssistant(
213
219
  method: "POST",
214
220
  headers: {
215
221
  "Content-Type": "application/json",
216
- "X-Session-Token": token,
222
+ ...authHeaders(token),
217
223
  "Vellum-Organization-Id": orgId,
218
224
  },
219
225
  body: JSON.stringify(version ? { version } : {}),
@@ -258,7 +264,7 @@ export async function platformInitiateExport(
258
264
  method: "POST",
259
265
  headers: {
260
266
  "Content-Type": "application/json",
261
- "X-Session-Token": token,
267
+ ...authHeaders(token),
262
268
  "Vellum-Organization-Id": orgId,
263
269
  },
264
270
  body: JSON.stringify({ description: description ?? "CLI backup" }),
@@ -292,7 +298,7 @@ export async function platformPollExportStatus(
292
298
  `${resolvedUrl}/v1/migrations/export/${jobId}/status/`,
293
299
  {
294
300
  headers: {
295
- "X-Session-Token": token,
301
+ ...authHeaders(token),
296
302
  "Vellum-Organization-Id": orgId,
297
303
  },
298
304
  },
@@ -349,7 +355,7 @@ export async function platformImportPreflight(
349
355
  method: "POST",
350
356
  headers: {
351
357
  "Content-Type": "application/octet-stream",
352
- "X-Session-Token": token,
358
+ ...authHeaders(token),
353
359
  "Vellum-Organization-Id": orgId,
354
360
  },
355
361
  body: new Blob([bundleData]),
@@ -375,7 +381,7 @@ export async function platformImportBundle(
375
381
  method: "POST",
376
382
  headers: {
377
383
  "Content-Type": "application/octet-stream",
378
- "X-Session-Token": token,
384
+ ...authHeaders(token),
379
385
  "Vellum-Organization-Id": orgId,
380
386
  },
381
387
  body: new Blob([bundleData]),
@@ -388,3 +394,131 @@ export async function platformImportBundle(
388
394
  >;
389
395
  return { statusCode: response.status, body };
390
396
  }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Signed-URL upload flow
400
+ // ---------------------------------------------------------------------------
401
+
402
+ export async function platformRequestUploadUrl(
403
+ token: string,
404
+ orgId: string,
405
+ platformUrl?: string,
406
+ ): Promise<{ uploadUrl: string; bundleKey: string; expiresAt: string }> {
407
+ const resolvedUrl = platformUrl || getPlatformUrl();
408
+ const response = await fetch(`${resolvedUrl}/v1/migrations/upload-url/`, {
409
+ method: "POST",
410
+ headers: {
411
+ "Content-Type": "application/json",
412
+ ...authHeaders(token),
413
+ "Vellum-Organization-Id": orgId,
414
+ },
415
+ body: JSON.stringify({ content_type: "application/octet-stream" }),
416
+ });
417
+
418
+ if (response.status === 201) {
419
+ const body = (await response.json()) as {
420
+ upload_url: string;
421
+ bundle_key: string;
422
+ expires_at: string;
423
+ };
424
+ return {
425
+ uploadUrl: body.upload_url,
426
+ bundleKey: body.bundle_key,
427
+ expiresAt: body.expires_at,
428
+ };
429
+ }
430
+
431
+ if (response.status === 404 || response.status === 503) {
432
+ throw new Error(
433
+ "Signed uploads are not available on this platform instance",
434
+ );
435
+ }
436
+
437
+ const errorBody = (await response.json().catch(() => ({}))) as {
438
+ detail?: string;
439
+ };
440
+ throw new Error(
441
+ errorBody.detail ??
442
+ `Failed to request upload URL: ${response.status} ${response.statusText}`,
443
+ );
444
+ }
445
+
446
+ export async function platformUploadToSignedUrl(
447
+ uploadUrl: string,
448
+ bundleData: Uint8Array<ArrayBuffer>,
449
+ ): Promise<void> {
450
+ const response = await fetch(uploadUrl, {
451
+ method: "PUT",
452
+ headers: {
453
+ "Content-Type": "application/octet-stream",
454
+ },
455
+ body: new Blob([bundleData]),
456
+ signal: AbortSignal.timeout(600_000),
457
+ });
458
+
459
+ if (!response.ok) {
460
+ throw new Error(
461
+ `Upload to signed URL failed: ${response.status} ${response.statusText}`,
462
+ );
463
+ }
464
+ }
465
+
466
+ export async function platformImportPreflightFromGcs(
467
+ bundleKey: string,
468
+ token: string,
469
+ orgId: string,
470
+ platformUrl?: string,
471
+ ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
472
+ const resolvedUrl = platformUrl || getPlatformUrl();
473
+ const response = await fetch(
474
+ `${resolvedUrl}/v1/migrations/import-preflight-from-gcs/`,
475
+ {
476
+ method: "POST",
477
+ headers: {
478
+ "Content-Type": "application/json",
479
+ ...authHeaders(token),
480
+ "Vellum-Organization-Id": orgId,
481
+ },
482
+ body: JSON.stringify({ bundle_key: bundleKey }),
483
+ signal: AbortSignal.timeout(120_000),
484
+ },
485
+ );
486
+
487
+ const body = (await response.json().catch(() => ({}))) as Record<
488
+ string,
489
+ unknown
490
+ >;
491
+ return { statusCode: response.status, body };
492
+ }
493
+
494
+ export async function platformImportBundleFromGcs(
495
+ bundleKey: string,
496
+ token: string,
497
+ orgId: string,
498
+ platformUrl?: string,
499
+ ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
500
+ const resolvedUrl = platformUrl || getPlatformUrl();
501
+ const response = await fetch(
502
+ `${resolvedUrl}/v1/migrations/import-from-gcs/`,
503
+ {
504
+ method: "POST",
505
+ headers: {
506
+ "Content-Type": "application/json",
507
+ ...authHeaders(token),
508
+ "Vellum-Organization-Id": orgId,
509
+ },
510
+ body: JSON.stringify({ bundle_key: bundleKey }),
511
+ signal: AbortSignal.timeout(120_000),
512
+ },
513
+ );
514
+
515
+ if (response.status === 413) {
516
+ throw new Error("Bundle too large to import");
517
+ }
518
+
519
+ const body = (await response.json().catch(() => ({}))) as Record<
520
+ string,
521
+ unknown
522
+ >;
523
+ return { statusCode: response.status, body };
524
+ }
@@ -0,0 +1,124 @@
1
+ import { spawn } from "child_process";
2
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
3
+ import { basename, dirname, join } from "path";
4
+
5
+ import { getBaseDir, loadAllAssistants } from "./assistant-config.js";
6
+ import type { AssistantEntry } from "./assistant-config.js";
7
+ import {
8
+ stopOrphanedDaemonProcesses,
9
+ stopProcessByPidFile,
10
+ } from "./process.js";
11
+ import { getArchivePath, getMetadataPath } from "./retire-archive.js";
12
+
13
+ export async function retireLocal(
14
+ name: string,
15
+ entry: AssistantEntry,
16
+ ): Promise<void> {
17
+ console.log("\u{1F5D1}\ufe0f Stopping local assistant...\n");
18
+
19
+ if (!entry.resources) {
20
+ throw new Error(
21
+ `Local assistant '${name}' is missing resource configuration. Re-hatch to fix.`,
22
+ );
23
+ }
24
+ const resources = entry.resources;
25
+ const vellumDir = join(resources.instanceDir, ".vellum");
26
+
27
+ // Check whether another local assistant shares the same data directory.
28
+ const otherSharesDir = loadAllAssistants().some((other) => {
29
+ if (other.cloud !== "local") return false;
30
+ if (other.assistantId === name) return false;
31
+ if (!other.resources) return false;
32
+ const otherVellumDir = join(other.resources.instanceDir, ".vellum");
33
+ return otherVellumDir === vellumDir;
34
+ });
35
+
36
+ if (otherSharesDir) {
37
+ console.log(
38
+ ` Skipping process stop and archive — another local assistant shares ${vellumDir}.`,
39
+ );
40
+ console.log("\u2705 Local instance retired (config entry removed only).");
41
+ return;
42
+ }
43
+
44
+ const daemonPidFile = resources.pidFile;
45
+ const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon");
46
+
47
+ // Stop gateway via PID file — use a longer timeout because the gateway has a
48
+ // drain window (5s) before it exits.
49
+ const gatewayPidFile = join(vellumDir, "gateway.pid");
50
+ await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
51
+
52
+ // Stop Qdrant — the daemon's graceful shutdown tries to stop it via
53
+ // qdrantManager.stop(), but if the daemon was SIGKILL'd (after 2s timeout)
54
+ // Qdrant may still be running as an orphan. Check both the current PID file
55
+ // location and the legacy location.
56
+ const qdrantPidFile = join(
57
+ vellumDir,
58
+ "workspace",
59
+ "data",
60
+ "qdrant",
61
+ "qdrant.pid",
62
+ );
63
+ const qdrantLegacyPidFile = join(vellumDir, "qdrant.pid");
64
+ await stopProcessByPidFile(qdrantPidFile, "qdrant", undefined, 5000);
65
+ await stopProcessByPidFile(qdrantLegacyPidFile, "qdrant", undefined, 5000);
66
+
67
+ // If the PID file didn't track a running daemon, scan for orphaned
68
+ // daemon processes that may have been started without writing a PID.
69
+ if (!daemonStopped) {
70
+ await stopOrphanedDaemonProcesses();
71
+ }
72
+
73
+ // For named instances (instanceDir differs from the base directory),
74
+ // archive and remove the entire instance directory. For the default
75
+ // instance, archive only the .vellum subdirectory.
76
+ const isNamedInstance = resources.instanceDir !== getBaseDir();
77
+ const dirToArchive = isNamedInstance ? resources.instanceDir : vellumDir;
78
+
79
+ // Move the data directory out of the way so the path is immediately available
80
+ // for the next hatch, then kick off the tar archive in the background.
81
+ const archivePath = getArchivePath(name);
82
+ const metadataPath = getMetadataPath(name);
83
+ const stagingDir = `${archivePath}.staging`;
84
+
85
+ if (!existsSync(dirToArchive)) {
86
+ console.log(
87
+ ` No data directory at ${dirToArchive} — nothing to archive.`,
88
+ );
89
+ console.log("\u2705 Local instance retired.");
90
+ return;
91
+ }
92
+
93
+ // Ensure the retired archive directory exists before attempting the rename
94
+ mkdirSync(dirname(stagingDir), { recursive: true });
95
+
96
+ try {
97
+ renameSync(dirToArchive, stagingDir);
98
+ } catch (err) {
99
+ // Re-throw so the caller (and the desktop app) knows the archive failed.
100
+ // If the rename fails, old workspace data stays in place and a subsequent
101
+ // hatch would inherit stale SOUL.md, IDENTITY.md, and memories.
102
+ throw new Error(
103
+ `Failed to archive ${dirToArchive}: ${err instanceof Error ? err.message : err}`,
104
+ );
105
+ }
106
+
107
+ writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
108
+
109
+ // Spawn tar + cleanup in the background and detach so the CLI can exit
110
+ // immediately. The staging directory is removed once the archive is written.
111
+ const tarCmd = [
112
+ `tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(dirname(stagingDir))} ${JSON.stringify(basename(stagingDir))}`,
113
+ `rm -rf ${JSON.stringify(stagingDir)}`,
114
+ ].join(" && ");
115
+
116
+ const child = spawn("sh", ["-c", tarCmd], {
117
+ stdio: "ignore",
118
+ detached: true,
119
+ });
120
+ child.unref();
121
+
122
+ console.log(`📦 Archiving to ${archivePath} in the background.`);
123
+ console.log("\u2705 Local instance retired.");
124
+ }
@@ -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",
@@ -467,6 +468,11 @@ export async function performDockerRollback(
467
468
  ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
468
469
  );
469
470
 
471
+ // Capture GUARDIAN_BOOTSTRAP_SECRET from the gateway container (it is only
472
+ // set on gateway, not assistant) so it persists across container restarts.
473
+ const gatewayEnv = await captureContainerEnv(res.gatewayContainer);
474
+ const bootstrapSecret = gatewayEnv["GUARDIAN_BOOTSTRAP_SECRET"];
475
+
470
476
  const cesServiceToken =
471
477
  capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
472
478
 
@@ -575,6 +581,7 @@ export async function performDockerRollback(
575
581
  await startContainers(
576
582
  {
577
583
  signingKey,
584
+ bootstrapSecret,
578
585
  cesServiceToken,
579
586
  extraAssistantEnv,
580
587
  gatewayPort,
@@ -695,6 +702,7 @@ export async function performDockerRollback(
695
702
  await startContainers(
696
703
  {
697
704
  signingKey,
705
+ bootstrapSecret,
698
706
  cesServiceToken,
699
707
  extraAssistantEnv,
700
708
  gatewayPort,
@@ -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
+ };