@vellumai/cli 0.5.6 → 0.5.7

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
@@ -192,10 +192,15 @@ function resolveDaemonMainPath(assistantIndex: string): string {
192
192
  return join(dirname(assistantIndex), "daemon", "main.ts");
193
193
  }
194
194
 
195
+ type DaemonStartOptions = {
196
+ foreground?: boolean;
197
+ defaultWorkspaceConfigPath?: string;
198
+ };
199
+
195
200
  async function startDaemonFromSource(
196
201
  assistantIndex: string,
197
202
  resources: LocalInstanceResources,
198
- options?: { foreground?: boolean },
203
+ options?: DaemonStartOptions,
199
204
  ): Promise<void> {
200
205
  const foreground = options?.foreground ?? false;
201
206
  const daemonMainPath = resolveDaemonMainPath(assistantIndex);
@@ -260,6 +265,7 @@ async function startDaemonFromSource(
260
265
  const env: Record<string, string | undefined> = {
261
266
  ...process.env,
262
267
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
268
+ VELLUM_CLOUD: "local",
263
269
  };
264
270
  if (resources) {
265
271
  env.BASE_DATA_DIR = resources.instanceDir;
@@ -268,6 +274,10 @@ async function startDaemonFromSource(
268
274
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
269
275
  delete env.QDRANT_URL;
270
276
  }
277
+ if (options?.defaultWorkspaceConfigPath) {
278
+ env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
279
+ options.defaultWorkspaceConfigPath;
280
+ }
271
281
 
272
282
  // Write a sentinel PID file before spawning so concurrent hatch() calls
273
283
  // detect the in-progress spawn and wait instead of racing.
@@ -306,6 +316,7 @@ async function startDaemonFromSource(
306
316
  async function startDaemonWatchFromSource(
307
317
  assistantIndex: string,
308
318
  resources: LocalInstanceResources,
319
+ options?: DaemonStartOptions,
309
320
  ): Promise<void> {
310
321
  const mainPath = resolveDaemonMainPath(assistantIndex);
311
322
  if (!existsSync(mainPath)) {
@@ -381,6 +392,10 @@ async function startDaemonWatchFromSource(
381
392
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
382
393
  delete env.QDRANT_URL;
383
394
  }
395
+ if (options?.defaultWorkspaceConfigPath) {
396
+ env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
397
+ options.defaultWorkspaceConfigPath;
398
+ }
384
399
 
385
400
  // Write a sentinel PID file before spawning so concurrent hatch() calls
386
401
  // detect the in-progress spawn and wait instead of racing.
@@ -819,7 +834,7 @@ export function isGatewayWatchModeAvailable(): boolean {
819
834
  export async function startLocalDaemon(
820
835
  watch: boolean = false,
821
836
  resources: LocalInstanceResources,
822
- options?: { foreground?: boolean },
837
+ options?: DaemonStartOptions,
823
838
  ): Promise<void> {
824
839
  const foreground = options?.foreground ?? false;
825
840
  // Check for a compiled daemon binary adjacent to the CLI executable.
@@ -905,7 +920,7 @@ export async function startLocalDaemon(
905
920
 
906
921
  // Build a minimal environment for the daemon. When launched from the
907
922
  // macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
908
- // __CFBundleIdentifier, CLAUDE_CODE_ENTRYPOINT, etc.) that can cause
923
+ // __CFBundleIdentifier, etc.) that can cause
909
924
  // the daemon to take 50+ seconds to start instead of ~1s.
910
925
  const home = homedir();
911
926
  const bunBinDir = join(home, ".bun", "bin");
@@ -924,20 +939,25 @@ export async function startLocalDaemon(
924
939
  "ANTHROPIC_API_KEY",
925
940
  "APP_VERSION",
926
941
  "BASE_DATA_DIR",
927
- "PLATFORM_BASE_URL",
942
+ "VELLUM_PLATFORM_URL",
928
943
  "QDRANT_HTTP_PORT",
929
944
  "QDRANT_URL",
930
945
  "RUNTIME_HTTP_PORT",
931
- "SENTRY_DSN",
946
+ "SENTRY_DSN_ASSISTANT",
932
947
  "TMPDIR",
933
948
  "USER",
934
949
  "LANG",
935
950
  "VELLUM_DEBUG",
951
+ "VELLUM_DESKTOP_APP",
936
952
  ]) {
937
953
  if (process.env[key]) {
938
954
  daemonEnv[key] = process.env[key]!;
939
955
  }
940
956
  }
957
+ if (options?.defaultWorkspaceConfigPath) {
958
+ daemonEnv.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
959
+ options.defaultWorkspaceConfigPath;
960
+ }
941
961
  // When running a named instance, override env so the daemon resolves
942
962
  // all paths under the instance directory and listens on its own port.
943
963
  if (resources) {
@@ -1005,9 +1025,9 @@ export async function startLocalDaemon(
1005
1025
  // Kill the bundled daemon to avoid two processes competing for the same port
1006
1026
  await stopProcessByPidFile(pidFile, "bundled daemon");
1007
1027
  if (watch) {
1008
- await startDaemonWatchFromSource(assistantIndex, resources);
1028
+ await startDaemonWatchFromSource(assistantIndex, resources, options);
1009
1029
  } else {
1010
- await startDaemonFromSource(assistantIndex, resources);
1030
+ await startDaemonFromSource(assistantIndex, resources, options);
1011
1031
  }
1012
1032
  daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
1013
1033
  }
@@ -1031,7 +1051,7 @@ export async function startLocalDaemon(
1031
1051
  );
1032
1052
  }
1033
1053
  if (watch) {
1034
- await startDaemonWatchFromSource(assistantIndex, resources);
1054
+ await startDaemonWatchFromSource(assistantIndex, resources, options);
1035
1055
 
1036
1056
  const daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
1037
1057
  if (daemonReady) {
@@ -1042,7 +1062,7 @@ export async function startLocalDaemon(
1042
1062
  );
1043
1063
  }
1044
1064
  } else {
1045
- await startDaemonFromSource(assistantIndex, resources, { foreground });
1065
+ await startDaemonFromSource(assistantIndex, resources, options);
1046
1066
 
1047
1067
  const daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
1048
1068
  if (daemonReady) {
@@ -9,7 +9,7 @@ import {
9
9
  import { homedir } from "os";
10
10
  import { join, dirname } from "path";
11
11
 
12
- const DEFAULT_PLATFORM_URL = "https://platform.vellum.ai";
12
+ const DEFAULT_PLATFORM_URL = "";
13
13
 
14
14
  function getXdgConfigHome(): string {
15
15
  return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
@@ -23,6 +23,20 @@ export function getPlatformUrl(): string {
23
23
  return process.env.VELLUM_PLATFORM_URL ?? DEFAULT_PLATFORM_URL;
24
24
  }
25
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
+ );
36
+ }
37
+ return url;
38
+ }
39
+
26
40
  export function readPlatformToken(): string | null {
27
41
  try {
28
42
  return readFileSync(getPlatformTokenPath(), "utf-8").trim();
@@ -60,7 +74,7 @@ interface OrganizationListResponse {
60
74
  }
61
75
 
62
76
  export async function fetchOrganizationId(token: string): Promise<string> {
63
- const platformUrl = getPlatformUrl();
77
+ const platformUrl = requirePlatformUrl();
64
78
  const url = `${platformUrl}/v1/organizations/`;
65
79
  const response = await fetch(url, {
66
80
  headers: { "X-Session-Token": token },
@@ -92,7 +106,7 @@ interface AllauthSessionResponse {
92
106
  }
93
107
 
94
108
  export async function fetchCurrentUser(token: string): Promise<PlatformUser> {
95
- const url = `${getPlatformUrl()}/_allauth/app/v1/auth/session`;
109
+ const url = `${requirePlatformUrl()}/_allauth/app/v1/auth/session`;
96
110
  const response = await fetch(url, {
97
111
  headers: { "X-Session-Token": token },
98
112
  });
@@ -0,0 +1,112 @@
1
+ import { getPlatformUrl } from "./platform-client.js";
2
+ import { DOCKERHUB_IMAGES } from "./docker.js";
3
+ import type { ServiceName } from "./docker.js";
4
+
5
+ export interface ResolvedImageRefs {
6
+ imageTags: Record<ServiceName, string>;
7
+ source: "platform" | "dockerhub";
8
+ }
9
+
10
+ /**
11
+ * Resolve image references for a given version.
12
+ *
13
+ * Tries the platform API first (returns GCR digest-based refs when available),
14
+ * then falls back to DockerHub tag-based refs when the platform is unreachable
15
+ * or the version is not found.
16
+ */
17
+ export async function resolveImageRefs(
18
+ version: string,
19
+ log?: (msg: string) => void,
20
+ ): Promise<ResolvedImageRefs> {
21
+ log?.("Resolving image references...");
22
+
23
+ const platformRefs = await fetchPlatformImageRefs(version, log);
24
+ if (platformRefs) {
25
+ log?.("Resolved image refs from platform API");
26
+ return { imageTags: platformRefs, source: "platform" };
27
+ }
28
+
29
+ log?.("Falling back to DockerHub tags");
30
+ const imageTags: Record<ServiceName, string> = {
31
+ assistant: `${DOCKERHUB_IMAGES.assistant}:${version}`,
32
+ "credential-executor": `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`,
33
+ gateway: `${DOCKERHUB_IMAGES.gateway}:${version}`,
34
+ };
35
+ return { imageTags, source: "dockerhub" };
36
+ }
37
+
38
+ /**
39
+ * Fetch image references from the platform releases API.
40
+ *
41
+ * Returns a record of service name to image ref (GCR digest-based) for the
42
+ * given version, or null if the platform is unreachable, the version is not
43
+ * found, or any error occurs.
44
+ */
45
+ async function fetchPlatformImageRefs(
46
+ version: string,
47
+ log?: (msg: string) => void,
48
+ ): Promise<Record<ServiceName, string> | null> {
49
+ try {
50
+ const platformUrl = getPlatformUrl();
51
+ const url = `${platformUrl}/v1/releases/?stable=true`;
52
+
53
+ log?.(`Fetching releases from ${url}`);
54
+
55
+ const response = await fetch(url, {
56
+ signal: AbortSignal.timeout(10_000),
57
+ });
58
+
59
+ if (!response.ok) {
60
+ log?.(`Platform API returned ${response.status}`);
61
+ return null;
62
+ }
63
+
64
+ const releases = (await response.json()) as Array<{
65
+ version?: string;
66
+ assistant_image_ref?: string | null;
67
+ gateway_image_ref?: string | null;
68
+ credential_executor_image_ref?: string | null;
69
+ }>;
70
+
71
+ // Strip leading "v" from the requested version for matching
72
+ const normalizedVersion = version.replace(/^v/, "");
73
+
74
+ const release = releases.find((r) => {
75
+ const releaseVersion = (r.version ?? "").replace(/^v/, "");
76
+ return releaseVersion === normalizedVersion;
77
+ });
78
+
79
+ if (!release) {
80
+ log?.(`Version ${version} not found in platform releases`);
81
+ return null;
82
+ }
83
+
84
+ const assistantImage = release.assistant_image_ref;
85
+ const gatewayImage = release.gateway_image_ref;
86
+ let credentialExecutorImage = release.credential_executor_image_ref;
87
+
88
+ // Assistant and gateway images are required; credential-executor falls back to DockerHub
89
+ if (!assistantImage || !gatewayImage) {
90
+ log?.("Platform release missing required image refs");
91
+ return null;
92
+ }
93
+
94
+ // Fall back to DockerHub for credential-executor if its image ref is null
95
+ if (!credentialExecutorImage) {
96
+ credentialExecutorImage = `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`;
97
+ log?.(
98
+ "credential-executor image not in platform release, using DockerHub fallback",
99
+ );
100
+ }
101
+
102
+ return {
103
+ assistant: assistantImage,
104
+ "credential-executor": credentialExecutorImage,
105
+ gateway: gatewayImage,
106
+ };
107
+ } catch (err) {
108
+ const message = err instanceof Error ? err.message : String(err);
109
+ log?.(`Platform image ref resolution failed: ${message}`);
110
+ return null;
111
+ }
112
+ }
@@ -0,0 +1,237 @@
1
+ import { DOCKER_READY_TIMEOUT_MS } from "./docker.js";
2
+ import { loadGuardianToken } from "./guardian-token.js";
3
+ import { execOutput } from "./step-runner.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Shared constants & builders for upgrade / rollback lifecycle events
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /** User-facing progress messages shared across upgrade and rollback flows. */
10
+ export const UPGRADE_PROGRESS = {
11
+ DOWNLOADING: "Downloading the update…",
12
+ BACKING_UP: "Saving a backup of your data…",
13
+ INSTALLING: "Installing the update…",
14
+ REVERTING: "The update didn't work. Reverting to the previous version…",
15
+ REVERTING_MIGRATIONS: "Reverting database changes…",
16
+ RESTORING: "Restoring your data…",
17
+ SWITCHING: "Switching to the previous version…",
18
+ } as const;
19
+
20
+ export function buildStartingEvent(
21
+ targetVersion: string,
22
+ expectedDowntimeSeconds = 60,
23
+ ) {
24
+ return { type: "starting" as const, targetVersion, expectedDowntimeSeconds };
25
+ }
26
+
27
+ export function buildProgressEvent(statusMessage: string) {
28
+ return { type: "progress" as const, statusMessage };
29
+ }
30
+
31
+ export function buildCompleteEvent(
32
+ installedVersion: string,
33
+ success: boolean,
34
+ rolledBackToVersion?: string,
35
+ ) {
36
+ return {
37
+ type: "complete" as const,
38
+ installedVersion,
39
+ success,
40
+ ...(rolledBackToVersion ? { rolledBackToVersion } : {}),
41
+ };
42
+ }
43
+
44
+ export function buildUpgradeCommitMessage(options: {
45
+ action: "upgrade" | "rollback";
46
+ phase: "starting" | "complete";
47
+ from: string;
48
+ to: string;
49
+ topology: "docker" | "managed";
50
+ assistantId: string;
51
+ result?: "success" | "failure";
52
+ }): string {
53
+ const { action, phase, from, to, topology, assistantId, result } = options;
54
+ const header =
55
+ phase === "starting"
56
+ ? `[${action}] Starting: ${from} → ${to}`
57
+ : `[${action}] Complete: ${from} → ${to}`;
58
+ const lines = [
59
+ header,
60
+ "",
61
+ `assistant: ${assistantId}`,
62
+ `from: ${from}`,
63
+ `to: ${to}`,
64
+ ];
65
+ if (result) lines.push(`result: ${result}`);
66
+ lines.push(`topology: ${topology}`);
67
+ return lines.join("\n");
68
+ }
69
+
70
+ /**
71
+ * Environment variable keys that are set by CLI run arguments and should
72
+ * not be replayed from a captured container environment during upgrades
73
+ * or rollbacks. Shared between upgrade.ts and rollback.ts.
74
+ */
75
+ export const CONTAINER_ENV_EXCLUDE_KEYS: ReadonlySet<string> = new Set([
76
+ "CES_SERVICE_TOKEN",
77
+ "VELLUM_ASSISTANT_NAME",
78
+ "RUNTIME_HTTP_HOST",
79
+ "PATH",
80
+ "ACTOR_TOKEN_SIGNING_KEY",
81
+ ]);
82
+
83
+ /**
84
+ * Capture environment variables from a running Docker container so they
85
+ * can be replayed onto the replacement container after upgrade.
86
+ */
87
+ export async function captureContainerEnv(
88
+ containerName: string,
89
+ ): Promise<Record<string, string>> {
90
+ const captured: Record<string, string> = {};
91
+ try {
92
+ const raw = await execOutput("docker", [
93
+ "inspect",
94
+ "--format",
95
+ "{{json .Config.Env}}",
96
+ containerName,
97
+ ]);
98
+ const entries = JSON.parse(raw) as string[];
99
+ for (const entry of entries) {
100
+ const eqIdx = entry.indexOf("=");
101
+ if (eqIdx > 0) {
102
+ captured[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
103
+ }
104
+ }
105
+ } catch {
106
+ // Container may not exist or not be inspectable
107
+ }
108
+ return captured;
109
+ }
110
+
111
+ /**
112
+ * Poll the gateway `/readyz` endpoint until it returns 200 or the timeout
113
+ * elapses. Returns whether the assistant became ready.
114
+ */
115
+ export async function waitForReady(runtimeUrl: string): Promise<boolean> {
116
+ const readyUrl = `${runtimeUrl}/readyz`;
117
+ const start = Date.now();
118
+
119
+ while (Date.now() - start < DOCKER_READY_TIMEOUT_MS) {
120
+ try {
121
+ const resp = await fetch(readyUrl, {
122
+ signal: AbortSignal.timeout(5000),
123
+ });
124
+ if (resp.ok) {
125
+ const elapsedSec = ((Date.now() - start) / 1000).toFixed(1);
126
+ console.log(`Assistant ready after ${elapsedSec}s`);
127
+ return true;
128
+ }
129
+ let detail = "";
130
+ try {
131
+ const body = await resp.text();
132
+ const json = JSON.parse(body);
133
+ const parts = [json.status];
134
+ if (json.upstream != null) parts.push(`upstream=${json.upstream}`);
135
+ detail = ` — ${parts.join(", ")}`;
136
+ } catch {
137
+ // ignore parse errors
138
+ }
139
+ console.log(`Readiness check: ${resp.status}${detail} (retrying...)`);
140
+ } catch {
141
+ // Connection refused / timeout — not up yet
142
+ }
143
+ await new Promise((r) => setTimeout(r, 1000));
144
+ }
145
+
146
+ return false;
147
+ }
148
+
149
+ /**
150
+ * Best-effort broadcast of an upgrade lifecycle event to connected clients
151
+ * via the gateway's upgrade-broadcast proxy. Uses guardian token auth.
152
+ * Failures are logged but never block the upgrade flow.
153
+ */
154
+ export async function broadcastUpgradeEvent(
155
+ gatewayUrl: string,
156
+ assistantId: string,
157
+ event: Record<string, unknown>,
158
+ ): Promise<void> {
159
+ try {
160
+ const token = loadGuardianToken(assistantId);
161
+ const headers: Record<string, string> = {
162
+ "Content-Type": "application/json",
163
+ };
164
+ if (token?.accessToken) {
165
+ headers["Authorization"] = `Bearer ${token.accessToken}`;
166
+ }
167
+ await fetch(`${gatewayUrl}/v1/admin/upgrade-broadcast`, {
168
+ method: "POST",
169
+ headers,
170
+ body: JSON.stringify(event),
171
+ signal: AbortSignal.timeout(3000),
172
+ });
173
+ } catch {
174
+ // Best-effort — gateway/daemon may already be shutting down or not yet ready
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Roll back DB and workspace migrations to a target state via the gateway.
180
+ * Best-effort — failures are logged but never block the rollback flow.
181
+ */
182
+ export async function rollbackMigrations(
183
+ gatewayUrl: string,
184
+ assistantId: string,
185
+ targetDbVersion?: number,
186
+ targetWorkspaceMigrationId?: string,
187
+ rollbackToRegistryCeiling?: boolean,
188
+ ): Promise<boolean> {
189
+ if (
190
+ !rollbackToRegistryCeiling &&
191
+ targetDbVersion === undefined &&
192
+ targetWorkspaceMigrationId === undefined
193
+ ) {
194
+ return false;
195
+ }
196
+ try {
197
+ const token = loadGuardianToken(assistantId);
198
+ const headers: Record<string, string> = {
199
+ "Content-Type": "application/json",
200
+ };
201
+ if (token?.accessToken) {
202
+ headers["Authorization"] = `Bearer ${token.accessToken}`;
203
+ }
204
+ const body: Record<string, unknown> = {};
205
+ if (targetDbVersion !== undefined) body.targetDbVersion = targetDbVersion;
206
+ if (targetWorkspaceMigrationId !== undefined)
207
+ body.targetWorkspaceMigrationId = targetWorkspaceMigrationId;
208
+ if (rollbackToRegistryCeiling) body.rollbackToRegistryCeiling = true;
209
+
210
+ const resp = await fetch(`${gatewayUrl}/v1/admin/rollback-migrations`, {
211
+ method: "POST",
212
+ headers,
213
+ body: JSON.stringify(body),
214
+ signal: AbortSignal.timeout(120_000),
215
+ });
216
+ if (!resp.ok) {
217
+ const text = await resp.text();
218
+ console.warn(`⚠️ Migration rollback failed (${resp.status}): ${text}`);
219
+ return false;
220
+ }
221
+ const result = (await resp.json()) as {
222
+ rolledBack?: { db?: string[]; workspace?: string[] };
223
+ };
224
+ const dbCount = result.rolledBack?.db?.length ?? 0;
225
+ const wsCount = result.rolledBack?.workspace?.length ?? 0;
226
+ if (dbCount > 0 || wsCount > 0) {
227
+ console.log(
228
+ ` Rolled back ${dbCount} DB migration(s) and ${wsCount} workspace migration(s)`,
229
+ );
230
+ }
231
+ return true;
232
+ } catch (err) {
233
+ const msg = err instanceof Error ? err.message : String(err);
234
+ console.warn(`⚠️ Migration rollback failed: ${msg}`);
235
+ return false;
236
+ }
237
+ }
@@ -0,0 +1,39 @@
1
+ import { exec } from "./step-runner";
2
+
3
+ /**
4
+ * Best-effort git commit in a workspace directory.
5
+ *
6
+ * Stages all changes and creates an `--allow-empty` commit so the
7
+ * history records every upgrade/rollback even when no files changed.
8
+ *
9
+ * Safety measures (mirroring WorkspaceGitService in the assistant package):
10
+ * - Deterministic committer identity (`vellum-cli`)
11
+ * - Hooks disabled (`core.hooksPath=/dev/null`, `--no-verify`)
12
+ *
13
+ * Callers should wrap this in try/catch — failures must never block
14
+ * the upgrade or rollback flow.
15
+ */
16
+ export async function commitWorkspaceState(
17
+ workspaceDir: string,
18
+ message: string,
19
+ ): Promise<void> {
20
+ const opts = { cwd: workspaceDir };
21
+ await exec("git", ["add", "-A"], opts);
22
+ await exec(
23
+ "git",
24
+ [
25
+ "-c",
26
+ `user.name=${process.env.CLI_GIT_USER_NAME || "vellum-cli"}`,
27
+ "-c",
28
+ `user.email=${process.env.CLI_GIT_USER_EMAIL || "cli@vellum.ai"}`,
29
+ "-c",
30
+ "core.hooksPath=/dev/null",
31
+ "commit",
32
+ "--no-verify",
33
+ "--allow-empty",
34
+ "-m",
35
+ message,
36
+ ],
37
+ opts,
38
+ );
39
+ }