@vellumai/cli 0.7.1 → 0.7.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.
Files changed (39) hide show
  1. package/AGENTS.md +3 -11
  2. package/bun.lock +0 -15
  3. package/package.json +1 -6
  4. package/src/__tests__/backup.test.ts +121 -5
  5. package/src/__tests__/teleport.test.ts +515 -10
  6. package/src/commands/backup.ts +35 -2
  7. package/src/commands/client.ts +90 -7
  8. package/src/commands/exec.ts +13 -4
  9. package/src/commands/hatch.ts +1 -1
  10. package/src/commands/login.ts +11 -0
  11. package/src/commands/restore.ts +7 -1
  12. package/src/commands/rollback.ts +1 -1
  13. package/src/commands/setup.ts +38 -73
  14. package/src/commands/teleport.ts +122 -12
  15. package/src/commands/upgrade.ts +8 -2
  16. package/src/commands/wake.ts +5 -16
  17. package/src/components/DefaultMainScreen.tsx +42 -130
  18. package/src/index.ts +1 -7
  19. package/src/lib/__tests__/docker.test.ts +53 -35
  20. package/src/lib/__tests__/local-runtime-client.test.ts +186 -0
  21. package/src/lib/__tests__/platform-client-signed-url.test.ts +235 -0
  22. package/src/lib/__tests__/runtime-url.test.ts +39 -1
  23. package/src/lib/assistant-client.ts +13 -5
  24. package/src/lib/assistant-config.ts +0 -25
  25. package/src/lib/backup-ops.ts +43 -17
  26. package/src/lib/client-identity.ts +9 -5
  27. package/src/lib/docker.ts +6 -267
  28. package/src/lib/environments/paths.ts +20 -0
  29. package/src/lib/guardian-token.ts +56 -6
  30. package/src/lib/hatch-local.ts +3 -26
  31. package/src/lib/local-runtime-client.ts +82 -1
  32. package/src/lib/local.ts +9 -7
  33. package/src/lib/ngrok.ts +36 -26
  34. package/src/lib/platform-client.ts +100 -1
  35. package/src/lib/retire-local.ts +2 -2
  36. package/src/lib/runtime-url.ts +22 -0
  37. package/src/lib/statefulset.ts +375 -0
  38. package/src/lib/upgrade-lifecycle.ts +97 -1
  39. package/src/commands/pair.ts +0 -212
package/src/lib/local.ts CHANGED
@@ -111,7 +111,9 @@ function computeIpcSocketDirOverride(workspaceDir: string): string | undefined {
111
111
  * a short override directory and set all IPC socket env vars on the target
112
112
  * env object. No-op on non-macOS or when paths are within limits.
113
113
  */
114
- function applyIpcSocketDirOverride(env: Record<string, string>): void {
114
+ function applyIpcSocketDirOverride(
115
+ env: Record<string, string | undefined>,
116
+ ): void {
115
117
  const workspaceDir =
116
118
  env.VELLUM_WORKSPACE_DIR || join(homedir(), ".vellum", "workspace");
117
119
  const override = computeIpcSocketDirOverride(workspaceDir);
@@ -417,6 +419,8 @@ async function startDaemonFromSource(
417
419
  options.defaultWorkspaceConfigPath;
418
420
  }
419
421
 
422
+ applyIpcSocketDirOverride(env);
423
+
420
424
  // Write a sentinel PID file before spawning so concurrent hatch() calls
421
425
  // detect the in-progress spawn and wait instead of racing.
422
426
  writeFileSync(pidFile, "starting", "utf-8");
@@ -552,6 +556,8 @@ async function startDaemonWatchFromSource(
552
556
  options.defaultWorkspaceConfigPath;
553
557
  }
554
558
 
559
+ applyIpcSocketDirOverride(env);
560
+
555
561
  // Write a sentinel PID file before spawning so concurrent hatch() calls
556
562
  // detect the in-progress spawn and wait instead of racing.
557
563
  writeFileSync(pidFile, "starting", "utf-8");
@@ -1183,10 +1189,6 @@ export async function startGateway(
1183
1189
 
1184
1190
  applyIpcSocketDirOverride(gatewayEnv);
1185
1191
 
1186
- if (publicUrl) {
1187
- console.log(` HTTP URL: ${publicUrl}`);
1188
- }
1189
-
1190
1192
  let gateway;
1191
1193
 
1192
1194
  const gatewayBinary = join(dirname(process.execPath), "vellum-gateway");
@@ -1232,8 +1234,8 @@ export async function startGateway(
1232
1234
  const gatewayUrl = publicUrl || `http://localhost:${effectiveGatewayPort}`;
1233
1235
 
1234
1236
  // Wait for the gateway to be responsive before returning. Without this,
1235
- // callers (e.g. displayPairingQRCode) may try to connect before the HTTP
1236
- // server is listening and get connection-refused errors.
1237
+ // callers may try to connect before the HTTP server is listening and get
1238
+ // connection-refused errors.
1237
1239
  const start = Date.now();
1238
1240
  const timeoutMs = 30000;
1239
1241
  let ready = false;
package/src/lib/ngrok.ts CHANGED
@@ -12,13 +12,19 @@ import { dirname, join } from "node:path";
12
12
 
13
13
  import { GATEWAY_PORT } from "./constants";
14
14
 
15
- function getConfigPath(): string {
16
- const root = join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
17
- return join(root, "workspace", "config.json");
15
+ function getDefaultWorkspaceDir(): string {
16
+ return (
17
+ process.env.VELLUM_WORKSPACE_DIR?.trim() ||
18
+ join(homedir(), ".vellum", "workspace")
19
+ );
20
+ }
21
+
22
+ function getConfigPath(workspaceDir: string): string {
23
+ return join(workspaceDir, "config.json");
18
24
  }
19
25
 
20
- function loadRawConfig(): Record<string, unknown> {
21
- const configPath = getConfigPath();
26
+ function loadRawConfig(workspaceDir: string): Record<string, unknown> {
27
+ const configPath = getConfigPath(workspaceDir);
22
28
  if (!existsSync(configPath)) return {};
23
29
  return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
24
30
  string,
@@ -26,8 +32,11 @@ function loadRawConfig(): Record<string, unknown> {
26
32
  >;
27
33
  }
28
34
 
29
- function saveRawConfig(config: Record<string, unknown>): void {
30
- const configPath = getConfigPath();
35
+ function saveRawConfig(
36
+ workspaceDir: string,
37
+ config: Record<string, unknown>,
38
+ ): void {
39
+ const configPath = getConfigPath(workspaceDir);
31
40
  const dir = dirname(configPath);
32
41
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
33
42
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
@@ -182,33 +191,33 @@ export async function waitForNgrokUrl(
182
191
  /**
183
192
  * Persist a public ingress URL to the workspace config and enable ingress.
184
193
  */
185
- function saveIngressUrl(publicUrl: string): void {
186
- const config = loadRawConfig();
194
+ function saveIngressUrl(workspaceDir: string, publicUrl: string): void {
195
+ const config = loadRawConfig(workspaceDir);
187
196
  const ingress = (config.ingress ?? {}) as Record<string, unknown>;
188
197
  ingress.publicBaseUrl = publicUrl;
189
198
  ingress.enabled = true;
190
199
  config.ingress = ingress;
191
- saveRawConfig(config);
200
+ saveRawConfig(workspaceDir, config);
192
201
  }
193
202
 
194
203
  /**
195
204
  * Clear the ingress public base URL from the workspace config.
196
205
  */
197
- function clearIngressUrl(): void {
198
- const config = loadRawConfig();
206
+ function clearIngressUrl(workspaceDir: string): void {
207
+ const config = loadRawConfig(workspaceDir);
199
208
  const ingress = (config.ingress ?? {}) as Record<string, unknown>;
200
209
  delete ingress.publicBaseUrl;
201
210
  config.ingress = ingress;
202
- saveRawConfig(config);
211
+ saveRawConfig(workspaceDir, config);
203
212
  }
204
213
 
205
214
  /**
206
215
  * Check whether any webhook-based integrations (e.g. Telegram, Twilio) are
207
216
  * configured that require a public ingress URL.
208
217
  */
209
- function hasWebhookIntegrationsConfigured(): boolean {
218
+ function hasWebhookIntegrationsConfigured(workspaceDir: string): boolean {
210
219
  try {
211
- const config = loadRawConfig();
220
+ const config = loadRawConfig(workspaceDir);
212
221
  const telegram = config.telegram as Record<string, unknown> | undefined;
213
222
  if (telegram?.botUsername) return true;
214
223
  const twilio = config.twilio as Record<string, unknown> | undefined;
@@ -223,9 +232,9 @@ function hasWebhookIntegrationsConfigured(): boolean {
223
232
  * Check whether a non-ngrok ingress URL is already configured (e.g. custom
224
233
  * domain or cloud deployment), meaning ngrok is not needed.
225
234
  */
226
- function hasNonNgrokIngressUrl(): boolean {
235
+ function hasNonNgrokIngressUrl(workspaceDir: string): boolean {
227
236
  try {
228
- const config = loadRawConfig();
237
+ const config = loadRawConfig(workspaceDir);
229
238
  const ingress = config.ingress as Record<string, unknown> | undefined;
230
239
  const publicBaseUrl = ingress?.publicBaseUrl;
231
240
  if (!publicBaseUrl || typeof publicBaseUrl !== "string") return false;
@@ -244,6 +253,7 @@ function hasNonNgrokIngressUrl(): boolean {
244
253
  */
245
254
  export async function maybeStartNgrokTunnel(
246
255
  targetPort: number,
256
+ workspaceDir: string,
247
257
  ): Promise<ChildProcess | null> {
248
258
  // Managed/containerized deployments route webhooks through the platform's
249
259
  // callback proxy. ngrok is not needed and would not be reachable from the
@@ -252,8 +262,8 @@ export async function maybeStartNgrokTunnel(
252
262
  process.env.IS_CONTAINERIZED === "true" ||
253
263
  process.env.IS_CONTAINERIZED === "1";
254
264
  if (isContainerized) return null;
255
- if (!hasWebhookIntegrationsConfigured()) return null;
256
- if (hasNonNgrokIngressUrl()) return null;
265
+ if (!hasWebhookIntegrationsConfigured(workspaceDir)) return null;
266
+ if (hasNonNgrokIngressUrl(workspaceDir)) return null;
257
267
 
258
268
  const version = getNgrokVersion();
259
269
  if (!version) return null;
@@ -262,7 +272,7 @@ export async function maybeStartNgrokTunnel(
262
272
  const existingUrl = await findExistingTunnel(targetPort);
263
273
  if (existingUrl) {
264
274
  console.log(` Found existing ngrok tunnel: ${existingUrl}`);
265
- saveIngressUrl(existingUrl);
275
+ saveIngressUrl(workspaceDir, existingUrl);
266
276
  return null;
267
277
  }
268
278
 
@@ -274,14 +284,13 @@ export async function maybeStartNgrokTunnel(
274
284
  // 2. If pipe handles are destroyed, SIGPIPE kills ngrok on its next write.
275
285
  // Writing to a log file sidesteps both issues — the file descriptor is
276
286
  // inherited by the detached ngrok process and remains valid after CLI exit.
277
- const root = join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
278
- const ngrokLogPath = join(root, "workspace", "data", "logs", "ngrok.log");
287
+ const ngrokLogPath = join(workspaceDir, "data", "logs", "ngrok.log");
279
288
  const ngrokProcess = startNgrokProcess(targetPort, ngrokLogPath);
280
289
  ngrokProcess.unref();
281
290
 
282
291
  try {
283
292
  const publicUrl = await waitForNgrokUrl();
284
- saveIngressUrl(publicUrl);
293
+ saveIngressUrl(workspaceDir, publicUrl);
285
294
  console.log(` Tunnel established: ${publicUrl}`);
286
295
 
287
296
  return ngrokProcess;
@@ -317,12 +326,13 @@ export async function runNgrokTunnel(): Promise<void> {
317
326
  console.log(`Using ${version}`);
318
327
 
319
328
  const port = GATEWAY_PORT;
329
+ const workspaceDir = getDefaultWorkspaceDir();
320
330
 
321
331
  // Check for an existing ngrok tunnel pointing at the gateway
322
332
  const existingUrl = await findExistingTunnel(port);
323
333
  if (existingUrl) {
324
334
  console.log(`Found existing ngrok tunnel: ${existingUrl}`);
325
- saveIngressUrl(existingUrl);
335
+ saveIngressUrl(workspaceDir, existingUrl);
326
336
  console.log("Ingress URL saved to config.");
327
337
  console.log("");
328
338
  console.log(
@@ -349,7 +359,7 @@ export async function runNgrokTunnel(): Promise<void> {
349
359
  }
350
360
  if (publicUrl) {
351
361
  console.log("\nClearing ingress URL from config...");
352
- clearIngressUrl();
362
+ clearIngressUrl(workspaceDir);
353
363
  }
354
364
  };
355
365
 
@@ -398,7 +408,7 @@ export async function runNgrokTunnel(): Promise<void> {
398
408
  console.log(`Tunnel established: ${publicUrl}`);
399
409
  console.log(`Forwarding to: localhost:${port}`);
400
410
 
401
- saveIngressUrl(publicUrl);
411
+ saveIngressUrl(workspaceDir, publicUrl);
402
412
  console.log("Ingress URL saved to config.");
403
413
  console.log("");
404
414
  console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
@@ -99,6 +99,23 @@ function tokenAuthHeader(token: string): Record<string, string> {
99
99
  const orgIdCache = new Map<string, { orgId: string; expiresAt: number }>();
100
100
  const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds
101
101
 
102
+ /**
103
+ * Drop the cached org ID for a given (token, platformUrl) pair. Used by the
104
+ * one-shot 401-retry path: a 401 on a session-token request frequently means
105
+ * the cached `Vellum-Organization-Id` header is stale (e.g. user switched
106
+ * orgs in another tab). Clearing the entry forces the next `authHeaders`
107
+ * call to refetch the org ID from the platform.
108
+ *
109
+ * Exported so other modules (e.g. local-runtime-client) can implement the
110
+ * same retry pattern without needing direct access to the cache map.
111
+ */
112
+ export function invalidateOrgIdCache(
113
+ token: string,
114
+ platformUrl?: string,
115
+ ): void {
116
+ orgIdCache.delete(`${token}::${platformUrl ?? ""}`);
117
+ }
118
+
102
119
  /**
103
120
  * Returns the full set of headers needed for an authenticated platform
104
121
  * API request:
@@ -196,6 +213,7 @@ export async function ensureSelfHostedLocalRegistration(
196
213
  clientPlatform: string,
197
214
  assistantVersion?: string,
198
215
  platformUrl?: string,
216
+ publicBaseUrl?: string,
199
217
  ): Promise<EnsureRegistrationResponse> {
200
218
  const resolvedUrl = platformUrl || getPlatformUrl();
201
219
  const body: Record<string, string> = {
@@ -206,6 +224,9 @@ export async function ensureSelfHostedLocalRegistration(
206
224
  if (assistantVersion) {
207
225
  body.assistant_version = assistantVersion;
208
226
  }
227
+ if (publicBaseUrl) {
228
+ body.public_ingress_url = publicBaseUrl;
229
+ }
209
230
 
210
231
  const response = await fetch(
211
232
  `${resolvedUrl}/v1/assistants/self-hosted-local/ensure-registration/`,
@@ -468,6 +489,7 @@ export async function hatchAssistant(
468
489
  method: "POST",
469
490
  headers: await authHeaders(token, platformUrl),
470
491
  body: JSON.stringify({}),
492
+ signal: AbortSignal.timeout(300_000),
471
493
  });
472
494
 
473
495
  if (response.ok) {
@@ -805,6 +827,45 @@ export function parseUnifiedJobStatus(
805
827
  };
806
828
  }
807
829
 
830
+ export interface BundleCompatibility {
831
+ min_runtime_version: string;
832
+ max_runtime_version: string | null;
833
+ }
834
+
835
+ /**
836
+ * Thrown by platformRequestSignedUrl when the platform rejects a download
837
+ * signed-URL request because the target runtime version is outside the
838
+ * ExportJob's [min_runtime_version, max_runtime_version] band. Terminal
839
+ * — callers must NOT retry; surface to the user and abort the
840
+ * teleport/restore wizard.
841
+ */
842
+ export class VersionMismatchError extends Error {
843
+ readonly bundleCompat: BundleCompatibility;
844
+ readonly targetRuntimeVersion: string;
845
+
846
+ constructor(bundleCompat: BundleCompatibility, targetRuntimeVersion: string) {
847
+ super(
848
+ VersionMismatchError.formatMessage(bundleCompat, targetRuntimeVersion),
849
+ );
850
+ this.name = "VersionMismatchError";
851
+ this.bundleCompat = bundleCompat;
852
+ this.targetRuntimeVersion = targetRuntimeVersion;
853
+ }
854
+
855
+ static formatMessage(
856
+ compat: BundleCompatibility,
857
+ targetRuntimeVersion: string,
858
+ ): string {
859
+ const range = compat.max_runtime_version
860
+ ? `${compat.min_runtime_version}–${compat.max_runtime_version}`
861
+ : `${compat.min_runtime_version}+`;
862
+ return (
863
+ `Cannot import: bundle requires runtime ${range}, but this runtime is ${targetRuntimeVersion}. ` +
864
+ `Update your runtime before importing.`
865
+ );
866
+ }
867
+ }
868
+
808
869
  /**
809
870
  * Request a signed URL from the platform for either uploading a new bundle
810
871
  * or downloading an existing one. Calls `POST /v1/migrations/signed-url/`.
@@ -816,6 +877,9 @@ export function parseUnifiedJobStatus(
816
877
  *
817
878
  * Retries once with a fresh org-ID cache on 401 to match the retry pattern
818
879
  * used by other authenticated platform helpers.
880
+ *
881
+ * Throws {@link VersionMismatchError} on a 422 `version_mismatch` response,
882
+ * which is terminal — callers must NOT retry.
819
883
  */
820
884
  export async function platformRequestSignedUrl(
821
885
  params: {
@@ -823,6 +887,11 @@ export async function platformRequestSignedUrl(
823
887
  bundleKey?: string;
824
888
  contentType?: string;
825
889
  contentLength?: number;
890
+ // Source-side, upload only: runtime version that produced the bundle.
891
+ minRuntimeVersion?: string;
892
+ maxRuntimeVersion?: string | null;
893
+ // Target-side, download only: runtime version that will import.
894
+ targetRuntimeVersion?: string;
826
895
  },
827
896
  token: string,
828
897
  platformUrl?: string,
@@ -839,6 +908,17 @@ export async function platformRequestSignedUrl(
839
908
  if (params.contentLength !== undefined) {
840
909
  body.content_length = params.contentLength;
841
910
  }
911
+ if (params.minRuntimeVersion !== undefined) {
912
+ body.min_runtime_version = params.minRuntimeVersion;
913
+ }
914
+ if (params.maxRuntimeVersion !== undefined) {
915
+ // Explicit null is the documented "no upper bound" sentinel; keep it
916
+ // in the payload rather than stripping to undefined.
917
+ body.max_runtime_version = params.maxRuntimeVersion;
918
+ }
919
+ if (params.targetRuntimeVersion !== undefined) {
920
+ body.target_runtime_version = params.targetRuntimeVersion;
921
+ }
842
922
 
843
923
  const doRequest = async (): Promise<Response> =>
844
924
  fetch(`${resolvedUrl}/v1/migrations/signed-url/`, {
@@ -854,7 +934,7 @@ export async function platformRequestSignedUrl(
854
934
  // lookup. For session-token callers, a 401 frequently means the
855
935
  // cached org ID is stale — calling doRequest() again without clearing
856
936
  // the cache would just send the same stale header and fail again.
857
- orgIdCache.delete(`${token}::${platformUrl ?? ""}`);
937
+ invalidateOrgIdCache(token, platformUrl);
858
938
  response = await doRequest();
859
939
  }
860
940
 
@@ -873,9 +953,28 @@ export async function platformRequestSignedUrl(
873
953
  };
874
954
  }
875
955
 
956
+ // Non-success body. Read once and reuse for both the 422 version-mismatch
957
+ // branch and the generic-error fallthrough — `response.json()` consumes
958
+ // the body, so a second read would always return undefined.
876
959
  const errorBody = (await response.json().catch(() => ({}))) as {
877
960
  detail?: string;
961
+ reason?: string;
962
+ bundle_compat?: BundleCompatibility;
963
+ target_runtime_version?: string;
878
964
  };
965
+
966
+ if (
967
+ response.status === 422 &&
968
+ errorBody.reason === "version_mismatch" &&
969
+ errorBody.bundle_compat &&
970
+ typeof errorBody.target_runtime_version === "string"
971
+ ) {
972
+ throw new VersionMismatchError(
973
+ errorBody.bundle_compat,
974
+ errorBody.target_runtime_version,
975
+ );
976
+ }
977
+
879
978
  throw new Error(
880
979
  errorBody.detail ??
881
980
  `Failed to request signed URL: ${response.status} ${response.statusText}`,
@@ -1,9 +1,9 @@
1
1
  import { spawn } from "child_process";
2
+ import { homedir } from "os";
2
3
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
3
4
  import { basename, dirname, join } from "path";
4
5
 
5
6
  import {
6
- getBaseDir,
7
7
  getDaemonPidPath,
8
8
  loadAllAssistants,
9
9
  } from "./assistant-config.js";
@@ -77,7 +77,7 @@ export async function retireLocal(
77
77
  // For named instances (instanceDir differs from the base directory),
78
78
  // archive and remove the entire instance directory. For the default
79
79
  // instance, archive only the .vellum subdirectory.
80
- const isNamedInstance = resources.instanceDir !== getBaseDir();
80
+ const isNamedInstance = resources.instanceDir !== homedir();
81
81
  const dirToArchive = isNamedInstance ? resources.instanceDir : vellumDir;
82
82
 
83
83
  // Move the data directory out of the way so the path is immediately available
@@ -28,3 +28,25 @@ export function resolveRuntimeMigrationUrl(
28
28
  }
29
29
  return `${entry.runtimeUrl}/v1/migrations/${subpath}`;
30
30
  }
31
+
32
+ /**
33
+ * Resolve the URL for a generic runtime endpoint under `/v1/<subpath>`,
34
+ * taking the assistant's topology into account.
35
+ *
36
+ * - For local/docker assistants, `runtimeUrl` is the loopback gateway and
37
+ * the runtime serves `/v1/<subpath>` directly.
38
+ * - For platform-managed (cloud="vellum") assistants the path is rewritten
39
+ * to the wildcard runtime proxy:
40
+ * `{platformUrl}/v1/assistants/<assistantId>/<subpath>`.
41
+ *
42
+ * The `subpath` is appended verbatim (e.g. `"identity"`).
43
+ */
44
+ export function resolveRuntimeUrl(
45
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
46
+ subpath: string,
47
+ ): string {
48
+ if (entry.cloud === "vellum") {
49
+ return `${entry.runtimeUrl}/v1/assistants/${entry.assistantId}/${subpath}`;
50
+ }
51
+ return `${entry.runtimeUrl}/v1/${subpath}`;
52
+ }