@vellumai/cli 0.7.1 → 0.7.2

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.
@@ -12,6 +12,10 @@ import {
12
12
  } from "../lib/constants";
13
13
  import { loadGuardianToken } from "../lib/guardian-token";
14
14
  import { getLocalLanIPv4 } from "../lib/local";
15
+ import {
16
+ fetchOrganizationId,
17
+ readPlatformToken,
18
+ } from "../lib/platform-client";
15
19
  import { tuiLog } from "../lib/tui-log";
16
20
 
17
21
  const ANSI = {
@@ -26,6 +30,11 @@ interface ParsedArgs {
26
30
  runtimeUrl: string;
27
31
  assistantId: string;
28
32
  species: Species;
33
+ /** "vellum" for platform-hosted assistants, undefined for local. */
34
+ cloud?: string;
35
+ /** Platform session token (X-Session-Token), set when cloud === "vellum". */
36
+ platformToken?: string;
37
+ /** Guardian JWT (Authorization: Bearer), set for local assistants. */
29
38
  bearerToken?: string;
30
39
  project?: string;
31
40
  zone?: string;
@@ -89,10 +98,17 @@ function parseArgs(): ParsedArgs {
89
98
 
90
99
  let runtimeUrl = entry?.localUrl || entry?.runtimeUrl || FALLBACK_RUNTIME_URL;
91
100
  let assistantId = entry?.assistantId || DAEMON_INTERNAL_ASSISTANT_ID;
92
- const bearerToken =
93
- loadGuardianToken(entry?.assistantId ?? "")?.accessToken ?? undefined;
101
+ const cloud = entry?.cloud;
94
102
  const species: Species = (entry?.species as Species) ?? "vellum";
95
103
 
104
+ // Platform-hosted assistants use a session token; local assistants use a guardian JWT.
105
+ const platformToken =
106
+ cloud === "vellum" ? (readPlatformToken() ?? undefined) : undefined;
107
+ const bearerToken =
108
+ cloud === "vellum"
109
+ ? undefined
110
+ : (loadGuardianToken(entry?.assistantId ?? "")?.accessToken ?? undefined);
111
+
96
112
  for (let i = 0; i < flagArgs.length; i++) {
97
113
  const flag = flagArgs[i];
98
114
  if ((flag === "--url" || flag === "-u") && flagArgs[i + 1]) {
@@ -109,6 +125,8 @@ function parseArgs(): ParsedArgs {
109
125
  runtimeUrl: maybeSwapToLocalhost(runtimeUrl.replace(/\/+$/, "")),
110
126
  assistantId,
111
127
  species,
128
+ cloud,
129
+ platformToken,
112
130
  bearerToken,
113
131
  project: entry?.project,
114
132
  zone: entry?.zone,
@@ -181,11 +199,34 @@ ${ANSI.bold}EXAMPLES:${ANSI.reset}
181
199
  }
182
200
 
183
201
  export async function client(): Promise<void> {
184
- const { runtimeUrl, assistantId, species, bearerToken, project, zone } =
185
- parseArgs();
202
+ const {
203
+ runtimeUrl,
204
+ assistantId,
205
+ species,
206
+ cloud,
207
+ platformToken,
208
+ bearerToken,
209
+ project,
210
+ zone,
211
+ } = parseArgs();
186
212
 
187
213
  tuiLog.init();
188
- tuiLog.info("session start", { runtimeUrl, assistantId, species });
214
+ tuiLog.info("session start", { runtimeUrl, assistantId, species, cloud });
215
+
216
+ // Build pre-constructed auth headers so all fetch sites share a single object.
217
+ let auth: Record<string, string> | undefined;
218
+ if (cloud === "vellum" && platformToken) {
219
+ const orgId = await fetchOrganizationId(platformToken).catch((err) => {
220
+ tuiLog.warn("failed to fetch organization id", { err: String(err) });
221
+ return undefined;
222
+ });
223
+ auth = {
224
+ "X-Session-Token": platformToken,
225
+ ...(orgId ? { "Vellum-Organization-Id": orgId } : {}),
226
+ };
227
+ } else if (bearerToken) {
228
+ auth = { Authorization: `Bearer ${bearerToken}` };
229
+ }
189
230
 
190
231
  const { renderChatApp } = await import("../components/DefaultMainScreen");
191
232
 
@@ -203,6 +244,6 @@ export async function client(): Promise<void> {
203
244
  console.log(`${ANSI.dim}Disconnected.${ANSI.reset}`);
204
245
  process.exit(0);
205
246
  },
206
- { bearerToken, project, zone },
247
+ { auth, project, zone },
207
248
  );
208
249
  }
@@ -139,6 +139,9 @@ export async function events(): Promise<void> {
139
139
  query,
140
140
  headers: getClientRegistrationHeaders(),
141
141
  })) {
142
+ if (!event.message) {
143
+ continue;
144
+ }
142
145
  if (jsonOutput) {
143
146
  console.log(JSON.stringify(event));
144
147
  } else {
@@ -156,10 +156,19 @@ export async function exec(): Promise<void> {
156
156
  const cloud = resolveCloud(entry);
157
157
 
158
158
  if (cloud === "local") {
159
- console.error(
160
- "Cannot exec into a local assistant — it runs directly on this machine.",
161
- );
162
- process.exit(1);
159
+ const child = spawn(command[0], command.slice(1), { stdio: "inherit" });
160
+ await new Promise<void>((resolve) => {
161
+ child.on("close", (code) => {
162
+ process.exitCode = code ?? 0;
163
+ resolve();
164
+ });
165
+ child.on("error", (err) => {
166
+ console.error(`Error: ${err.message}`);
167
+ process.exitCode = 1;
168
+ resolve();
169
+ });
170
+ });
171
+ return;
163
172
  }
164
173
 
165
174
  if (cloud === "apple-container") {
@@ -32,7 +32,7 @@ export type { PollResult, WatchHatchingResult } from "../lib/gcp";
32
32
  const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";
33
33
 
34
34
  const HATCH_TIMEOUT_MS: Record<Species, number> = {
35
- vellum: 2 * 60 * 1000,
35
+ vellum: 5 * 60 * 1000,
36
36
  openclaw: 10 * 60 * 1000,
37
37
  };
38
38
  const DEFAULT_SPECIES: Species = "vellum";
@@ -179,7 +179,13 @@ async function restorePlatform(
179
179
  process.exit(1);
180
180
  }
181
181
 
182
- // Step 1.5 — Upload to GCS via signed URL
182
+ // Step 1.5 — Upload to GCS via signed URL.
183
+ // We deliberately omit min/max runtime version here: restore uploads an
184
+ // arbitrary .vbundle from disk (often produced by a different runtime
185
+ // than the one we'd query right now), and the bundle's own manifest is
186
+ // the authority on its compatibility band. The platform skips the
187
+ // version gate when these fields are absent and re-derives compat from
188
+ // the manifest when it processes the import.
183
189
  const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
184
190
  { operation: "upload" },
185
191
  token,
@@ -1,43 +1,6 @@
1
1
  import { createInterface } from "readline";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
- import { homedir } from "os";
4
- import { dirname, join } from "path";
5
2
 
6
- function getVellumDir(): string {
7
- const base = process.env.BASE_DATA_DIR?.trim() || homedir();
8
- return join(base, ".vellum");
9
- }
10
-
11
- function getEnvFilePath(): string {
12
- return join(getVellumDir(), ".env");
13
- }
14
-
15
- function readEnvFile(): Record<string, string> {
16
- const envPath = getEnvFilePath();
17
- const vars: Record<string, string> = {};
18
- if (!existsSync(envPath)) return vars;
19
-
20
- const content = readFileSync(envPath, "utf-8");
21
- for (const line of content.split("\n")) {
22
- const trimmed = line.trim();
23
- if (!trimmed || trimmed.startsWith("#")) continue;
24
- const eqIdx = trimmed.indexOf("=");
25
- if (eqIdx === -1) continue;
26
- const key = trimmed.slice(0, eqIdx).trim();
27
- const value = trimmed.slice(eqIdx + 1).trim();
28
- vars[key] = value;
29
- }
30
- return vars;
31
- }
32
-
33
- function writeEnvFile(vars: Record<string, string>): void {
34
- const envPath = getEnvFilePath();
35
- const dir = dirname(envPath);
36
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
37
-
38
- const lines = Object.entries(vars).map(([k, v]) => `${k}=${v}`);
39
- writeFileSync(envPath, lines.join("\n") + "\n", { mode: 0o600 });
40
- }
3
+ import { resolveAssistant } from "../lib/assistant-config.js";
41
4
 
42
5
  async function promptMasked(prompt: string): Promise<string> {
43
6
  return new Promise((resolve) => {
@@ -46,7 +9,6 @@ async function promptMasked(prompt: string): Promise<string> {
46
9
  output: process.stdout,
47
10
  });
48
11
 
49
- // Disable echoing by writing the prompt manually and intercepting keystrokes
50
12
  process.stdout.write(prompt);
51
13
 
52
14
  const stdin = process.stdin;
@@ -60,7 +22,6 @@ async function promptMasked(prompt: string): Promise<string> {
60
22
  const char = key.toString("utf-8");
61
23
 
62
24
  if (char === "\r" || char === "\n") {
63
- // Enter pressed
64
25
  stdin.removeListener("data", onData);
65
26
  if (stdin.isTTY) {
66
27
  stdin.setRawMode(wasRaw ?? false);
@@ -69,11 +30,9 @@ async function promptMasked(prompt: string): Promise<string> {
69
30
  rl.close();
70
31
  resolve(input);
71
32
  } else if (char === "\u0003") {
72
- // Ctrl+C
73
33
  process.stdout.write("\n");
74
34
  process.exit(1);
75
35
  } else if (char === "\u007F" || char === "\b") {
76
- // Backspace
77
36
  if (input.length > 0) {
78
37
  input = input.slice(0, -1);
79
38
  process.stdout.write("\b \b");
@@ -111,39 +70,23 @@ export async function setup(): Promise<void> {
111
70
  console.log("");
112
71
  console.log("Interactive wizard to configure API keys.");
113
72
  console.log(
114
- "Keys are validated against their APIs and saved to <BASE_DATA_DIR>/.vellum/.env.",
73
+ "Injects secrets into your running assistant via the gateway API.",
115
74
  );
116
75
  process.exit(0);
117
76
  }
118
77
 
119
- console.log("Vellum Setup");
120
- console.log("============\n");
121
-
122
- const existingVars = readEnvFile();
123
- const hasExistingKey = !!existingVars.ANTHROPIC_API_KEY;
78
+ const entry = resolveAssistant();
79
+ if (!entry) {
80
+ console.error(
81
+ "Error: No active assistant found. Run `vellum hatch` first.",
82
+ );
83
+ process.exit(1);
84
+ }
124
85
 
125
- if (hasExistingKey) {
126
- const masked =
127
- existingVars.ANTHROPIC_API_KEY.slice(0, 7) +
128
- "..." +
129
- existingVars.ANTHROPIC_API_KEY.slice(-4);
130
- console.log(`Anthropic API key is already configured (${masked}).`);
86
+ const gatewayUrl = entry.localUrl ?? entry.runtimeUrl;
131
87
 
132
- const rl = createInterface({
133
- input: process.stdin,
134
- output: process.stdout,
135
- });
136
- const answer = await new Promise<string>((resolve) => {
137
- rl.question("Overwrite? [y/N] ", resolve);
138
- });
139
- rl.close();
140
-
141
- if (answer.trim().toLowerCase() !== "y") {
142
- console.log("\nSetup complete. No changes made.");
143
- return;
144
- }
145
- console.log("");
146
- }
88
+ console.log("Vellum Setup");
89
+ console.log("============\n");
147
90
 
148
91
  const apiKey = await promptMasked(
149
92
  "Enter your Anthropic API key (sk-ant-...): ",
@@ -164,9 +107,31 @@ export async function setup(): Promise<void> {
164
107
  process.exit(1);
165
108
  }
166
109
 
167
- existingVars.ANTHROPIC_API_KEY = apiKey.trim();
168
- writeEnvFile(existingVars);
110
+ const headers: Record<string, string> = {
111
+ "Content-Type": "application/json",
112
+ Accept: "application/json",
113
+ };
114
+ if (entry.bearerToken) {
115
+ headers["Authorization"] = `Bearer ${entry.bearerToken}`;
116
+ }
117
+
118
+ const response = await fetch(`${gatewayUrl}/v1/secrets`, {
119
+ method: "POST",
120
+ headers,
121
+ body: JSON.stringify({
122
+ type: "credential",
123
+ name: "ANTHROPIC_API_KEY",
124
+ value: apiKey.trim(),
125
+ }),
126
+ signal: AbortSignal.timeout(10_000),
127
+ });
128
+
129
+ if (!response.ok) {
130
+ console.error(
131
+ `Error: Failed to store API key in assistant (${response.status}).`,
132
+ );
133
+ process.exit(1);
134
+ }
169
135
 
170
- console.log(`\nAPI key saved to ${getEnvFilePath()}`);
171
- console.log("Setup complete.");
136
+ console.log("\nAPI key saved to assistant. Setup complete.");
172
137
  }
@@ -21,6 +21,7 @@ import {
21
21
  platformImportBundleFromGcs,
22
22
  platformImportPreflightFromGcs,
23
23
  platformRequestSignedUrl,
24
+ VersionMismatchError,
24
25
  ensureSelfHostedLocalRegistration,
25
26
  readGatewayCredential,
26
27
  reprovisionAssistantApiKey,
@@ -30,6 +31,7 @@ import {
30
31
  } from "../lib/platform-client.js";
31
32
  import {
32
33
  localRuntimeExportToGcs,
34
+ localRuntimeIdentity,
33
35
  localRuntimeImportFromGcs,
34
36
  localRuntimePollJobStatus,
35
37
  MigrationInProgressError,
@@ -254,13 +256,17 @@ async function getAccessToken(
254
256
 
255
257
  /**
256
258
  * Detect a 401 Unauthorized raised by `localRuntimeExportToGcs` /
257
- * `localRuntimeImportFromGcs`. Both throw Error with a message of the form
258
- * `"Local runtime <op> failed (401): ..."` when the gateway rejects the
259
- * cached guardian token.
259
+ * `localRuntimeImportFromGcs` / `localRuntimeIdentity`. They throw Error
260
+ * with a message of the form `"Local runtime <op> failed (401): ..."` or
261
+ * `"Failed to fetch runtime identity: 401 ..."` when the gateway rejects
262
+ * the cached guardian token.
260
263
  */
261
264
  function isRuntime401(err: unknown): boolean {
262
265
  const msg = err instanceof Error ? err.message : String(err);
263
- return /Local runtime [^(]*failed \(401\)/.test(msg);
266
+ return (
267
+ /Local runtime [^(]*failed \(401\)/.test(msg) ||
268
+ /Failed to fetch runtime identity: 401\b/.test(msg)
269
+ );
264
270
  }
265
271
 
266
272
  /**
@@ -367,13 +373,40 @@ async function exportFromAssistant(
367
373
  }
368
374
 
369
375
  if (cloud === "local" || cloud === "docker") {
376
+ // Ask the source runtime which version it's running before requesting
377
+ // the signed upload URL. The bundle is produced by the daemon (not the
378
+ // CLI), so the daemon's version is what defines the bundle's
379
+ // `min_runtime_version`. Stamping with `cliPkg.version` instead would
380
+ // record an inaccurate compatibility band whenever the CLI/daemon have
381
+ // drifted (a normal case in real usage — `vellum upgrade` swaps the
382
+ // daemon, the CLI is updated separately).
383
+ let sourceRuntimeVersion: string;
384
+ try {
385
+ const identity = await callRuntimeWithAuthRetry(
386
+ entry.runtimeUrl,
387
+ entry.assistantId,
388
+ async (token) => localRuntimeIdentity(entry, token),
389
+ );
390
+ sourceRuntimeVersion = identity.version;
391
+ } catch (err) {
392
+ const msg = err instanceof Error ? err.message : String(err);
393
+ console.error(
394
+ `Error: Could not fetch runtime identity from '${entry.assistantId}': ${msg}`,
395
+ );
396
+ process.exit(1);
397
+ }
398
+
370
399
  // Request a signed upload URL from the platform instance that will
371
400
  // eventually own the bundle (i.e. the one the importer will read from).
372
401
  // Passing the target's runtime URL here keeps upload and download on
373
402
  // the same platform — otherwise a non-default/stale platform URL would
374
403
  // cause the import to look at an empty object.
375
404
  const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
376
- { operation: "upload" },
405
+ {
406
+ operation: "upload",
407
+ minRuntimeVersion: sourceRuntimeVersion,
408
+ maxRuntimeVersion: null,
409
+ },
377
410
  platformToken,
378
411
  bundlePlatformUrl,
379
412
  );
@@ -440,6 +473,24 @@ async function exportFromAssistant(
440
473
  }
441
474
 
442
475
  if (cloud === "vellum") {
476
+ // Ask the managed runtime which version it's running so the signed-URL
477
+ // request records the bundle's actual `min_runtime_version`. The
478
+ // platform-managed runtime is the exporter; the CLI version is
479
+ // unrelated. Routed via the wildcard proxy with platform-token auth
480
+ // (resolveRuntimeUrl + migrationRequestHeaders inside
481
+ // localRuntimeIdentity).
482
+ let sourceRuntimeVersion: string;
483
+ try {
484
+ const identity = await localRuntimeIdentity(entry, platformToken);
485
+ sourceRuntimeVersion = identity.version;
486
+ } catch (err) {
487
+ const msg = err instanceof Error ? err.message : String(err);
488
+ console.error(
489
+ `Error: Could not fetch runtime identity from '${entry.assistantId}': ${msg}`,
490
+ );
491
+ process.exit(1);
492
+ }
493
+
443
494
  // Platform source — request a signed upload URL on the same platform
444
495
  // instance the bundle will eventually be imported from, then ask the
445
496
  // managed runtime to export directly to GCS. The runtime endpoint is
@@ -449,7 +500,11 @@ async function exportFromAssistant(
449
500
  // pick that shape for `cloud === "vellum"` and `migrationRequestHeaders`
450
501
  // to send platform-token auth (no guardian-token bootstrap).
451
502
  const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
452
- { operation: "upload" },
503
+ {
504
+ operation: "upload",
505
+ minRuntimeVersion: sourceRuntimeVersion,
506
+ maxRuntimeVersion: null,
507
+ },
453
508
  platformToken,
454
509
  bundlePlatformUrl,
455
510
  );
@@ -659,11 +714,56 @@ async function importToAssistant(
659
714
  // never touches the bytes. The URL must target the same platform the
660
715
  // bundle was uploaded to; otherwise the object won't exist on this
661
716
  // platform's GCS bucket.
662
- const { url: bundleUrl } = await platformRequestSignedUrl(
663
- { operation: "download", bundleKey },
664
- platformToken,
665
- bundlePlatformUrl,
666
- );
717
+ //
718
+ // The platform's vbundle version gate compares the **target runtime's**
719
+ // version against the bundle's compatibility range. The CLI and the
720
+ // target assistant's daemon can diverge (assistants upgrade
721
+ // independently), so we MUST query the target runtime's `/v1/identity`
722
+ // for its version rather than sending `cliPkg.version`. Sending the CLI
723
+ // version here would falsely 422 a valid import (or pass a bundle the
724
+ // target can't actually load) whenever the two drift apart.
725
+ let targetRuntimeVersion: string;
726
+ try {
727
+ const identity = await callRuntimeWithAuthRetry(
728
+ entry.runtimeUrl,
729
+ entry.assistantId,
730
+ (token) => localRuntimeIdentity(entry, token),
731
+ );
732
+ targetRuntimeVersion = identity.version;
733
+ } catch (err) {
734
+ // Surface and abort — silently falling back to `cliPkg.version` would
735
+ // re-introduce the bug this code is fixing. If the runtime is
736
+ // unreachable, the import would fail downstream anyway.
737
+ const msg = err instanceof Error ? err.message : String(err);
738
+ console.error(
739
+ `Error: Could not read target runtime version from '${entry.assistantId}': ${msg}`,
740
+ );
741
+ console.error(`Try: vellum wake ${entry.assistantId}`);
742
+ process.exit(1);
743
+ }
744
+
745
+ let bundleUrl: string;
746
+ try {
747
+ const result = await platformRequestSignedUrl(
748
+ {
749
+ operation: "download",
750
+ bundleKey,
751
+ targetRuntimeVersion,
752
+ },
753
+ platformToken,
754
+ bundlePlatformUrl,
755
+ );
756
+ bundleUrl = result.url;
757
+ } catch (err) {
758
+ if (err instanceof VersionMismatchError) {
759
+ // 422 version_mismatch is terminal — the bundle's runtime range and
760
+ // the target runtime's version don't overlap. Surface the
761
+ // platform-formatted message and exit; do NOT retry.
762
+ console.error(`Error: ${err.message}`);
763
+ process.exit(1);
764
+ }
765
+ throw err;
766
+ }
667
767
 
668
768
  console.log("Importing data...");
669
769
 
@@ -40,6 +40,7 @@ import {
40
40
  buildStartingEvent,
41
41
  buildUpgradeCommitMessage,
42
42
  captureContainerEnv,
43
+ captureUpgradeFailureLogs,
43
44
  commitWorkspaceViaGateway,
44
45
  CONTAINER_ENV_EXCLUDE_KEYS,
45
46
  rollbackMigrations,
@@ -511,6 +512,11 @@ async function upgradeDocker(
511
512
  } else {
512
513
  console.error(`\n❌ Containers failed to become ready within the timeout.`);
513
514
 
515
+ const logDir = await captureUpgradeFailureLogs(res, `${instanceName}-upgrade-failure`);
516
+ if (logDir) {
517
+ console.log(`📋 Container logs saved to: ${logDir}`);
518
+ }
519
+
514
520
  if (previousImageRefs) {
515
521
  await broadcastUpgradeEvent(
516
522
  entry.runtimeUrl,
@@ -195,22 +195,11 @@ export async function wake(): Promise<void> {
195
195
  }
196
196
 
197
197
  // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
198
- // Scope BASE_DATA_DIR to the woken instance so ngrok reads the correct
199
- // instance config, then restore on any exit path.
200
- const prevBaseDataDir = process.env.BASE_DATA_DIR;
201
- process.env.BASE_DATA_DIR = resources.instanceDir;
202
- try {
203
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
204
- if (ngrokChild?.pid) {
205
- const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
206
- writeFileSync(ngrokPidFile, String(ngrokChild.pid));
207
- }
208
- } finally {
209
- if (prevBaseDataDir !== undefined) {
210
- process.env.BASE_DATA_DIR = prevBaseDataDir;
211
- } else {
212
- delete process.env.BASE_DATA_DIR;
213
- }
198
+ const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
199
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort, workspaceDir);
200
+ if (ngrokChild?.pid) {
201
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
202
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
214
203
  }
215
204
 
216
205
  console.log("Wake complete.");