@vellumai/cli 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -39,7 +39,7 @@ vellum-cli hatch [species] [options]
39
39
 
40
40
  #### Remote Targets
41
41
 
42
- - **`local`** -- Starts a local daemon on your machine via `bunx vellum daemon start`.
42
+ - **`local`** -- Starts the local daemon and local gateway. Gateway source resolution order is: `VELLUM_GATEWAY_DIR` override, repo source tree, then installed `@vellumai/vellum-gateway` package.
43
43
  - **`gcp`** -- Creates a GCP Compute Engine VM (`e2-standard-4`: 4 vCPUs, 16 GB) with a startup script that bootstraps the assistant. Requires `gcloud` authentication and `GCP_PROJECT` / `GCP_DEFAULT_ZONE` environment variables.
44
44
  - **`aws`** -- Provisions an AWS instance.
45
45
  - **`custom`** -- Provisions on an arbitrary SSH host. Set `VELLUM_CUSTOM_HOST` (e.g. `user@hostname`) to specify the target.
@@ -52,6 +52,8 @@ vellum-cli hatch [species] [options]
52
52
  | `GCP_PROJECT` | `gcp` | GCP project ID. Falls back to the active `gcloud` project. |
53
53
  | `GCP_DEFAULT_ZONE` | `gcp` | GCP zone for the compute instance. |
54
54
  | `VELLUM_CUSTOM_HOST` | `custom` | SSH host in `user@hostname` format. |
55
+ | `VELLUM_GATEWAY_DIR` | `local` | Optional absolute path to a local gateway source directory to run instead of the packaged gateway. |
56
+ | `INGRESS_PUBLIC_BASE_URL` | `local` | Optional fallback public ingress URL when `ingress.publicBaseUrl` is not set in workspace config. |
55
57
 
56
58
  #### Examples
57
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  import { spawn } from "child_process";
2
2
  import { randomBytes } from "crypto";
3
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
4
4
  import { createRequire } from "module";
5
- import { tmpdir, userInfo } from "os";
5
+ import { tmpdir, userInfo, homedir } from "os";
6
6
  import { dirname, join } from "path";
7
7
 
8
8
  import { buildOpenclawStartupScript } from "../adapters/openclaw";
@@ -26,8 +26,18 @@ import { exec, execOutput } from "../lib/step-runner";
26
26
  const _require = createRequire(import.meta.url);
27
27
 
28
28
  const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";
29
- const INSTALL_SCRIPT_PATH = join(import.meta.dir, "..", "adapters", "install.sh");
30
29
  const MACHINE_TYPE = "e2-standard-4"; // 4 vCPUs, 16 GB memory
30
+
31
+ // Resolve the install script path. In source tree, use the file directly.
32
+ // In compiled binary ($bunfs), the file may not be available.
33
+ async function resolveInstallScriptPath(): Promise<string | null> {
34
+ const sourcePath = join(import.meta.dir, "..", "adapters", "install.sh");
35
+ if (existsSync(sourcePath)) {
36
+ return sourcePath;
37
+ }
38
+ console.warn("⚠️ Install script not found at", sourcePath, "(expected in compiled binary)");
39
+ return null;
40
+ }
31
41
  const HATCH_TIMEOUT_MS: Record<Species, number> = {
32
42
  vellum: 2 * 60 * 1000,
33
43
  openclaw: 10 * 60 * 1000,
@@ -84,6 +94,8 @@ export async function buildStartupScript(
84
94
  bearerToken: string,
85
95
  sshUser: string,
86
96
  anthropicApiKey: string,
97
+ instanceName: string,
98
+ cloud: RemoteHost,
87
99
  ): Promise<string> {
88
100
  const platformUrl = process.env.VELLUM_ASSISTANT_PLATFORM_URL ?? "https://assistant.vellum.ai";
89
101
  const timestampRedirect = buildTimestampRedirect();
@@ -101,7 +113,7 @@ export async function buildStartupScript(
101
113
  );
102
114
  }
103
115
 
104
- const interfacesSeed = buildInterfacesSeed();
116
+ const interfacesSeed = await buildInterfacesSeed();
105
117
 
106
118
  return `#!/bin/bash
107
119
  set -e
@@ -113,6 +125,8 @@ ${userSetup}
113
125
  ANTHROPIC_API_KEY=${anthropicApiKey}
114
126
  GATEWAY_RUNTIME_PROXY_ENABLED=true
115
127
  RUNTIME_PROXY_BEARER_TOKEN=${bearerToken}
128
+ VELLUM_ASSISTANT_NAME=${instanceName}
129
+ VELLUM_CLOUD=${cloud}
116
130
  ${interfacesSeed}
117
131
  mkdir -p "\$HOME/.vellum"
118
132
  cat > "\$HOME/.vellum/.env" << DOTENV_EOF
@@ -121,6 +135,7 @@ GATEWAY_RUNTIME_PROXY_ENABLED=\$GATEWAY_RUNTIME_PROXY_ENABLED
121
135
  RUNTIME_PROXY_BEARER_TOKEN=\$RUNTIME_PROXY_BEARER_TOKEN
122
136
  INTERFACES_SEED_DIR=\$INTERFACES_SEED_DIR
123
137
  RUNTIME_HTTP_PORT=7821
138
+ VELLUM_CLOUD=\$VELLUM_CLOUD
124
139
  DOTENV_EOF
125
140
 
126
141
  mkdir -p "\$HOME/.vellum/workspace"
@@ -135,6 +150,7 @@ CONFIG_EOF
135
150
  ${ownershipFixup}
136
151
 
137
152
  export VELLUM_SSH_USER="\$SSH_USER"
153
+ export VELLUM_ASSISTANT_NAME="\$VELLUM_ASSISTANT_NAME"
138
154
  echo "Downloading install script from ${platformUrl}/install.sh..."
139
155
  curl -fsSL ${platformUrl}/install.sh -o ${INSTALL_SCRIPT_REMOTE_PATH}
140
156
  echo "Install script downloaded (\$(wc -c < ${INSTALL_SCRIPT_REMOTE_PATH}) bytes)"
@@ -151,6 +167,7 @@ interface HatchArgs {
151
167
  detached: boolean;
152
168
  name: string | null;
153
169
  remote: RemoteHost;
170
+ daemonOnly: boolean;
154
171
  }
155
172
 
156
173
  function parseArgs(): HatchArgs {
@@ -159,11 +176,14 @@ function parseArgs(): HatchArgs {
159
176
  let detached = false;
160
177
  let name: string | null = null;
161
178
  let remote: RemoteHost = DEFAULT_REMOTE;
179
+ let daemonOnly = false;
162
180
 
163
181
  for (let i = 0; i < args.length; i++) {
164
182
  const arg = args[i];
165
183
  if (arg === "-d") {
166
184
  detached = true;
185
+ } else if (arg === "--daemon-only") {
186
+ daemonOnly = true;
167
187
  } else if (arg === "--name") {
168
188
  const next = args[i + 1];
169
189
  if (!next || next.startsWith("-")) {
@@ -186,13 +206,13 @@ function parseArgs(): HatchArgs {
186
206
  species = arg as Species;
187
207
  } else {
188
208
  console.error(
189
- `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
209
+ `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
190
210
  );
191
211
  process.exit(1);
192
212
  }
193
213
  }
194
214
 
195
- return { species, detached, name, remote };
215
+ return { species, detached, name, remote, daemonOnly };
196
216
  }
197
217
 
198
218
  export interface PollResult {
@@ -301,14 +321,16 @@ async function recoverFromCurlFailure(
301
321
  sshUser: string,
302
322
  account?: string,
303
323
  ): Promise<void> {
304
- if (!existsSync(INSTALL_SCRIPT_PATH)) {
305
- throw new Error(`Install script not found at ${INSTALL_SCRIPT_PATH}`);
324
+ const installScriptPath = await resolveInstallScriptPath();
325
+ if (!installScriptPath) {
326
+ console.warn("⚠️ Skipping install script upload (not available in compiled binary)");
327
+ return;
306
328
  }
307
329
 
308
330
  const scpArgs = [
309
331
  "compute",
310
332
  "scp",
311
- INSTALL_SCRIPT_PATH,
333
+ installScriptPath,
312
334
  `${instanceName}:${INSTALL_SCRIPT_REMOTE_PATH}`,
313
335
  `--zone=${zone}`,
314
336
  `--project=${project}`,
@@ -502,7 +524,14 @@ async function hatchGcp(
502
524
  console.error("Error: ANTHROPIC_API_KEY environment variable is not set.");
503
525
  process.exit(1);
504
526
  }
505
- const startupScript = await buildStartupScript(species, bearerToken, sshUser, anthropicApiKey);
527
+ const startupScript = await buildStartupScript(
528
+ species,
529
+ bearerToken,
530
+ sshUser,
531
+ anthropicApiKey,
532
+ instanceName,
533
+ "gcp",
534
+ );
506
535
  const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
507
536
  writeFileSync(startupScriptPath, startupScript);
508
537
 
@@ -674,19 +703,31 @@ async function hatchCustom(
674
703
  process.exit(1);
675
704
  }
676
705
 
677
- const startupScript = await buildStartupScript(species, bearerToken, sshUser, anthropicApiKey);
706
+ const startupScript = await buildStartupScript(
707
+ species,
708
+ bearerToken,
709
+ sshUser,
710
+ anthropicApiKey,
711
+ instanceName,
712
+ "custom",
713
+ );
678
714
  const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
679
715
  writeFileSync(startupScriptPath, startupScript);
680
716
 
681
717
  try {
682
- console.log("📋 Uploading install script to instance...");
683
- await exec("scp", [
684
- "-o", "StrictHostKeyChecking=no",
685
- "-o", "UserKnownHostsFile=/dev/null",
686
- "-o", "LogLevel=ERROR",
687
- INSTALL_SCRIPT_PATH,
688
- `${host}:${INSTALL_SCRIPT_REMOTE_PATH}`,
689
- ]);
718
+ const installScriptPath = await resolveInstallScriptPath();
719
+ if (installScriptPath) {
720
+ console.log("📋 Uploading install script to instance...");
721
+ await exec("scp", [
722
+ "-o", "StrictHostKeyChecking=no",
723
+ "-o", "UserKnownHostsFile=/dev/null",
724
+ "-o", "LogLevel=ERROR",
725
+ installScriptPath,
726
+ `${host}:${INSTALL_SCRIPT_REMOTE_PATH}`,
727
+ ]);
728
+ } else {
729
+ console.warn("⚠️ Skipping install script upload (not available in compiled binary)");
730
+ }
690
731
 
691
732
  console.log("📋 Uploading startup script to instance...");
692
733
  const remoteStartupPath = `/tmp/${instanceName}-startup.sh`;
@@ -739,59 +780,239 @@ async function hatchCustom(
739
780
  }
740
781
  }
741
782
 
783
+ function isGatewaySourceDir(dir: string): boolean {
784
+ return existsSync(join(dir, "package.json")) && existsSync(join(dir, "src", "index.ts"));
785
+ }
786
+
787
+ function findGatewaySourceFromCwd(): string | undefined {
788
+ let current = process.cwd();
789
+ while (true) {
790
+ if (isGatewaySourceDir(current)) {
791
+ return current;
792
+ }
793
+ const nestedCandidate = join(current, "gateway");
794
+ if (isGatewaySourceDir(nestedCandidate)) {
795
+ return nestedCandidate;
796
+ }
797
+ const parent = dirname(current);
798
+ if (parent === current) {
799
+ return undefined;
800
+ }
801
+ current = parent;
802
+ }
803
+ }
804
+
742
805
  function resolveGatewayDir(): string {
806
+ const override = process.env.VELLUM_GATEWAY_DIR?.trim();
807
+ if (override) {
808
+ if (!isGatewaySourceDir(override)) {
809
+ throw new Error(
810
+ `VELLUM_GATEWAY_DIR is set to "${override}", but it is not a valid gateway source directory.`,
811
+ );
812
+ }
813
+ return override;
814
+ }
815
+
743
816
  const sourceDir = join(import.meta.dir, "..", "..", "..", "gateway");
744
- if (existsSync(sourceDir)) {
817
+ if (isGatewaySourceDir(sourceDir)) {
745
818
  return sourceDir;
746
819
  }
747
820
 
821
+ const cwdSourceDir = findGatewaySourceFromCwd();
822
+ if (cwdSourceDir) {
823
+ return cwdSourceDir;
824
+ }
825
+
748
826
  try {
749
827
  const pkgPath = _require.resolve("@vellumai/vellum-gateway/package.json");
750
828
  return dirname(pkgPath);
751
829
  } catch {
752
830
  throw new Error(
753
- "Gateway not found. Ensure @vellumai/vellum-gateway is installed or run from the source tree.",
831
+ "Gateway not found. Ensure @vellumai/vellum-gateway is installed, run from the source tree, or set VELLUM_GATEWAY_DIR.",
754
832
  );
755
833
  }
756
834
  }
757
835
 
758
- async function hatchLocal(species: Species, name: string | null): Promise<void> {
759
- const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
836
+ function normalizeIngressUrl(value: unknown): string | undefined {
837
+ if (typeof value !== "string") return undefined;
838
+ const normalized = value.trim().replace(/\/+$/, "");
839
+ return normalized || undefined;
840
+ }
841
+
842
+ function readWorkspaceIngressPublicBaseUrl(): string | undefined {
843
+ const baseDataDir = process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir());
844
+ const workspaceConfigPath = join(baseDataDir, ".vellum", "workspace", "config.json");
845
+ try {
846
+ const raw = JSON.parse(readFileSync(workspaceConfigPath, "utf-8")) as Record<string, unknown>;
847
+ const ingress = raw.ingress as Record<string, unknown> | undefined;
848
+ return normalizeIngressUrl(ingress?.publicBaseUrl);
849
+ } catch {
850
+ return undefined;
851
+ }
852
+ }
853
+
854
+ async function discoverPublicUrl(): Promise<string | undefined> {
855
+ const cloud = process.env.VELLUM_CLOUD;
856
+ if (!cloud || cloud === "local") {
857
+ return `http://localhost:${GATEWAY_PORT}`;
858
+ }
859
+
860
+ let externalIp: string | undefined;
861
+ try {
862
+ if (cloud === "gcp") {
863
+ const resp = await fetch(
864
+ "http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
865
+ { headers: { "Metadata-Flavor": "Google" } },
866
+ );
867
+ if (resp.ok) externalIp = (await resp.text()).trim();
868
+ } else if (cloud === "aws") {
869
+ // Use IMDSv2 (token-based) for compatibility with HttpTokens=required
870
+ const tokenResp = await fetch(
871
+ "http://169.254.169.254/latest/api/token",
872
+ { method: "PUT", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" } },
873
+ );
874
+ if (tokenResp.ok) {
875
+ const token = await tokenResp.text();
876
+ const ipResp = await fetch(
877
+ "http://169.254.169.254/latest/meta-data/public-ipv4",
878
+ { headers: { "X-aws-ec2-metadata-token": token } },
879
+ );
880
+ if (ipResp.ok) externalIp = (await ipResp.text()).trim();
881
+ }
882
+ }
883
+ } catch {
884
+ // metadata service not reachable
885
+ }
886
+
887
+ if (externalIp) {
888
+ console.log(` Discovered external IP: ${externalIp}`);
889
+ return `http://${externalIp}:${GATEWAY_PORT}`;
890
+ }
891
+ return undefined;
892
+ }
893
+
894
+ async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false): Promise<void> {
895
+ const instanceName =
896
+ name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`;
760
897
 
761
898
  console.log(`🥚 Hatching local assistant: ${instanceName}`);
762
899
  console.log(` Species: ${species}`);
763
900
  console.log("");
764
901
 
765
- console.log("🔨 Starting local daemon...");
766
-
767
902
  if (process.env.VELLUM_DESKTOP_APP) {
903
+ // When running inside the desktop app, the CLI owns the daemon lifecycle.
904
+ // Find the vellum-daemon binary adjacent to the CLI binary.
768
905
  const daemonBinary = join(dirname(process.execPath), "vellum-daemon");
769
- const child = spawn(daemonBinary, [], {
770
- detached: true,
771
- stdio: "ignore",
772
- env: { ...process.env },
773
- });
774
- child.unref();
775
-
776
- const homeDir = process.env.HOME ?? userInfo().homedir;
777
- const socketPath = join(homeDir, ".vellum", "vellum.sock");
778
- const maxWait = 10000;
779
- const pollInterval = 100;
780
- let waited = 0;
781
- while (waited < maxWait) {
782
- if (existsSync(socketPath)) {
783
- break;
906
+ if (!existsSync(daemonBinary)) {
907
+ throw new Error(
908
+ `vellum-daemon binary not found at ${daemonBinary}.\n` +
909
+ " Ensure the daemon binary is bundled alongside the CLI in the app bundle.",
910
+ );
911
+ }
912
+
913
+ const vellumDir = join(homedir(), ".vellum");
914
+ const pidFile = join(vellumDir, "vellum.pid");
915
+ const socketFile = join(vellumDir, "vellum.sock");
916
+
917
+ // If a daemon is already running, skip spawning a new one.
918
+ // This prevents cascading kill→restart cycles when multiple callers
919
+ // invoke hatch() concurrently (setupDaemonClient + ensureDaemonConnected).
920
+ let daemonAlive = false;
921
+ if (existsSync(pidFile)) {
922
+ try {
923
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
924
+ if (!isNaN(pid)) {
925
+ try {
926
+ process.kill(pid, 0); // Check if alive
927
+ daemonAlive = true;
928
+ console.log(` Daemon already running (pid ${pid})\n`);
929
+ } catch {
930
+ // Process doesn't exist, clean up stale PID file
931
+ try { unlinkSync(pidFile); } catch {}
932
+ }
933
+ }
934
+ } catch {}
935
+ }
936
+
937
+ if (!daemonAlive) {
938
+ // Remove stale socket so we can detect the fresh one
939
+ try { unlinkSync(socketFile); } catch {}
940
+
941
+ console.log("🔨 Starting daemon...");
942
+
943
+ // Ensure ~/.vellum/ exists for PID/socket files
944
+ mkdirSync(vellumDir, { recursive: true });
945
+
946
+ // Build a minimal environment for the daemon. When launched from the
947
+ // macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
948
+ // __CFBundleIdentifier, CLAUDE_CODE_ENTRYPOINT, etc.) that can cause
949
+ // the daemon to take 50+ seconds to start instead of ~1s.
950
+ const daemonEnv: Record<string, string> = {
951
+ HOME: process.env.HOME || homedir(),
952
+ PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
953
+ VELLUM_DAEMON_TCP_ENABLED: "1",
954
+ };
955
+ // Forward optional config env vars the daemon may need
956
+ for (const key of [
957
+ "ANTHROPIC_API_KEY",
958
+ "BASE_DATA_DIR",
959
+ "VELLUM_DAEMON_TCP_PORT",
960
+ "VELLUM_DAEMON_TCP_HOST",
961
+ "VELLUM_DAEMON_SOCKET",
962
+ "VELLUM_DEBUG",
963
+ "SENTRY_DSN",
964
+ "TMPDIR",
965
+ "USER",
966
+ "LANG",
967
+ ]) {
968
+ if (process.env[key]) {
969
+ daemonEnv[key] = process.env[key]!;
970
+ }
971
+ }
972
+
973
+ const child = spawn(daemonBinary, [], {
974
+ detached: true,
975
+ stdio: "ignore",
976
+ env: daemonEnv,
977
+ });
978
+ child.unref();
979
+
980
+ // Write PID file immediately so the health monitor can find the process
981
+ // and concurrent hatch() calls see it as alive.
982
+ if (child.pid) {
983
+ writeFileSync(pidFile, String(child.pid), "utf-8");
784
984
  }
785
- await new Promise((r) => setTimeout(r, pollInterval));
786
- waited += pollInterval;
787
985
  }
788
- if (!existsSync(socketPath)) {
789
- console.warn("⚠️ Daemon socket did not appear within 10s continuing anyway");
986
+
987
+ // Wait for socket at ~/.vellum/vellum.sock (up to 15s)
988
+ if (!existsSync(socketFile)) {
989
+ const maxWait = 15000;
990
+ const start = Date.now();
991
+ while (Date.now() - start < maxWait) {
992
+ if (existsSync(socketFile)) {
993
+ break;
994
+ }
995
+ await new Promise((r) => setTimeout(r, 100));
996
+ }
997
+ }
998
+ if (existsSync(socketFile)) {
999
+ console.log(" Daemon socket ready\n");
1000
+ } else {
1001
+ console.log(" ⚠️ Daemon socket did not appear within 15s — continuing anyway\n");
790
1002
  }
791
1003
  } else {
1004
+ console.log("🔨 Starting local daemon...");
1005
+
1006
+ // Source tree layout: cli/src/commands/ -> ../../.. -> repo root -> assistant/src/index.ts
792
1007
  const sourceTreeIndex = join(import.meta.dir, "..", "..", "..", "assistant", "src", "index.ts");
1008
+ // bunx layout: @vellumai/cli/src/commands/ -> ../../../.. -> node_modules/ -> vellum/src/index.ts
1009
+ const bunxIndex = join(import.meta.dir, "..", "..", "..", "..", "vellum", "src", "index.ts");
793
1010
  let assistantIndex = sourceTreeIndex;
794
1011
 
1012
+ if (!existsSync(assistantIndex)) {
1013
+ assistantIndex = bunxIndex;
1014
+ }
1015
+
795
1016
  if (!existsSync(assistantIndex)) {
796
1017
  try {
797
1018
  const vellumPkgPath = _require.resolve("vellum/package.json");
@@ -825,44 +1046,77 @@ async function hatchLocal(species: Species, name: string | null): Promise<void>
825
1046
  });
826
1047
  }
827
1048
 
828
- console.log("🌐 Starting gateway...");
829
- const gatewayDir = resolveGatewayDir();
830
- const gateway = spawn("bun", ["run", "src/index.ts"], {
831
- cwd: gatewayDir,
832
- detached: true,
833
- stdio: "ignore",
834
- env: {
835
- ...process.env,
1049
+ // The desktop app communicates with the daemon directly via Unix socket,
1050
+ // so the HTTP gateway is only needed for non-desktop (CLI) usage.
1051
+ let runtimeUrl: string;
1052
+
1053
+ if (process.env.VELLUM_DESKTOP_APP) {
1054
+ // No gateway needed — the macOS app uses DaemonClient over the Unix socket.
1055
+ runtimeUrl = "local";
1056
+ } else {
1057
+ const publicUrl = await discoverPublicUrl();
1058
+ if (publicUrl) {
1059
+ console.log(` Public URL: ${publicUrl}`);
1060
+ }
1061
+
1062
+ console.log("🌐 Starting gateway...");
1063
+ const gatewayDir = resolveGatewayDir();
1064
+ const gatewayEnv: Record<string, string> = {
1065
+ ...process.env as Record<string, string>,
836
1066
  GATEWAY_RUNTIME_PROXY_ENABLED: "true",
837
1067
  GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
838
- },
839
- });
840
- gateway.unref();
841
- console.log("✅ Gateway started\n");
1068
+ };
1069
+ const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl();
1070
+ const ingressPublicBaseUrl =
1071
+ workspaceIngressPublicBaseUrl
1072
+ ?? normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL);
1073
+ if (ingressPublicBaseUrl) {
1074
+ gatewayEnv.INGRESS_PUBLIC_BASE_URL = ingressPublicBaseUrl;
1075
+ console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
1076
+ if (!workspaceIngressPublicBaseUrl) {
1077
+ console.log(" (using INGRESS_PUBLIC_BASE_URL env fallback)");
1078
+ }
1079
+ }
1080
+ if (publicUrl) gatewayEnv.GATEWAY_PUBLIC_URL = publicUrl;
842
1081
 
843
- const runtimeUrl = `http://localhost:${GATEWAY_PORT}`;
1082
+ const gateway = spawn("bun", ["run", "src/index.ts"], {
1083
+ cwd: gatewayDir,
1084
+ detached: true,
1085
+ stdio: "ignore",
1086
+ env: gatewayEnv,
1087
+ });
1088
+ gateway.unref();
1089
+ console.log("✅ Gateway started\n");
1090
+ runtimeUrl = publicUrl || `http://localhost:${GATEWAY_PORT}`;
1091
+ }
1092
+
1093
+ const baseDataDir = join(process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? userInfo().homedir), ".vellum");
844
1094
  const localEntry: AssistantEntry = {
845
1095
  assistantId: instanceName,
846
1096
  runtimeUrl,
1097
+ baseDataDir,
847
1098
  cloud: "local",
848
1099
  species,
849
1100
  hatchedAt: new Date().toISOString(),
850
1101
  };
851
- saveAssistantEntry(localEntry);
1102
+ if (!daemonOnly) {
1103
+ saveAssistantEntry(localEntry);
852
1104
 
853
- console.log("");
854
- console.log(`✅ Local assistant hatched!`);
855
- console.log("");
856
- console.log("Instance details:");
857
- console.log(` Name: ${instanceName}`);
858
- console.log(` Runtime: ${runtimeUrl}`);
859
- console.log("");
1105
+ console.log("");
1106
+ console.log(`✅ Local assistant hatched!`);
1107
+ console.log("");
1108
+ console.log("Instance details:");
1109
+ console.log(` Name: ${instanceName}`);
1110
+ console.log(` Runtime: ${runtimeUrl}`);
1111
+ console.log("");
1112
+ }
860
1113
  }
861
1114
 
862
1115
  function getCliVersion(): string {
863
1116
  try {
864
- const pkgPath = join(import.meta.dir, "..", "..", "package.json");
865
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1117
+ // Use createRequire for JSON import works in both Bun dev and compiled binary.
1118
+ const require = createRequire(import.meta.url);
1119
+ const pkg = require("../../package.json") as { version?: string };
866
1120
  return pkg.version ?? "unknown";
867
1121
  } catch {
868
1122
  return "unknown";
@@ -873,10 +1127,10 @@ export async function hatch(): Promise<void> {
873
1127
  const cliVersion = getCliVersion();
874
1128
  console.log(`@vellumai/cli v${cliVersion}`);
875
1129
 
876
- const { species, detached, name, remote } = parseArgs();
1130
+ const { species, detached, name, remote, daemonOnly } = parseArgs();
877
1131
 
878
1132
  if (remote === "local") {
879
- await hatchLocal(species, name);
1133
+ await hatchLocal(species, name, daemonOnly);
880
1134
  return;
881
1135
  }
882
1136
 
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "child_process";
2
- import { rmSync } from "fs";
2
+ import { existsSync, readFileSync, rmSync, unlinkSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { join } from "path";
5
5
 
@@ -34,30 +34,68 @@ function extractHostFromUrl(url: string): string {
34
34
  async function retireLocal(): Promise<void> {
35
35
  console.log("\u{1F5D1}\ufe0f Stopping local daemon...\n");
36
36
 
37
- try {
38
- const child = spawn("bunx", ["vellum", "daemon", "stop"], {
39
- stdio: "inherit",
40
- });
41
-
42
- await new Promise<void>((resolve) => {
43
- child.on("close", () => resolve());
44
- child.on("error", () => resolve());
45
- });
46
- } catch {}
47
-
48
- try {
49
- const killGateway = spawn("pkill", ["-f", "gateway/src/index.ts"], {
50
- stdio: "ignore",
51
- });
37
+ const vellumDir = join(homedir(), ".vellum");
38
+ const isDesktopApp = !!process.env.VELLUM_DESKTOP_APP;
39
+
40
+ // Stop daemon via PID file (works for both desktop app and standalone)
41
+ const pidFile = join(vellumDir, "vellum.pid");
42
+ const socketFile = join(vellumDir, "vellum.sock");
43
+
44
+ if (existsSync(pidFile)) {
45
+ try {
46
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
47
+ if (!isNaN(pid)) {
48
+ try {
49
+ process.kill(pid, 0); // Check if alive
50
+ process.kill(pid, "SIGTERM");
51
+ const deadline = Date.now() + 2000;
52
+ while (Date.now() < deadline) {
53
+ try {
54
+ process.kill(pid, 0);
55
+ await new Promise((r) => setTimeout(r, 100));
56
+ } catch {
57
+ break;
58
+ }
59
+ }
60
+ try {
61
+ process.kill(pid, 0);
62
+ process.kill(pid, "SIGKILL");
63
+ } catch {}
64
+ } catch {}
65
+ }
66
+ } catch {}
67
+ try { unlinkSync(pidFile); } catch {}
68
+ try { unlinkSync(socketFile); } catch {}
69
+ }
52
70
 
53
- await new Promise<void>((resolve) => {
54
- killGateway.on("close", () => resolve());
55
- killGateway.on("error", () => resolve());
56
- });
57
- } catch {}
71
+ if (!isDesktopApp) {
72
+ // Non-desktop: also stop daemon via bunx (fallback) and kill gateway
73
+ try {
74
+ const child = spawn("bunx", ["vellum", "daemon", "stop"], {
75
+ stdio: "inherit",
76
+ });
77
+
78
+ await new Promise<void>((resolve) => {
79
+ child.on("close", () => resolve());
80
+ child.on("error", () => resolve());
81
+ });
82
+ } catch {}
83
+
84
+ try {
85
+ const killGateway = spawn("pkill", ["-f", "gateway/src/index.ts"], {
86
+ stdio: "ignore",
87
+ });
88
+
89
+ await new Promise<void>((resolve) => {
90
+ killGateway.on("close", () => resolve());
91
+ killGateway.on("error", () => resolve());
92
+ });
93
+ } catch {}
94
+
95
+ // Only delete ~/.vellum in non-desktop mode
96
+ rmSync(vellumDir, { recursive: true, force: true });
97
+ }
58
98
 
59
- const vellumDir = join(homedir(), ".vellum");
60
- rmSync(vellumDir, { recursive: true, force: true });
61
99
  console.log("\u2705 Local instance retired.");
62
100
  }
63
101
 
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync, unlinkSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ export async function sleep(): Promise<void> {
6
+ const vellumDir = join(homedir(), ".vellum");
7
+ const pidFile = join(vellumDir, "vellum.pid");
8
+ const socketFile = join(vellumDir, "vellum.sock");
9
+
10
+ if (!existsSync(pidFile)) {
11
+ console.log("No daemon PID file found — nothing to stop.");
12
+ process.exit(0);
13
+ }
14
+
15
+ const pidStr = readFileSync(pidFile, "utf-8").trim();
16
+ const pid = parseInt(pidStr, 10);
17
+
18
+ if (isNaN(pid)) {
19
+ console.log("Invalid PID file contents — cleaning up.");
20
+ try { unlinkSync(pidFile); } catch {}
21
+ try { unlinkSync(socketFile); } catch {}
22
+ process.exit(0);
23
+ }
24
+
25
+ // Check if process is alive
26
+ try {
27
+ process.kill(pid, 0);
28
+ } catch {
29
+ console.log(`Daemon process ${pid} is not running — cleaning up stale files.`);
30
+ try { unlinkSync(pidFile); } catch {}
31
+ try { unlinkSync(socketFile); } catch {}
32
+ process.exit(0);
33
+ }
34
+
35
+ console.log(`Stopping daemon (pid ${pid})...`);
36
+ process.kill(pid, "SIGTERM");
37
+
38
+ // Wait up to 2s for graceful exit
39
+ const deadline = Date.now() + 2000;
40
+ while (Date.now() < deadline) {
41
+ try {
42
+ process.kill(pid, 0);
43
+ await new Promise((r) => setTimeout(r, 100));
44
+ } catch {
45
+ break; // Process exited
46
+ }
47
+ }
48
+
49
+ // Force kill if still alive
50
+ try {
51
+ process.kill(pid, 0);
52
+ console.log("Daemon did not exit after SIGTERM, sending SIGKILL...");
53
+ process.kill(pid, "SIGKILL");
54
+ } catch {
55
+ // Already dead
56
+ }
57
+
58
+ // Clean up PID and socket files
59
+ try { unlinkSync(pidFile); } catch {}
60
+ try { unlinkSync(socketFile); } catch {}
61
+
62
+ console.log("Daemon stopped.");
63
+ }
package/src/index.ts CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  import { hatch } from "./commands/hatch";
4
4
  import { retire } from "./commands/retire";
5
+ import { sleep } from "./commands/sleep";
5
6
 
6
7
  const commands = {
7
8
  hatch,
8
9
  retire,
10
+ sleep,
9
11
  } as const;
10
12
 
11
13
  type CommandName = keyof typeof commands;
@@ -20,6 +22,7 @@ async function main() {
20
22
  console.log("Commands:");
21
23
  console.log(" hatch Create a new assistant instance");
22
24
  console.log(" retire Delete an assistant instance");
25
+ console.log(" sleep Stop the daemon process");
23
26
  process.exit(0);
24
27
  }
25
28
 
@@ -5,6 +5,7 @@ import { join } from "path";
5
5
  export interface AssistantEntry {
6
6
  assistantId: string;
7
7
  runtimeUrl: string;
8
+ baseDataDir?: string;
8
9
  bearerToken?: string;
9
10
  cloud: string;
10
11
  instanceId?: string;
package/src/lib/aws.ts CHANGED
@@ -420,7 +420,14 @@ export async function hatchAws(
420
420
  console.log("\u{1F50D} Finding latest Debian AMI...");
421
421
  const amiId = await getLatestDebianAmi(region);
422
422
 
423
- const startupScript = await buildStartupScript(species, bearerToken, sshUser, anthropicApiKey);
423
+ const startupScript = await buildStartupScript(
424
+ species,
425
+ bearerToken,
426
+ sshUser,
427
+ anthropicApiKey,
428
+ instanceName,
429
+ "aws",
430
+ );
424
431
  const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
425
432
  writeFileSync(startupScriptPath, startupScript);
426
433
 
package/src/lib/gcp.ts CHANGED
@@ -354,6 +354,7 @@ export async function retireInstance(
354
354
  name,
355
355
  `--project=${project}`,
356
356
  `--zone=${zone}`,
357
+ "--quiet",
357
358
  ],
358
359
  { stdio: "inherit" },
359
360
  );
@@ -1,21 +1,28 @@
1
- import { join } from "path";
1
+ // Read source files using Bun.file() with string concatenation (not join())
2
+ // so Bun's bundler can statically analyze the paths and embed the files
3
+ // in the compiled binary ($bunfs). Files must also be passed via --embed
4
+ // in the bun build --compile invocation.
2
5
 
3
- const constantsSource = await Bun.file(join(import.meta.dir, "constants.ts")).text();
4
- const defaultMainScreenSource = await Bun.file(join(import.meta.dir, "..", "components", "DefaultMainScreen.tsx")).text();
5
-
6
- function inlineLocalImports(source: string): string {
6
+ function inlineLocalImports(source: string, constantsSource: string): string {
7
7
  return source
8
8
  .replace(/import\s*\{[^}]*\}\s*from\s*["'][^"']*\/constants["'];?\s*\n/, constantsSource + "\n")
9
9
  .replace(/import\s*\{[^}]*\}\s*from\s*["']path["'];?\s*\n/, "");
10
10
  }
11
11
 
12
- export function buildInterfacesSeed(): string {
13
- const mainWindowSource = inlineLocalImports(defaultMainScreenSource);
12
+ export async function buildInterfacesSeed(): Promise<string> {
13
+ try {
14
+ const constantsSource = await Bun.file(import.meta.dir + "/constants.ts").text();
15
+ const defaultMainScreenSource = await Bun.file(import.meta.dir + "/../components/DefaultMainScreen.tsx").text();
16
+ const mainWindowSource = inlineLocalImports(defaultMainScreenSource, constantsSource);
14
17
 
15
- return `
18
+ return `
16
19
  INTERFACES_SEED_DIR="/tmp/interfaces-seed"
17
20
  mkdir -p "\$INTERFACES_SEED_DIR/tui"
18
21
  cat > "\$INTERFACES_SEED_DIR/tui/main-window.tsx" << 'INTERFACES_SEED_EOF'
19
22
  ${mainWindowSource}INTERFACES_SEED_EOF
20
23
  `;
24
+ } catch (err) {
25
+ console.warn("⚠️ Could not embed interfaces seed files (expected in compiled binary without --embed):", (err as Error).message);
26
+ return "# interfaces-seed: skipped (source files not available in compiled binary)";
27
+ }
21
28
  }
@@ -1,9 +1,13 @@
1
- import { join } from "path";
1
+ // Read source file using Bun.file() with string concatenation (not join())
2
+ // so Bun's bundler can statically analyze the path and embed the file
3
+ // in the compiled binary ($bunfs). The file must also be passed via --embed
4
+ // in the bun build --compile invocation.
2
5
 
3
6
  export async function buildOpenclawRuntimeServer(): Promise<string> {
4
- const serverSource = await Bun.file(join(import.meta.dir, "..", "adapters", "openclaw-http-server.ts")).text();
7
+ try {
8
+ const serverSource = await Bun.file(import.meta.dir + "/../adapters/openclaw-http-server.ts").text();
5
9
 
6
- return `
10
+ return `
7
11
  cat > /opt/openclaw-runtime-server.ts << 'RUNTIME_SERVER_EOF'
8
12
  ${serverSource}
9
13
  RUNTIME_SERVER_EOF
@@ -12,4 +16,8 @@ mkdir -p "\$HOME/.vellum"
12
16
  nohup bun run /opt/openclaw-runtime-server.ts >> "\$HOME/.vellum/http-gateway.log" 2>&1 &
13
17
  echo "OpenClaw runtime server started (PID: \$!)"
14
18
  `;
19
+ } catch (err) {
20
+ console.warn("⚠️ Could not embed openclaw runtime server (expected in compiled binary without --embed):", (err as Error).message);
21
+ return "# openclaw-runtime-server: skipped (source files not available in compiled binary)";
22
+ }
15
23
  }