@vellumai/cli 0.4.36 → 0.4.40

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.
@@ -342,7 +362,16 @@ async function startDaemonWatchFromSource(
342
362
  const env: Record<string, string | undefined> = {
343
363
  ...process.env,
344
364
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
365
+ VELLUM_DEV: "1",
345
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
+ }
346
375
 
347
376
  const daemonLogFd = openLogFile("hatch.log");
348
377
  const child = spawn("bun", ["--watch", "run", mainPath], {
@@ -405,9 +434,12 @@ function normalizeIngressUrl(value: unknown): string | undefined {
405
434
  return normalized || undefined;
406
435
  }
407
436
 
408
- function readWorkspaceIngressPublicBaseUrl(): string | undefined {
437
+ function readWorkspaceIngressPublicBaseUrl(
438
+ instanceDir?: string,
439
+ ): string | undefined {
409
440
  const baseDataDir =
410
- process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir());
441
+ instanceDir ??
442
+ (process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir()));
411
443
  const workspaceConfigPath = join(
412
444
  baseDataDir,
413
445
  ".vellum",
@@ -477,7 +509,8 @@ function isSocketResponsive(
477
509
  });
478
510
  }
479
511
 
480
- async function discoverPublicUrl(): Promise<string | undefined> {
512
+ async function discoverPublicUrl(port?: number): Promise<string | undefined> {
513
+ const effectivePort = port ?? GATEWAY_PORT;
481
514
  const cloud = process.env.VELLUM_CLOUD;
482
515
 
483
516
  let externalIp: string | undefined;
@@ -515,7 +548,7 @@ async function discoverPublicUrl(): Promise<string | undefined> {
515
548
 
516
549
  if (externalIp) {
517
550
  console.log(` Discovered external IP: ${externalIp}`);
518
- return `http://${externalIp}:${GATEWAY_PORT}`;
551
+ return `http://${externalIp}:${effectivePort}`;
519
552
  }
520
553
  }
521
554
 
@@ -526,18 +559,18 @@ async function discoverPublicUrl(): Promise<string | undefined> {
526
559
  const localHostname = getMacLocalHostname();
527
560
  if (localHostname) {
528
561
  console.log(` Discovered macOS local hostname: ${localHostname}`);
529
- return `http://${localHostname}:${GATEWAY_PORT}`;
562
+ return `http://${localHostname}:${effectivePort}`;
530
563
  }
531
564
  }
532
565
 
533
566
  const lanIp = getLocalLanIPv4();
534
567
  if (lanIp) {
535
568
  console.log(` Discovered LAN IP: ${lanIp}`);
536
- return `http://${lanIp}:${GATEWAY_PORT}`;
569
+ return `http://${lanIp}:${effectivePort}`;
537
570
  }
538
571
 
539
572
  // Final fallback to localhost when no LAN address could be discovered.
540
- return `http://localhost:${GATEWAY_PORT}`;
573
+ return `http://localhost:${effectivePort}`;
541
574
  }
542
575
 
543
576
  /**
@@ -606,7 +639,10 @@ function getLocalLanIPv4(): string | undefined {
606
639
  // It should eventually converge with
607
640
  // assistant/src/daemon/daemon-control.ts::startDaemon which is the
608
641
  // assistant-side equivalent.
609
- export async function startLocalDaemon(watch: boolean = false): Promise<void> {
642
+ export async function startLocalDaemon(
643
+ watch: boolean = false,
644
+ resources?: LocalInstanceResources,
645
+ ): Promise<void> {
610
646
  if (process.env.VELLUM_DESKTOP_APP && !watch) {
611
647
  // When running inside the desktop app, the CLI owns the daemon lifecycle.
612
648
  // Find the vellum-daemon binary adjacent to the CLI binary.
@@ -620,9 +656,10 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
620
656
  );
621
657
  }
622
658
 
623
- const vellumDir = join(homedir(), ".vellum");
624
- const pidFile = join(vellumDir, "vellum.pid");
625
- 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;
626
663
 
627
664
  // If a daemon is already running, skip spawning a new one.
628
665
  // This prevents cascading kill→restart cycles when multiple callers
@@ -678,8 +715,8 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
678
715
  // Ensure bun is available for runtime features (browser, skills install)
679
716
  ensureBunInstalled();
680
717
 
681
- // Ensure ~/.vellum/ exists for PID/socket files
682
- mkdirSync(vellumDir, { recursive: true });
718
+ // Ensure the directory containing PID/socket files exists
719
+ mkdirSync(dirname(pidFile), { recursive: true });
683
720
 
684
721
  // Build a minimal environment for the daemon. When launched from the
685
722
  // macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
@@ -697,6 +734,8 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
697
734
  for (const key of [
698
735
  "ANTHROPIC_API_KEY",
699
736
  "BASE_DATA_DIR",
737
+ "QDRANT_HTTP_PORT",
738
+ "QDRANT_URL",
700
739
  "RUNTIME_HTTP_PORT",
701
740
  "VELLUM_DAEMON_TCP_PORT",
702
741
  "VELLUM_DAEMON_TCP_HOST",
@@ -712,6 +751,16 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
712
751
  daemonEnv[key] = process.env[key]!;
713
752
  }
714
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
+ }
715
764
 
716
765
  // Use fd inheritance instead of pipes so the daemon's stdout/stderr
717
766
  // survive after the parent (hatch) exits. Bun does not ignore SIGPIPE,
@@ -757,9 +806,9 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
757
806
  // Kill the bundled daemon to avoid two processes competing for the same socket/port
758
807
  await stopProcessByPidFile(pidFile, "bundled daemon", [socketFile]);
759
808
  if (watch) {
760
- await startDaemonWatchFromSource(assistantIndex);
809
+ await startDaemonWatchFromSource(assistantIndex, resources);
761
810
  } else {
762
- await startDaemonFromSource(assistantIndex);
811
+ await startDaemonFromSource(assistantIndex, resources);
763
812
  }
764
813
  socketReady = await waitForSocketFile(socketFile, 60000);
765
814
  }
@@ -782,12 +831,13 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
782
831
  " Ensure the daemon binary is bundled alongside the CLI, or run from the source tree.",
783
832
  );
784
833
  }
834
+ const defaults = defaultLocalResources();
835
+ const res = resources ?? defaults;
836
+
785
837
  if (watch) {
786
- await startDaemonWatchFromSource(assistantIndex);
838
+ await startDaemonWatchFromSource(assistantIndex, resources);
787
839
 
788
- const vellumDir = join(homedir(), ".vellum");
789
- const socketFile = join(vellumDir, "vellum.sock");
790
- const socketReady = await waitForSocketFile(socketFile, 60000);
840
+ const socketReady = await waitForSocketFile(res.socketPath, 60000);
791
841
  if (socketReady) {
792
842
  console.log(" Assistant socket ready\n");
793
843
  } else {
@@ -796,11 +846,9 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
796
846
  );
797
847
  }
798
848
  } else {
799
- await startDaemonFromSource(assistantIndex);
849
+ await startDaemonFromSource(assistantIndex, resources);
800
850
 
801
- const vellumDir = join(homedir(), ".vellum");
802
- const socketFile = join(vellumDir, "vellum.sock");
803
- const socketReady = await waitForSocketFile(socketFile, 60000);
851
+ const socketReady = await waitForSocketFile(res.socketPath, 60000);
804
852
  if (socketReady) {
805
853
  console.log(" Assistant socket ready\n");
806
854
  } else {
@@ -815,8 +863,11 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
815
863
  export async function startGateway(
816
864
  assistantId?: string,
817
865
  watch: boolean = false,
866
+ resources?: LocalInstanceResources,
818
867
  ): Promise<string> {
819
- const publicUrl = await discoverPublicUrl();
868
+ const effectiveGatewayPort = resources?.gatewayPort ?? GATEWAY_PORT;
869
+
870
+ const publicUrl = await discoverPublicUrl(effectiveGatewayPort);
820
871
  if (publicUrl) {
821
872
  console.log(` Public URL: ${publicUrl}`);
822
873
  }
@@ -830,16 +881,91 @@ export async function startGateway(
830
881
  process.env.GATEWAY_DEFAULT_ASSISTANT_ID ||
831
882
  loadLatestAssistant()?.assistantId;
832
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
+
833
953
  const gatewayEnv: Record<string, string> = {
834
954
  ...(process.env as Record<string, string>),
835
955
  GATEWAY_RUNTIME_PROXY_ENABLED: "true",
836
956
  GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "true",
837
- 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),
838
960
  // Skip the drain window for locally-launched gateways — there is no load
839
961
  // balancer draining connections, so waiting serves no purpose and causes
840
962
  // `vellum sleep` to SIGKILL the gateway when the CLI timeout is shorter
841
963
  // than the drain window. Respect an explicit env override.
842
964
  GATEWAY_SHUTDOWN_DRAIN_MS: process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "0",
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 } : {}),
843
969
  };
844
970
 
845
971
  if (process.env.GATEWAY_UNMAPPED_POLICY) {
@@ -851,7 +977,9 @@ export async function startGateway(
851
977
  if (resolvedAssistantId) {
852
978
  gatewayEnv.GATEWAY_DEFAULT_ASSISTANT_ID = resolvedAssistantId;
853
979
  }
854
- const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl();
980
+ const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl(
981
+ resources?.instanceDir,
982
+ );
855
983
  const ingressPublicBaseUrl =
856
984
  workspaceIngressPublicBaseUrl ??
857
985
  normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL) ??
@@ -906,11 +1034,13 @@ export async function startGateway(
906
1034
  gateway.unref();
907
1035
 
908
1036
  if (gateway.pid) {
909
- const vellumDir = join(homedir(), ".vellum");
910
- 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");
911
1041
  }
912
1042
 
913
- const gatewayUrl = publicUrl || `http://localhost:${GATEWAY_PORT}`;
1043
+ const gatewayUrl = publicUrl || `http://localhost:${effectiveGatewayPort}`;
914
1044
 
915
1045
  // Wait for the gateway to be responsive before returning. Without this,
916
1046
  // callers (e.g. displayPairingQRCode) may try to connect before the HTTP
@@ -920,9 +1050,12 @@ export async function startGateway(
920
1050
  let ready = false;
921
1051
  while (Date.now() - start < timeoutMs) {
922
1052
  try {
923
- const res = await fetch(`http://localhost:${GATEWAY_PORT}/healthz`, {
924
- signal: AbortSignal.timeout(2000),
925
- });
1053
+ const res = await fetch(
1054
+ `http://localhost:${effectiveGatewayPort}/healthz`,
1055
+ {
1056
+ signal: AbortSignal.timeout(2000),
1057
+ },
1058
+ );
926
1059
  if (res.ok) {
927
1060
  ready = true;
928
1061
  break;
@@ -944,14 +1077,21 @@ export async function startGateway(
944
1077
  }
945
1078
 
946
1079
  /**
947
- * Stop any locally-running daemon and gateway processes and clean up
948
- * PID/socket files. Called when hatch fails partway through so we don't
949
- * 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.
950
1086
  */
951
- export async function stopLocalProcesses(): Promise<void> {
952
- const vellumDir = join(homedir(), ".vellum");
953
- const daemonPidFile = join(vellumDir, "vellum.pid");
954
- 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");
955
1095
  await stopProcessByPidFile(daemonPidFile, "daemon", [socketFile]);
956
1096
 
957
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