@vellumai/cli 0.4.31 → 0.4.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.31",
3
+ "version": "0.4.32",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,9 +56,10 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
56
56
  socketFile,
57
57
  ]);
58
58
 
59
- // Stop gateway via PID file
59
+ // Stop gateway via PID file — use a longer timeout because the gateway has a
60
+ // configurable drain window (GATEWAY_SHUTDOWN_DRAIN_MS, default 5s) before it exits.
60
61
  const gatewayPidFile = join(vellumDir, "gateway.pid");
61
- await stopProcessByPidFile(gatewayPidFile, "gateway");
62
+ await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
62
63
 
63
64
  // If the PID file didn't track a running daemon, scan for orphaned
64
65
  // daemon processes that may have been started without writing a PID.
@@ -27,8 +27,14 @@ export async function sleep(): Promise<void> {
27
27
  console.log("Assistant stopped.");
28
28
  }
29
29
 
30
- // Stop gateway
31
- const gatewayStopped = await stopProcessByPidFile(gatewayPidFile, "gateway");
30
+ // Stop gateway — use a longer timeout because the gateway has a configurable
31
+ // drain window (GATEWAY_SHUTDOWN_DRAIN_MS, default 5s) before it exits.
32
+ const gatewayStopped = await stopProcessByPidFile(
33
+ gatewayPidFile,
34
+ "gateway",
35
+ undefined,
36
+ 7000,
37
+ );
32
38
  if (!gatewayStopped) {
33
39
  console.log("Gateway is not running.");
34
40
  } else {
package/src/lib/gcp.ts CHANGED
@@ -198,58 +198,6 @@ export async function syncFirewallRules(
198
198
  }
199
199
  }
200
200
 
201
- export async function fetchFirewallRules(
202
- project: string,
203
- tag: string,
204
- ): Promise<string> {
205
- const output = await execOutput("gcloud", [
206
- "compute",
207
- "firewall-rules",
208
- "list",
209
- `--project=${project}`,
210
- "--format=json",
211
- ]);
212
- const rules = JSON.parse(output) as Array<{ targetTags?: string[] }>;
213
- const filtered = rules.filter((r) => r.targetTags?.includes(tag));
214
- return JSON.stringify(filtered, null, 2);
215
- }
216
-
217
- export interface GcpInstance {
218
- name: string;
219
- zone: string;
220
- externalIp: string | null;
221
- species: string | null;
222
- }
223
-
224
- export async function listAssistantInstances(
225
- project: string,
226
- ): Promise<GcpInstance[]> {
227
- const output = await execOutput("gcloud", [
228
- "compute",
229
- "instances",
230
- "list",
231
- `--project=${project}`,
232
- "--filter=labels.vellum-assistant=true",
233
- "--format=json(name,zone,networkInterfaces[0].accessConfigs[0].natIP,labels)",
234
- ]);
235
- const parsed = JSON.parse(output) as Array<{
236
- name: string;
237
- zone: string;
238
- networkInterfaces?: Array<{ accessConfigs?: Array<{ natIP?: string }> }>;
239
- labels?: Record<string, string>;
240
- }>;
241
- return parsed.map((inst) => {
242
- const zoneParts = (inst.zone ?? "").split("/");
243
- return {
244
- name: inst.name,
245
- zone: zoneParts[zoneParts.length - 1] || "",
246
- externalIp:
247
- inst.networkInterfaces?.[0]?.accessConfigs?.[0]?.natIP ?? null,
248
- species: inst.labels?.species ?? null,
249
- };
250
- });
251
- }
252
-
253
201
  export async function instanceExists(
254
202
  instanceName: string,
255
203
  project: string,
@@ -281,27 +229,6 @@ export async function instanceExists(
281
229
  }
282
230
  }
283
231
 
284
- export async function sshCommand(
285
- instanceName: string,
286
- project: string,
287
- zone: string,
288
- command: string,
289
- ): Promise<string> {
290
- return execOutput("gcloud", [
291
- "compute",
292
- "ssh",
293
- instanceName,
294
- `--project=${project}`,
295
- `--zone=${zone}`,
296
- "--quiet",
297
- "--ssh-flag=-o StrictHostKeyChecking=no",
298
- "--ssh-flag=-o UserKnownHostsFile=/dev/null",
299
- "--ssh-flag=-o ConnectTimeout=5",
300
- "--ssh-flag=-o LogLevel=ERROR",
301
- `--command=${command}`,
302
- ]);
303
- }
304
-
305
232
  export async function fetchAndDisplayStartupLogs(
306
233
  instanceName: string,
307
234
  project: string,
package/src/lib/local.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { execFileSync, spawn } from "child_process";
1
+ import { execFileSync, execSync, spawn } from "child_process";
2
2
  import {
3
3
  closeSync,
4
4
  existsSync,
@@ -149,6 +149,63 @@ async function waitForSocketFile(
149
149
  return existsSync(socketPath);
150
150
  }
151
151
 
152
+ function ensureBunInstalled(): void {
153
+ const bunBinDir = join(homedir(), ".bun", "bin");
154
+ const pathWithBun = [
155
+ bunBinDir,
156
+ process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
157
+ ].join(":");
158
+
159
+ try {
160
+ execFileSync("bun", ["--version"], {
161
+ stdio: "pipe",
162
+ env: { ...process.env, PATH: pathWithBun },
163
+ });
164
+ return;
165
+ } catch {
166
+ // bun not found, try to install
167
+ }
168
+
169
+ console.log(" Installing bun...");
170
+ try {
171
+ const installEnv: Record<string, string> = {
172
+ HOME: process.env.HOME || homedir(),
173
+ PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
174
+ TMPDIR: process.env.TMPDIR || "/tmp",
175
+ USER: process.env.USER || "",
176
+ LANG: process.env.LANG || "",
177
+ };
178
+ // Preserve proxy/TLS env vars so curl works in proxied/corporate environments
179
+ for (const key of [
180
+ "HTTP_PROXY",
181
+ "http_proxy",
182
+ "HTTPS_PROXY",
183
+ "https_proxy",
184
+ "ALL_PROXY",
185
+ "all_proxy",
186
+ "NO_PROXY",
187
+ "no_proxy",
188
+ "SSL_CERT_FILE",
189
+ "SSL_CERT_DIR",
190
+ "CURL_CA_BUNDLE",
191
+ ]) {
192
+ if (process.env[key]) {
193
+ installEnv[key] = process.env[key]!;
194
+ }
195
+ }
196
+ execSync("curl -fsSL https://bun.sh/install | bash", {
197
+ stdio: "pipe",
198
+ timeout: 60_000,
199
+ env: installEnv,
200
+ });
201
+ console.log(" Bun installed successfully");
202
+ } catch {
203
+ console.log(
204
+ " ⚠️ Failed to install bun — some features may be unavailable",
205
+ );
206
+ }
207
+ }
208
+
152
209
  function resolveDaemonMainPath(assistantIndex: string): string {
153
210
  return join(dirname(assistantIndex), "daemon", "main.ts");
154
211
  }
@@ -605,6 +662,9 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
605
662
  } else {
606
663
  console.log(" Assistant socket is responsive — skipping restart\n");
607
664
  }
665
+ // Ensure bun is available for runtime features (browser, skills install)
666
+ // even when reusing an existing daemon.
667
+ ensureBunInstalled();
608
668
  return;
609
669
  }
610
670
 
@@ -615,6 +675,9 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
615
675
 
616
676
  console.log("🔨 Starting assistant...");
617
677
 
678
+ // Ensure bun is available for runtime features (browser, skills install)
679
+ ensureBunInstalled();
680
+
618
681
  // Ensure ~/.vellum/ exists for PID/socket files
619
682
  mkdirSync(vellumDir, { recursive: true });
620
683
 
@@ -622,10 +685,12 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
622
685
  // macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
623
686
  // __CFBundleIdentifier, CLAUDE_CODE_ENTRYPOINT, etc.) that can cause
624
687
  // the daemon to take 50+ seconds to start instead of ~1s.
688
+ const bunBinDir = join(homedir(), ".bun", "bin");
689
+ const basePath =
690
+ process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
625
691
  const daemonEnv: Record<string, string> = {
626
692
  HOME: process.env.HOME || homedir(),
627
- PATH:
628
- process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
693
+ PATH: `${bunBinDir}:${basePath}`,
629
694
  VELLUM_DAEMON_TCP_ENABLED: "1",
630
695
  };
631
696
  // Forward optional config env vars the daemon may need
@@ -647,14 +712,18 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
647
712
  }
648
713
  }
649
714
 
715
+ // Use fd inheritance instead of pipes so the daemon's stdout/stderr
716
+ // survive after the parent (hatch) exits. Bun does not ignore SIGPIPE,
717
+ // so piped stdio would kill the daemon on its first write after the
718
+ // parent closes.
650
719
  const daemonLogFd = openLogFile("hatch.log");
651
720
  const child = spawn(daemonBinary, [], {
652
721
  cwd: dirname(daemonBinary),
653
722
  detached: true,
654
- stdio: ["ignore", "pipe", "pipe"],
723
+ stdio: ["ignore", daemonLogFd, daemonLogFd],
655
724
  env: daemonEnv,
656
725
  });
657
- pipeToLogFile(child, daemonLogFd, "daemon");
726
+ if (typeof daemonLogFd === "number") closeSync(daemonLogFd);
658
727
  child.unref();
659
728
  const daemonPid = child.pid;
660
729
 
@@ -665,6 +734,13 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
665
734
  }
666
735
  }
667
736
 
737
+ // Ensure bun is available for runtime features (browser, skills install)
738
+ // Runs after daemon-reuse checks so the fast attach path is not blocked
739
+ // by a potentially slow bun install when the daemon is already alive.
740
+ if (daemonAlive) {
741
+ ensureBunInstalled();
742
+ }
743
+
668
744
  // Wait for socket at ~/.vellum/vellum.sock (up to 60s — fresh installs
669
745
  // may need 30-60s for Qdrant download, migrations, and first-time init)
670
746
  let socketReady = await waitForSocketFile(socketFile, 60000);
@@ -821,6 +897,11 @@ export async function startGateway(
821
897
  GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "true",
822
898
  RUNTIME_PROXY_BEARER_TOKEN: runtimeProxyBearerToken,
823
899
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
900
+ // Skip the drain window for locally-launched gateways — there is no load
901
+ // balancer draining connections, so waiting serves no purpose and causes
902
+ // `vellum sleep` to SIGKILL the gateway when the CLI timeout is shorter
903
+ // than the drain window. Respect an explicit env override.
904
+ GATEWAY_SHUTDOWN_DRAIN_MS: process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "0",
824
905
  };
825
906
 
826
907
  if (process.env.GATEWAY_UNMAPPED_POLICY) {
@@ -856,13 +937,15 @@ export async function startGateway(
856
937
  );
857
938
  }
858
939
 
940
+ // Use fd inheritance (not pipes) so the gateway survives after the
941
+ // hatch CLI exits — Bun does not ignore SIGPIPE.
859
942
  const gatewayLogFd = openLogFile("hatch.log");
860
943
  gateway = spawn(gatewayBinary, [], {
861
944
  detached: true,
862
- stdio: ["ignore", "pipe", "pipe"],
945
+ stdio: ["ignore", gatewayLogFd, gatewayLogFd],
863
946
  env: gatewayEnv,
864
947
  });
865
- pipeToLogFile(gateway, gatewayLogFd, "gateway");
948
+ if (typeof gatewayLogFd === "number") closeSync(gatewayLogFd);
866
949
  } else {
867
950
  // Source tree / bunx: resolve the gateway source directory and run via bun.
868
951
  const gatewayDir = resolveGatewayDir();
@@ -873,10 +956,10 @@ export async function startGateway(
873
956
  gateway = spawn("bun", bunArgs, {
874
957
  cwd: gatewayDir,
875
958
  detached: true,
876
- stdio: ["ignore", "pipe", "pipe"],
959
+ stdio: ["ignore", gwLogFd, gwLogFd],
877
960
  env: gatewayEnv,
878
961
  });
879
- pipeToLogFile(gateway, gwLogFd, "gateway");
962
+ if (typeof gwLogFd === "number") closeSync(gwLogFd);
880
963
  if (watch) {
881
964
  console.log(" Gateway started in watch mode (bun --watch)");
882
965
  }
@@ -934,6 +1017,5 @@ export async function stopLocalProcesses(): Promise<void> {
934
1017
  await stopProcessByPidFile(daemonPidFile, "daemon", [socketFile]);
935
1018
 
936
1019
  const gatewayPidFile = join(vellumDir, "gateway.pid");
937
- await stopProcessByPidFile(gatewayPidFile, "gateway");
938
-
1020
+ await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
939
1021
  }
@@ -45,12 +45,13 @@ export function isProcessAlive(pidFile: string): {
45
45
  }
46
46
 
47
47
  /**
48
- * Stop a process by PID: SIGTERM, wait up to 2s, then SIGKILL if still alive.
48
+ * Stop a process by PID: SIGTERM, wait up to `timeoutMs`, then SIGKILL if still alive.
49
49
  * Returns true if the process was stopped, false if it wasn't alive.
50
50
  */
51
51
  export async function stopProcess(
52
52
  pid: number,
53
53
  label: string,
54
+ timeoutMs: number = 2000,
54
55
  ): Promise<boolean> {
55
56
  try {
56
57
  process.kill(pid, 0);
@@ -61,7 +62,7 @@ export async function stopProcess(
61
62
  console.log(`Stopping ${label} (pid ${pid})...`);
62
63
  process.kill(pid, "SIGTERM");
63
64
 
64
- const deadline = Date.now() + 2000;
65
+ const deadline = Date.now() + timeoutMs;
65
66
  while (Date.now() < deadline) {
66
67
  try {
67
68
  process.kill(pid, 0);
@@ -90,6 +91,7 @@ export async function stopProcessByPidFile(
90
91
  pidFile: string,
91
92
  label: string,
92
93
  cleanupFiles?: string[],
94
+ timeoutMs?: number,
93
95
  ): Promise<boolean> {
94
96
  const { alive, pid } = isProcessAlive(pidFile);
95
97
 
@@ -129,7 +131,7 @@ export async function stopProcessByPidFile(
129
131
  return false;
130
132
  }
131
133
 
132
- const stopped = await stopProcess(pid, label);
134
+ const stopped = await stopProcess(pid, label, timeoutMs);
133
135
 
134
136
  try {
135
137
  unlinkSync(pidFile);
@@ -1,46 +1,5 @@
1
1
  import { spawn } from "child_process";
2
2
 
3
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4
-
5
- interface Step {
6
- name: string;
7
- run: () => Promise<void>;
8
- }
9
-
10
- export function clearLine(): void {
11
- process.stdout.write("\r\x1b[K");
12
- }
13
-
14
- export function showSpinner(name: string): NodeJS.Timeout {
15
- let frameIndex = 0;
16
- return setInterval(() => {
17
- clearLine();
18
- process.stdout.write(` ${SPINNER_FRAMES[frameIndex]} ${name}...`);
19
- frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
20
- }, 80);
21
- }
22
-
23
- export async function runSteps(steps: Step[]): Promise<void> {
24
- for (let i = 0; i < steps.length; i++) {
25
- const step = steps[i];
26
- const label = `[${i + 1}/${steps.length}] ${step.name}`;
27
-
28
- const spinner = showSpinner(label);
29
-
30
- try {
31
- await step.run();
32
- clearInterval(spinner);
33
- clearLine();
34
- console.log(` ✔ ${label}`);
35
- } catch (error) {
36
- clearInterval(spinner);
37
- clearLine();
38
- console.log(` ✖ ${label}`);
39
- throw error;
40
- }
41
- }
42
- }
43
-
44
3
  export function exec(
45
4
  command: string,
46
5
  args: string[],