@vellumai/cli 0.4.37 → 0.4.41

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
@@ -12,7 +12,11 @@ import { createConnection } from "net";
12
12
  import { homedir, hostname, networkInterfaces, platform } from "os";
13
13
  import { dirname, join } from "path";
14
14
 
15
- import { loadLatestAssistant } from "./assistant-config.js";
15
+ import {
16
+ defaultLocalResources,
17
+ loadLatestAssistant,
18
+ type LocalInstanceResources,
19
+ } from "./assistant-config.js";
16
20
  import { GATEWAY_PORT } from "./constants.js";
17
21
  import { stopProcessByPidFile } from "./process.js";
18
22
  import { openLogFile, pipeToLogFile } from "./xdg-log.js";
@@ -210,14 +214,20 @@ function resolveDaemonMainPath(assistantIndex: string): string {
210
214
  return join(dirname(assistantIndex), "daemon", "main.ts");
211
215
  }
212
216
 
213
- async function startDaemonFromSource(assistantIndex: string): Promise<void> {
217
+ async function startDaemonFromSource(
218
+ assistantIndex: string,
219
+ resources?: LocalInstanceResources,
220
+ ): Promise<void> {
214
221
  const daemonMainPath = resolveDaemonMainPath(assistantIndex);
215
222
 
216
- const vellumDir = join(homedir(), ".vellum");
217
- mkdirSync(vellumDir, { recursive: true });
223
+ const defaults = defaultLocalResources();
224
+ const res = resources ?? defaults;
225
+ // Ensure the directory containing PID/socket files exists. For named
226
+ // instances this is instanceDir/.vellum/ (matching daemon's getRootDir()).
227
+ mkdirSync(dirname(res.pidFile), { recursive: true });
218
228
 
219
- const pidFile = join(vellumDir, "vellum.pid");
220
- const socketFile = join(vellumDir, "vellum.sock");
229
+ const pidFile = res.pidFile;
230
+ const socketFile = res.socketPath;
221
231
 
222
232
  // --- Lifecycle guard: prevent split-brain daemon state ---
223
233
  if (existsSync(pidFile)) {
@@ -263,6 +273,14 @@ async function startDaemonFromSource(assistantIndex: string): Promise<void> {
263
273
  env.VELLUM_DAEMON_TCP_ENABLED =
264
274
  process.env.VELLUM_DAEMON_TCP_ENABLED || "1";
265
275
  }
276
+ if (resources) {
277
+ env.BASE_DATA_DIR = resources.instanceDir;
278
+ env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
279
+ env.GATEWAY_PORT = String(resources.gatewayPort);
280
+ env.VELLUM_DAEMON_SOCKET = resources.socketPath;
281
+ env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
282
+ delete env.QDRANT_URL;
283
+ }
266
284
 
267
285
  // Use fd inheritance instead of pipes so the daemon's stdout/stderr survive
268
286
  // after the parent (hatch) exits. Bun does not ignore SIGPIPE, so piped
@@ -287,17 +305,19 @@ async function startDaemonFromSource(assistantIndex: string): Promise<void> {
287
305
  // assistant-side equivalent.
288
306
  async function startDaemonWatchFromSource(
289
307
  assistantIndex: string,
308
+ resources?: LocalInstanceResources,
290
309
  ): Promise<void> {
291
310
  const mainPath = resolveDaemonMainPath(assistantIndex);
292
311
  if (!existsSync(mainPath)) {
293
312
  throw new Error(`Daemon main.ts not found at ${mainPath}`);
294
313
  }
295
314
 
296
- const vellumDir = join(homedir(), ".vellum");
297
- mkdirSync(vellumDir, { recursive: true });
315
+ const defaults = defaultLocalResources();
316
+ const res = resources ?? defaults;
317
+ mkdirSync(dirname(res.pidFile), { recursive: true });
298
318
 
299
- const pidFile = join(vellumDir, "vellum.pid");
300
- const socketFile = join(vellumDir, "vellum.sock");
319
+ const pidFile = res.pidFile;
320
+ const socketFile = res.socketPath;
301
321
 
302
322
  // --- Lifecycle guard: prevent split-brain daemon state ---
303
323
  // If a daemon is already running, skip spawning a new one.
@@ -344,6 +364,14 @@ async function startDaemonWatchFromSource(
344
364
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
345
365
  VELLUM_DEV: "1",
346
366
  };
367
+ if (resources) {
368
+ env.BASE_DATA_DIR = resources.instanceDir;
369
+ env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
370
+ env.GATEWAY_PORT = String(resources.gatewayPort);
371
+ env.VELLUM_DAEMON_SOCKET = resources.socketPath;
372
+ env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
373
+ delete env.QDRANT_URL;
374
+ }
347
375
 
348
376
  const daemonLogFd = openLogFile("hatch.log");
349
377
  const child = spawn("bun", ["--watch", "run", mainPath], {
@@ -406,9 +434,12 @@ function normalizeIngressUrl(value: unknown): string | undefined {
406
434
  return normalized || undefined;
407
435
  }
408
436
 
409
- function readWorkspaceIngressPublicBaseUrl(): string | undefined {
437
+ function readWorkspaceIngressPublicBaseUrl(
438
+ instanceDir?: string,
439
+ ): string | undefined {
410
440
  const baseDataDir =
411
- process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir());
441
+ instanceDir ??
442
+ (process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir()));
412
443
  const workspaceConfigPath = join(
413
444
  baseDataDir,
414
445
  ".vellum",
@@ -478,7 +509,8 @@ function isSocketResponsive(
478
509
  });
479
510
  }
480
511
 
481
- async function discoverPublicUrl(): Promise<string | undefined> {
512
+ async function discoverPublicUrl(port?: number): Promise<string | undefined> {
513
+ const effectivePort = port ?? GATEWAY_PORT;
482
514
  const cloud = process.env.VELLUM_CLOUD;
483
515
 
484
516
  let externalIp: string | undefined;
@@ -516,7 +548,7 @@ async function discoverPublicUrl(): Promise<string | undefined> {
516
548
 
517
549
  if (externalIp) {
518
550
  console.log(` Discovered external IP: ${externalIp}`);
519
- return `http://${externalIp}:${GATEWAY_PORT}`;
551
+ return `http://${externalIp}:${effectivePort}`;
520
552
  }
521
553
  }
522
554
 
@@ -527,18 +559,18 @@ async function discoverPublicUrl(): Promise<string | undefined> {
527
559
  const localHostname = getMacLocalHostname();
528
560
  if (localHostname) {
529
561
  console.log(` Discovered macOS local hostname: ${localHostname}`);
530
- return `http://${localHostname}:${GATEWAY_PORT}`;
562
+ return `http://${localHostname}:${effectivePort}`;
531
563
  }
532
564
  }
533
565
 
534
566
  const lanIp = getLocalLanIPv4();
535
567
  if (lanIp) {
536
568
  console.log(` Discovered LAN IP: ${lanIp}`);
537
- return `http://${lanIp}:${GATEWAY_PORT}`;
569
+ return `http://${lanIp}:${effectivePort}`;
538
570
  }
539
571
 
540
572
  // Final fallback to localhost when no LAN address could be discovered.
541
- return `http://localhost:${GATEWAY_PORT}`;
573
+ return `http://localhost:${effectivePort}`;
542
574
  }
543
575
 
544
576
  /**
@@ -607,7 +639,10 @@ function getLocalLanIPv4(): string | undefined {
607
639
  // It should eventually converge with
608
640
  // assistant/src/daemon/daemon-control.ts::startDaemon which is the
609
641
  // assistant-side equivalent.
610
- export async function startLocalDaemon(watch: boolean = false): Promise<void> {
642
+ export async function startLocalDaemon(
643
+ watch: boolean = false,
644
+ resources?: LocalInstanceResources,
645
+ ): Promise<void> {
611
646
  if (process.env.VELLUM_DESKTOP_APP && !watch) {
612
647
  // When running inside the desktop app, the CLI owns the daemon lifecycle.
613
648
  // Find the vellum-daemon binary adjacent to the CLI binary.
@@ -621,9 +656,10 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
621
656
  );
622
657
  }
623
658
 
624
- const vellumDir = join(homedir(), ".vellum");
625
- const pidFile = join(vellumDir, "vellum.pid");
626
- const socketFile = join(vellumDir, "vellum.sock");
659
+ const defaults = defaultLocalResources();
660
+ const res = resources ?? defaults;
661
+ const pidFile = res.pidFile;
662
+ const socketFile = res.socketPath;
627
663
 
628
664
  // If a daemon is already running, skip spawning a new one.
629
665
  // This prevents cascading kill→restart cycles when multiple callers
@@ -679,8 +715,8 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
679
715
  // Ensure bun is available for runtime features (browser, skills install)
680
716
  ensureBunInstalled();
681
717
 
682
- // Ensure ~/.vellum/ exists for PID/socket files
683
- mkdirSync(vellumDir, { recursive: true });
718
+ // Ensure the directory containing PID/socket files exists
719
+ mkdirSync(dirname(pidFile), { recursive: true });
684
720
 
685
721
  // Build a minimal environment for the daemon. When launched from the
686
722
  // macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
@@ -698,6 +734,8 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
698
734
  for (const key of [
699
735
  "ANTHROPIC_API_KEY",
700
736
  "BASE_DATA_DIR",
737
+ "QDRANT_HTTP_PORT",
738
+ "QDRANT_URL",
701
739
  "RUNTIME_HTTP_PORT",
702
740
  "VELLUM_DAEMON_TCP_PORT",
703
741
  "VELLUM_DAEMON_TCP_HOST",
@@ -713,6 +751,16 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
713
751
  daemonEnv[key] = process.env[key]!;
714
752
  }
715
753
  }
754
+ // When running a named instance, override env so the daemon resolves
755
+ // all paths under the instance directory and listens on its own port.
756
+ if (resources) {
757
+ daemonEnv.BASE_DATA_DIR = resources.instanceDir;
758
+ daemonEnv.RUNTIME_HTTP_PORT = String(resources.daemonPort);
759
+ daemonEnv.GATEWAY_PORT = String(resources.gatewayPort);
760
+ daemonEnv.VELLUM_DAEMON_SOCKET = resources.socketPath;
761
+ daemonEnv.QDRANT_HTTP_PORT = String(resources.qdrantPort);
762
+ delete daemonEnv.QDRANT_URL;
763
+ }
716
764
 
717
765
  // Use fd inheritance instead of pipes so the daemon's stdout/stderr
718
766
  // survive after the parent (hatch) exits. Bun does not ignore SIGPIPE,
@@ -758,9 +806,9 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
758
806
  // Kill the bundled daemon to avoid two processes competing for the same socket/port
759
807
  await stopProcessByPidFile(pidFile, "bundled daemon", [socketFile]);
760
808
  if (watch) {
761
- await startDaemonWatchFromSource(assistantIndex);
809
+ await startDaemonWatchFromSource(assistantIndex, resources);
762
810
  } else {
763
- await startDaemonFromSource(assistantIndex);
811
+ await startDaemonFromSource(assistantIndex, resources);
764
812
  }
765
813
  socketReady = await waitForSocketFile(socketFile, 60000);
766
814
  }
@@ -783,12 +831,13 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
783
831
  " Ensure the daemon binary is bundled alongside the CLI, or run from the source tree.",
784
832
  );
785
833
  }
834
+ const defaults = defaultLocalResources();
835
+ const res = resources ?? defaults;
836
+
786
837
  if (watch) {
787
- await startDaemonWatchFromSource(assistantIndex);
838
+ await startDaemonWatchFromSource(assistantIndex, resources);
788
839
 
789
- const vellumDir = join(homedir(), ".vellum");
790
- const socketFile = join(vellumDir, "vellum.sock");
791
- const socketReady = await waitForSocketFile(socketFile, 60000);
840
+ const socketReady = await waitForSocketFile(res.socketPath, 60000);
792
841
  if (socketReady) {
793
842
  console.log(" Assistant socket ready\n");
794
843
  } else {
@@ -797,11 +846,9 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
797
846
  );
798
847
  }
799
848
  } else {
800
- await startDaemonFromSource(assistantIndex);
849
+ await startDaemonFromSource(assistantIndex, resources);
801
850
 
802
- const vellumDir = join(homedir(), ".vellum");
803
- const socketFile = join(vellumDir, "vellum.sock");
804
- const socketReady = await waitForSocketFile(socketFile, 60000);
851
+ const socketReady = await waitForSocketFile(res.socketPath, 60000);
805
852
  if (socketReady) {
806
853
  console.log(" Assistant socket ready\n");
807
854
  } else {
@@ -816,8 +863,11 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
816
863
  export async function startGateway(
817
864
  assistantId?: string,
818
865
  watch: boolean = false,
866
+ resources?: LocalInstanceResources,
819
867
  ): Promise<string> {
820
- const publicUrl = await discoverPublicUrl();
868
+ const effectiveGatewayPort = resources?.gatewayPort ?? GATEWAY_PORT;
869
+
870
+ const publicUrl = await discoverPublicUrl(effectiveGatewayPort);
821
871
  if (publicUrl) {
822
872
  console.log(` Public URL: ${publicUrl}`);
823
873
  }
@@ -831,17 +881,91 @@ export async function startGateway(
831
881
  process.env.GATEWAY_DEFAULT_ASSISTANT_ID ||
832
882
  loadLatestAssistant()?.assistantId;
833
883
 
884
+ // Read the bearer token so the gateway can authenticate proxied requests
885
+ // (e.g. from paired iOS devices). Respect VELLUM_HTTP_TOKEN_PATH and
886
+ // BASE_DATA_DIR for consistency with gateway/config.ts and the daemon.
887
+ // When resources are provided, the token lives under the instance directory.
888
+ const httpTokenPath =
889
+ process.env.VELLUM_HTTP_TOKEN_PATH ??
890
+ (resources
891
+ ? join(resources.instanceDir, ".vellum", "http-token")
892
+ : join(
893
+ process.env.BASE_DATA_DIR?.trim() || homedir(),
894
+ ".vellum",
895
+ "http-token",
896
+ ));
897
+ let runtimeProxyBearerToken: string | undefined;
898
+ try {
899
+ const tok = readFileSync(httpTokenPath, "utf-8").trim();
900
+ if (tok) runtimeProxyBearerToken = tok;
901
+ } catch {
902
+ // Token file doesn't exist yet — daemon hasn't written it.
903
+ }
904
+
905
+ // If no token is available (first startup — daemon hasn't written it yet),
906
+ // poll for the file to appear. On fresh installs the daemon may take 60s+
907
+ // for Qdrant download, migrations, and first-time init. Starting the
908
+ // gateway without auth is a security risk since the config is loaded once
909
+ // at startup and never reloads, so we fail rather than silently disabling auth.
910
+ if (!runtimeProxyBearerToken) {
911
+ console.log(" Waiting for bearer token file...");
912
+ const maxWait = 60000;
913
+ const pollInterval = 500;
914
+ const start = Date.now();
915
+ const pidFile =
916
+ resources?.pidFile ??
917
+ join(
918
+ process.env.BASE_DATA_DIR?.trim() || homedir(),
919
+ ".vellum",
920
+ "vellum.pid",
921
+ );
922
+ while (Date.now() - start < maxWait) {
923
+ await new Promise((r) => setTimeout(r, pollInterval));
924
+ try {
925
+ const tok = readFileSync(httpTokenPath, "utf-8").trim();
926
+ if (tok) {
927
+ runtimeProxyBearerToken = tok;
928
+ break;
929
+ }
930
+ } catch {
931
+ // File still doesn't exist, keep polling.
932
+ }
933
+ // Check if the daemon process is still alive — no point waiting if it crashed
934
+ try {
935
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
936
+ if (pid) process.kill(pid, 0); // throws if process doesn't exist
937
+ } catch {
938
+ break; // daemon process is gone
939
+ }
940
+ }
941
+ }
942
+
943
+ if (!runtimeProxyBearerToken) {
944
+ throw new Error(
945
+ `Bearer token file not found at ${httpTokenPath} after 60s.\n` +
946
+ " The gateway cannot start without authentication — this would leave the proxy permanently unauthenticated.\n" +
947
+ " Ensure the daemon is running and has written the token file, or set VELLUM_HTTP_TOKEN_PATH to the correct path.",
948
+ );
949
+ }
950
+ const effectiveDaemonPort =
951
+ resources?.daemonPort ?? Number(process.env.RUNTIME_HTTP_PORT || "7821");
952
+
834
953
  const gatewayEnv: Record<string, string> = {
835
954
  ...(process.env as Record<string, string>),
836
955
  GATEWAY_RUNTIME_PROXY_ENABLED: "true",
837
956
  GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "true",
838
- RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
957
+ RUNTIME_PROXY_BEARER_TOKEN: runtimeProxyBearerToken,
958
+ RUNTIME_HTTP_PORT: String(effectiveDaemonPort),
959
+ GATEWAY_PORT: String(effectiveGatewayPort),
839
960
  // Skip the drain window for locally-launched gateways — there is no load
840
961
  // balancer draining connections, so waiting serves no purpose and causes
841
962
  // `vellum sleep` to SIGKILL the gateway when the CLI timeout is shorter
842
963
  // than the drain window. Respect an explicit env override.
843
964
  GATEWAY_SHUTDOWN_DRAIN_MS: process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "0",
844
965
  ...(watch ? { VELLUM_DEV: "1" } : {}),
966
+ // Set BASE_DATA_DIR so the gateway loads the correct signing key and
967
+ // credentials for this instance (mirrors the daemon env setup).
968
+ ...(resources ? { BASE_DATA_DIR: resources.instanceDir } : {}),
845
969
  };
846
970
 
847
971
  if (process.env.GATEWAY_UNMAPPED_POLICY) {
@@ -853,7 +977,9 @@ export async function startGateway(
853
977
  if (resolvedAssistantId) {
854
978
  gatewayEnv.GATEWAY_DEFAULT_ASSISTANT_ID = resolvedAssistantId;
855
979
  }
856
- const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl();
980
+ const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl(
981
+ resources?.instanceDir,
982
+ );
857
983
  const ingressPublicBaseUrl =
858
984
  workspaceIngressPublicBaseUrl ??
859
985
  normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL) ??
@@ -908,11 +1034,13 @@ export async function startGateway(
908
1034
  gateway.unref();
909
1035
 
910
1036
  if (gateway.pid) {
911
- const vellumDir = join(homedir(), ".vellum");
912
- writeFileSync(join(vellumDir, "gateway.pid"), String(gateway.pid), "utf-8");
1037
+ const gwPidDir = resources
1038
+ ? join(resources.instanceDir, ".vellum")
1039
+ : join(homedir(), ".vellum");
1040
+ writeFileSync(join(gwPidDir, "gateway.pid"), String(gateway.pid), "utf-8");
913
1041
  }
914
1042
 
915
- const gatewayUrl = publicUrl || `http://localhost:${GATEWAY_PORT}`;
1043
+ const gatewayUrl = publicUrl || `http://localhost:${effectiveGatewayPort}`;
916
1044
 
917
1045
  // Wait for the gateway to be responsive before returning. Without this,
918
1046
  // callers (e.g. displayPairingQRCode) may try to connect before the HTTP
@@ -922,9 +1050,12 @@ export async function startGateway(
922
1050
  let ready = false;
923
1051
  while (Date.now() - start < timeoutMs) {
924
1052
  try {
925
- const res = await fetch(`http://localhost:${GATEWAY_PORT}/healthz`, {
926
- signal: AbortSignal.timeout(2000),
927
- });
1053
+ const res = await fetch(
1054
+ `http://localhost:${effectiveGatewayPort}/healthz`,
1055
+ {
1056
+ signal: AbortSignal.timeout(2000),
1057
+ },
1058
+ );
928
1059
  if (res.ok) {
929
1060
  ready = true;
930
1061
  break;
@@ -946,14 +1077,21 @@ export async function startGateway(
946
1077
  }
947
1078
 
948
1079
  /**
949
- * Stop any locally-running daemon and gateway processes and clean up
950
- * PID/socket files. Called when hatch fails partway through so we don't
951
- * leave orphaned processes with no lock file entry.
1080
+ * Stop any locally-running daemon and gateway processes
1081
+ * and clean up PID/socket files. Called when hatch fails partway through
1082
+ * so we don't leave orphaned processes with no lock file entry.
1083
+ *
1084
+ * When `resources` is provided, uses instance-specific paths instead of
1085
+ * the default ~/.vellum/ paths.
952
1086
  */
953
- export async function stopLocalProcesses(): Promise<void> {
954
- const vellumDir = join(homedir(), ".vellum");
955
- const daemonPidFile = join(vellumDir, "vellum.pid");
956
- const socketFile = join(vellumDir, "vellum.sock");
1087
+ export async function stopLocalProcesses(
1088
+ resources?: LocalInstanceResources,
1089
+ ): Promise<void> {
1090
+ const vellumDir = resources
1091
+ ? join(resources.instanceDir, ".vellum")
1092
+ : join(homedir(), ".vellum");
1093
+ const daemonPidFile = resources?.pidFile ?? join(vellumDir, "vellum.pid");
1094
+ const socketFile = resources?.socketPath ?? join(vellumDir, "vellum.sock");
957
1095
  await stopProcessByPidFile(daemonPidFile, "daemon", [socketFile]);
958
1096
 
959
1097
  const gatewayPidFile = join(vellumDir, "gateway.pid");
@@ -6,6 +6,9 @@ export function statusEmoji(status: string): string {
6
6
  if (s.startsWith("error") || s === "unreachable" || s.startsWith("exited")) {
7
7
  return "🔴";
8
8
  }
9
+ if (s === "sleeping") {
10
+ return "💤";
11
+ }
9
12
  return "🟡";
10
13
  }
11
14