@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/knip.json CHANGED
@@ -3,7 +3,9 @@
3
3
  "src/**/*.test.ts",
4
4
  "src/**/__tests__/**/*.ts",
5
5
  "src/adapters/openclaw-http-server.ts",
6
- "src/lib/version-compat.ts"
6
+ "src/lib/version-compat.ts",
7
+ "src/lib/platform-releases.ts",
8
+ "src/lib/cli-error.ts"
7
9
  ],
8
10
  "project": ["src/**/*.ts", "src/**/*.tsx"]
9
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,22 +1,10 @@
1
1
  import { mkdirSync, writeFileSync } from "fs";
2
- import { homedir } from "os";
3
2
  import { dirname, join } from "path";
4
3
 
5
4
  import { findAssistantByName } from "../lib/assistant-config";
5
+ import { getBackupsDir, formatSize } from "../lib/backup-ops.js";
6
6
  import { loadGuardianToken, leaseGuardianToken } from "../lib/guardian-token";
7
7
 
8
- function getBackupsDir(): string {
9
- const dataHome =
10
- process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
11
- return join(dataHome, "vellum", "backups");
12
- }
13
-
14
- function formatSize(bytes: number): string {
15
- if (bytes < 1024) return `${bytes} B`;
16
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
17
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
18
- }
19
-
20
8
  export async function backup(): Promise<void> {
21
9
  const args = process.argv.slice(3);
22
10
 
@@ -104,6 +92,33 @@ export async function backup(): Promise<void> {
104
92
  body: JSON.stringify({ description: "CLI backup" }),
105
93
  signal: AbortSignal.timeout(120_000),
106
94
  });
95
+
96
+ // Retry once with a fresh token on 401 — the cached token may be stale
97
+ // after a container restart that generated a new gateway signing key.
98
+ if (response.status === 401) {
99
+ let refreshedToken: string | null = null;
100
+ try {
101
+ const freshToken = await leaseGuardianToken(
102
+ entry.runtimeUrl,
103
+ entry.assistantId,
104
+ );
105
+ refreshedToken = freshToken.accessToken;
106
+ } catch {
107
+ // If token refresh fails, fall through to the !response.ok handler below
108
+ }
109
+ if (refreshedToken) {
110
+ accessToken = refreshedToken;
111
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
112
+ method: "POST",
113
+ headers: {
114
+ Authorization: `Bearer ${accessToken}`,
115
+ "Content-Type": "application/json",
116
+ },
117
+ body: JSON.stringify({ description: "CLI backup" }),
118
+ signal: AbortSignal.timeout(120_000),
119
+ });
120
+ }
121
+ }
107
122
  } catch (err) {
108
123
  if (err instanceof Error && err.name === "TimeoutError") {
109
124
  console.error("Error: Export request timed out after 2 minutes.");
@@ -39,12 +39,14 @@ import type { RemoteHost, Species } from "../lib/constants";
39
39
  import { hatchDocker } from "../lib/docker";
40
40
  import { hatchGcp } from "../lib/gcp";
41
41
  import type { PollResult, WatchHatchingResult } from "../lib/gcp";
42
+ import { buildNestedConfig, writeInitialConfig } from "../lib/config-utils";
42
43
  import {
43
44
  startLocalDaemon,
44
45
  startGateway,
45
46
  stopLocalProcesses,
46
47
  } from "../lib/local";
47
48
  import { maybeStartNgrokTunnel } from "../lib/ngrok";
49
+ import { getPlatformUrl } from "../lib/platform-client";
48
50
  import { httpHealthCheck } from "../lib/http-client";
49
51
  import { detectOrphanedProcesses } from "../lib/orphan-detection";
50
52
  import { isProcessAlive, stopProcess } from "../lib/process";
@@ -99,8 +101,9 @@ export async function buildStartupScript(
99
101
  providerApiKeys: Record<string, string>,
100
102
  instanceName: string,
101
103
  cloud: RemoteHost,
104
+ configValues: Record<string, string> = {},
102
105
  ): Promise<string> {
103
- const platformUrl = process.env.VELLUM_PLATFORM_URL ?? "https://vellum.ai";
106
+ const platformUrl = getPlatformUrl();
104
107
  const logPath =
105
108
  cloud === "custom"
106
109
  ? "/tmp/vellum-startup.log"
@@ -130,6 +133,22 @@ export async function buildStartupScript(
130
133
  .map((envVar) => `${envVar}=\$${envVar}`)
131
134
  .join("\n");
132
135
 
136
+ // Write --config key=value pairs to a temp JSON file on the remote host
137
+ // and export the env var so the daemon reads it on first boot.
138
+ let configWriteBlock = "";
139
+ if (Object.keys(configValues).length > 0) {
140
+ const configJson = JSON.stringify(buildNestedConfig(configValues), null, 2);
141
+ configWriteBlock = `
142
+ echo "Writing default workspace config..."
143
+ VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH="/tmp/vellum-initial-config-$$.json"
144
+ cat > "\$VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH" << 'VELLUM_CONFIG_EOF'
145
+ ${configJson}
146
+ VELLUM_CONFIG_EOF
147
+ export VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH
148
+ echo "Default workspace config written to \$VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH"
149
+ `;
150
+ }
151
+
133
152
  return `#!/bin/bash
134
153
  set -e
135
154
 
@@ -146,9 +165,10 @@ RUNTIME_HTTP_PORT=7821
146
165
  DOTENV_EOF
147
166
 
148
167
  ${ownershipFixup}
149
-
168
+ ${configWriteBlock}
150
169
  export VELLUM_SSH_USER="\$SSH_USER"
151
170
  export VELLUM_ASSISTANT_NAME="\$VELLUM_ASSISTANT_NAME"
171
+ export VELLUM_CLOUD="${cloud}"
152
172
  echo "Downloading install script from ${platformUrl}/install.sh..."
153
173
  curl -fsSL ${platformUrl}/install.sh -o ${INSTALL_SCRIPT_REMOTE_PATH}
154
174
  echo "Install script downloaded (\$(wc -c < ${INSTALL_SCRIPT_REMOTE_PATH}) bytes)"
@@ -166,9 +186,9 @@ interface HatchArgs {
166
186
  keepAlive: boolean;
167
187
  name: string | null;
168
188
  remote: RemoteHost;
169
- daemonOnly: boolean;
170
189
  restart: boolean;
171
190
  watch: boolean;
191
+ configValues: Record<string, string>;
172
192
  }
173
193
 
174
194
  function parseArgs(): HatchArgs {
@@ -178,9 +198,9 @@ function parseArgs(): HatchArgs {
178
198
  let keepAlive = false;
179
199
  let name: string | null = null;
180
200
  let remote: RemoteHost = DEFAULT_REMOTE;
181
- let daemonOnly = false;
182
201
  let restart = false;
183
202
  let watch = false;
203
+ const configValues: Record<string, string> = {};
184
204
 
185
205
  for (let i = 0; i < args.length; i++) {
186
206
  const arg = args[i];
@@ -199,9 +219,6 @@ function parseArgs(): HatchArgs {
199
219
  console.log(
200
220
  " --remote <host> Remote host (local, gcp, aws, docker, custom)",
201
221
  );
202
- console.log(
203
- " --daemon-only Start assistant only, skip gateway",
204
- );
205
222
  console.log(
206
223
  " --restart Restart processes without onboarding side effects",
207
224
  );
@@ -211,11 +228,12 @@ function parseArgs(): HatchArgs {
211
228
  console.log(
212
229
  " --keep-alive Stay alive after hatch, exit when gateway stops",
213
230
  );
231
+ console.log(
232
+ " --config <key=value> Set a workspace config value (repeatable)",
233
+ );
214
234
  process.exit(0);
215
235
  } else if (arg === "-d") {
216
236
  detached = true;
217
- } else if (arg === "--daemon-only") {
218
- daemonOnly = true;
219
237
  } else if (arg === "--restart") {
220
238
  restart = true;
221
239
  } else if (arg === "--watch") {
@@ -248,11 +266,28 @@ function parseArgs(): HatchArgs {
248
266
  }
249
267
  remote = next as RemoteHost;
250
268
  i++;
269
+ } else if (arg === "--config") {
270
+ const next = args[i + 1];
271
+ if (!next || next.startsWith("-")) {
272
+ console.error("Error: --config requires a key=value argument");
273
+ process.exit(1);
274
+ }
275
+ const eqIndex = next.indexOf("=");
276
+ if (eqIndex <= 0) {
277
+ console.error(
278
+ `Error: --config value must be in key=value format, got '${next}'`,
279
+ );
280
+ process.exit(1);
281
+ }
282
+ const key = next.slice(0, eqIndex);
283
+ const value = next.slice(eqIndex + 1);
284
+ configValues[key] = value;
285
+ i++;
251
286
  } else if (VALID_SPECIES.includes(arg as Species)) {
252
287
  species = arg as Species;
253
288
  } else {
254
289
  console.error(
255
- `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --watch, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
290
+ `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --restart, --watch, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>, --config <key=value>`,
256
291
  );
257
292
  process.exit(1);
258
293
  }
@@ -264,9 +299,9 @@ function parseArgs(): HatchArgs {
264
299
  keepAlive,
265
300
  name,
266
301
  remote,
267
- daemonOnly,
268
302
  restart,
269
303
  watch,
304
+ configValues,
270
305
  };
271
306
  }
272
307
 
@@ -574,10 +609,10 @@ function installCLISymlink(): void {
574
609
  async function hatchLocal(
575
610
  species: Species,
576
611
  name: string | null,
577
- daemonOnly: boolean = false,
578
612
  restart: boolean = false,
579
613
  watch: boolean = false,
580
614
  keepAlive: boolean = false,
615
+ configValues: Record<string, string> = {},
581
616
  ): Promise<void> {
582
617
  if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
583
618
  console.error(
@@ -709,46 +744,44 @@ async function hatchLocal(
709
744
  process.env.APP_VERSION = cliPkg.version;
710
745
  }
711
746
 
712
- await startLocalDaemon(watch, resources);
747
+ const defaultWorkspaceConfigPath = writeInitialConfig(configValues);
748
+
749
+ await startLocalDaemon(watch, resources, { defaultWorkspaceConfigPath });
713
750
 
714
- // When daemonOnly is set, skip gateway and ngrok — the caller only wants
715
- // the daemon restarted (e.g. macOS app bootstrap retry).
716
751
  let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
717
- if (!daemonOnly) {
718
- try {
719
- runtimeUrl = await startGateway(watch, resources);
720
- } catch (error) {
721
- // Gateway failed stop the daemon we just started so we don't leave
722
- // orphaned processes with no lock file entry.
723
- console.error(
724
- `\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
725
- );
726
- await stopLocalProcesses(resources);
727
- throw error;
728
- }
752
+ try {
753
+ runtimeUrl = await startGateway(watch, resources);
754
+ } catch (error) {
755
+ // Gateway failed — stop the daemon we just started so we don't leave
756
+ // orphaned processes with no lock file entry.
757
+ console.error(
758
+ `\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
759
+ );
760
+ await stopLocalProcesses(resources);
761
+ throw error;
762
+ }
729
763
 
730
- // Lease a guardian token so the desktop app can import it on first launch
731
- // instead of hitting /v1/guardian/init itself.
732
- try {
733
- await leaseGuardianToken(runtimeUrl, instanceName);
734
- } catch (err) {
735
- console.error(`⚠️ Guardian token lease failed: ${err}`);
736
- }
764
+ // Lease a guardian token so the desktop app can import it on first launch
765
+ // instead of hitting /v1/guardian/init itself.
766
+ try {
767
+ await leaseGuardianToken(runtimeUrl, instanceName);
768
+ } catch (err) {
769
+ console.error(`⚠️ Guardian token lease failed: ${err}`);
770
+ }
737
771
 
738
- // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
739
- // Set BASE_DATA_DIR so ngrok reads the correct instance config.
740
- const prevBaseDataDir = process.env.BASE_DATA_DIR;
741
- process.env.BASE_DATA_DIR = resources.instanceDir;
742
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
743
- if (ngrokChild?.pid) {
744
- const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
745
- writeFileSync(ngrokPidFile, String(ngrokChild.pid));
746
- }
747
- if (prevBaseDataDir !== undefined) {
748
- process.env.BASE_DATA_DIR = prevBaseDataDir;
749
- } else {
750
- delete process.env.BASE_DATA_DIR;
751
- }
772
+ // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
773
+ // Set BASE_DATA_DIR so ngrok reads the correct instance config.
774
+ const prevBaseDataDir = process.env.BASE_DATA_DIR;
775
+ process.env.BASE_DATA_DIR = resources.instanceDir;
776
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
777
+ if (ngrokChild?.pid) {
778
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
779
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
780
+ }
781
+ if (prevBaseDataDir !== undefined) {
782
+ process.env.BASE_DATA_DIR = prevBaseDataDir;
783
+ } else {
784
+ delete process.env.BASE_DATA_DIR;
752
785
  }
753
786
 
754
787
  const localEntry: AssistantEntry = {
@@ -761,7 +794,7 @@ async function hatchLocal(
761
794
  serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
762
795
  resources,
763
796
  };
764
- if (!daemonOnly && !restart) {
797
+ if (!restart) {
765
798
  saveAssistantEntry(localEntry);
766
799
  setActiveAssistant(instanceName);
767
800
  syncConfigToLockfile();
@@ -780,12 +813,8 @@ async function hatchLocal(
780
813
  }
781
814
 
782
815
  if (keepAlive) {
783
- // When --daemon-only is set, no gateway is running — poll the daemon
784
- // health endpoint instead of the gateway to avoid self-termination.
785
- const healthUrl = daemonOnly
786
- ? `http://127.0.0.1:${resources.daemonPort}/healthz`
787
- : `http://127.0.0.1:${resources.gatewayPort}/healthz`;
788
- const healthTarget = daemonOnly ? "Assistant" : "Gateway";
816
+ const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
817
+ const healthTarget = "Gateway";
789
818
  const POLL_INTERVAL_MS = 5000;
790
819
  const MAX_FAILURES = 3;
791
820
  let consecutiveFailures = 0;
@@ -839,9 +868,9 @@ export async function hatch(): Promise<void> {
839
868
  keepAlive,
840
869
  name,
841
870
  remote,
842
- daemonOnly,
843
871
  restart,
844
872
  watch,
873
+ configValues,
845
874
  } = parseArgs();
846
875
 
847
876
  if (restart && remote !== "local") {
@@ -859,22 +888,29 @@ export async function hatch(): Promise<void> {
859
888
  }
860
889
 
861
890
  if (remote === "local") {
862
- await hatchLocal(species, name, daemonOnly, restart, watch, keepAlive);
891
+ await hatchLocal(species, name, restart, watch, keepAlive, configValues);
863
892
  return;
864
893
  }
865
894
 
866
895
  if (remote === "gcp") {
867
- await hatchGcp(species, detached, name, buildStartupScript, watchHatching);
896
+ await hatchGcp(
897
+ species,
898
+ detached,
899
+ name,
900
+ buildStartupScript,
901
+ watchHatching,
902
+ configValues,
903
+ );
868
904
  return;
869
905
  }
870
906
 
871
907
  if (remote === "aws") {
872
- await hatchAws(species, detached, name);
908
+ await hatchAws(species, detached, name, configValues);
873
909
  return;
874
910
  }
875
911
 
876
912
  if (remote === "docker") {
877
- await hatchDocker(species, detached, name, watch);
913
+ await hatchDocker(species, detached, name, watch, configValues);
878
914
  return;
879
915
  }
880
916
 
@@ -1,10 +1,10 @@
1
1
  import { spawn } from "child_process";
2
2
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
3
- import { homedir } from "os";
4
3
  import { basename, dirname, join } from "path";
5
4
 
6
5
  import {
7
6
  findAssistantByName,
7
+ getBaseDir,
8
8
  loadAllAssistants,
9
9
  removeAssistantEntry,
10
10
  } from "../lib/assistant-config";
@@ -109,10 +109,10 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
109
109
  await stopOrphanedDaemonProcesses();
110
110
  }
111
111
 
112
- // For named instances (instanceDir differs from homedir), archive and
113
- // remove the entire instance directory. For the default instance
114
- // (instanceDir is homedir), archive only the .vellum subdirectory.
115
- const isNamedInstance = resources.instanceDir !== homedir();
112
+ // For named instances (instanceDir differs from the base directory),
113
+ // archive and remove the entire instance directory. For the default
114
+ // instance, archive only the .vellum subdirectory.
115
+ const isNamedInstance = resources.instanceDir !== getBaseDir();
116
116
  const dirToArchive = isNamedInstance ? resources.instanceDir : vellumDir;
117
117
 
118
118
  // Move the data directory out of the way so the path is immediately available